Merge "Explicitly release ImageWriter resources when CaptureSessionState is shutdown" into androidx-main
diff --git a/activity/activity-compose/src/androidTest/java/androidx/activity/compose/PredictiveBackHandlerTest.kt b/activity/activity-compose/src/androidTest/java/androidx/activity/compose/PredictiveBackHandlerTest.kt
index a8e1bee..169e92a2 100644
--- a/activity/activity-compose/src/androidTest/java/androidx/activity/compose/PredictiveBackHandlerTest.kt
+++ b/activity/activity-compose/src/androidTest/java/androidx/activity/compose/PredictiveBackHandlerTest.kt
@@ -38,6 +38,7 @@
import androidx.lifecycle.testing.TestLifecycleOwner
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
import com.google.common.truth.Truth.assertThat
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.delay
@@ -173,12 +174,14 @@
}
@Test
+ @SdkSuppress(minSdkVersion = 34) // Below API 34 startGestureBack triggers back
fun testPredictiveBackHandlerDisabledBeforeStart() {
val result = mutableListOf<String>()
var count by mutableStateOf(2)
lateinit var dispatcherOwner: TestOnBackPressedDispatcherOwner
lateinit var dispatcher: OnBackPressedDispatcher
var started = false
+ var cancelled = false
rule.setContent {
dispatcherOwner =
@@ -188,8 +191,12 @@
if (count <= 1) {
started = true
}
- progress.collect()
- result += "onBack"
+ try {
+ progress.collect()
+ result += "onBack"
+ } catch (e: CancellationException) {
+ cancelled = true
+ }
}
dispatcher = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher
}
@@ -200,9 +207,12 @@
count = 1
dispatcher.startGestureBack()
+ // In a test, we don't get the launched effect fast enough to prevent starting
+ // but since we idle here, we can cancel the callback channel and keep from completing
rule.runOnIdle { assertThat(started).isTrue() }
dispatcher.api34Complete()
- rule.runOnIdle { assertThat(result).isEqualTo(listOf("onBack")) }
+ rule.runOnIdle { assertThat(result).isEmpty() }
+ rule.runOnIdle { assertThat(cancelled).isTrue() }
}
fun testPredictiveBackHandlerDisabledAfterStart() {
diff --git a/activity/activity-compose/src/main/java/androidx/activity/compose/PredictiveBackHandler.kt b/activity/activity-compose/src/main/java/androidx/activity/compose/PredictiveBackHandler.kt
index 3bdbb96..b35c8bf 100644
--- a/activity/activity-compose/src/main/java/androidx/activity/compose/PredictiveBackHandler.kt
+++ b/activity/activity-compose/src/main/java/androidx/activity/compose/PredictiveBackHandler.kt
@@ -76,63 +76,19 @@
// ensure we don't re-register callbacks when onBack changes
val currentOnBack by rememberUpdatedState(onBack)
val onBackScope = rememberCoroutineScope()
- var onBackInstance: OnBackInstance? = null
val backCallBack = remember {
- object : OnBackPressedCallback(enabled) {
-
- override fun handleOnBackStarted(backEvent: BackEventCompat) {
- super.handleOnBackStarted(backEvent)
- // in case the previous onBackInstance was started by a normal back gesture
- // we want to make sure it's still cancelled before we start a predictive
- // back gesture
- onBackInstance?.cancel()
- onBackInstance = OnBackInstance(onBackScope, true, currentOnBack)
- }
-
- override fun handleOnBackProgressed(backEvent: BackEventCompat) {
- super.handleOnBackProgressed(backEvent)
- onBackInstance?.send(backEvent)
- }
-
- override fun handleOnBackPressed() {
- // handleOnBackPressed could be called by regular back to restart
- // a new back instance. If this is the case (where current back instance
- // was NOT started by handleOnBackStarted) then we need to reset the previous
- // regular back.
- onBackInstance?.apply {
- if (!isPredictiveBack) {
- cancel()
- onBackInstance = null
- }
- }
- if (onBackInstance == null) {
- onBackInstance = OnBackInstance(onBackScope, false, currentOnBack)
- }
-
- // finally, we close the channel to ensure no more events can be sent
- // but let the job complete normally
- onBackInstance?.close()
- onBackInstance?.isPredictiveBack = false
- }
-
- override fun handleOnBackCancelled() {
- super.handleOnBackCancelled()
- // cancel will purge the channel of any sent events that are yet to be received
- onBackInstance?.cancel()
- onBackInstance?.isPredictiveBack = false
- }
- }
+ PredictiveBackHandlerCallback(enabled, onBackScope, currentOnBack)
}
- LaunchedEffect(enabled) {
- backCallBack.isEnabled = enabled
- if (!enabled) {
- onBackInstance?.close()
- onBackInstance = null
- }
+ // we want to use the same callback, but ensure we adjust the variable on recomposition
+ remember(currentOnBack, onBackScope) {
+ backCallBack.currentOnBack = currentOnBack
+ backCallBack.onBackScope = onBackScope
}
+ LaunchedEffect(enabled) { backCallBack.setIsEnabled(enabled) }
+
val backDispatcher =
checkNotNull(LocalOnBackPressedDispatcherOwner.current) {
"No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner"
@@ -153,13 +109,16 @@
scope: CoroutineScope,
var isPredictiveBack: Boolean,
onBack: suspend (progress: Flow<BackEventCompat>) -> Unit,
+ callback: OnBackPressedCallback
) {
val channel = Channel<BackEventCompat>(capacity = BUFFERED, onBufferOverflow = SUSPEND)
val job =
scope.launch {
- var completed = false
- onBack(channel.consumeAsFlow().onCompletion { completed = true })
- check(completed) { "You must collect the progress flow" }
+ if (callback.isEnabled) {
+ var completed = false
+ onBack(channel.consumeAsFlow().onCompletion { completed = true })
+ check(completed) { "You must collect the progress flow" }
+ }
}
fun send(backEvent: BackEventCompat) = channel.trySend(backEvent)
@@ -172,3 +131,63 @@
job.cancel()
}
}
+
+private class PredictiveBackHandlerCallback(
+ enabled: Boolean,
+ var onBackScope: CoroutineScope,
+ var currentOnBack: suspend (progress: Flow<BackEventCompat>) -> Unit,
+) : OnBackPressedCallback(enabled) {
+ private var onBackInstance: OnBackInstance? = null
+
+ fun setIsEnabled(enabled: Boolean) {
+ // We are disabling a callback that was enabled.
+ if (!enabled && isEnabled) {
+ onBackInstance?.cancel()
+ }
+ isEnabled = enabled
+ }
+
+ override fun handleOnBackStarted(backEvent: BackEventCompat) {
+ super.handleOnBackStarted(backEvent)
+ // in case the previous onBackInstance was started by a normal back gesture
+ // we want to make sure it's still cancelled before we start a predictive
+ // back gesture
+ onBackInstance?.cancel()
+ if (isEnabled) {
+ onBackInstance = OnBackInstance(onBackScope, true, currentOnBack, this)
+ }
+ }
+
+ override fun handleOnBackProgressed(backEvent: BackEventCompat) {
+ super.handleOnBackProgressed(backEvent)
+ onBackInstance?.send(backEvent)
+ }
+
+ override fun handleOnBackPressed() {
+ // handleOnBackPressed could be called by regular back to restart
+ // a new back instance. If this is the case (where current back instance
+ // was NOT started by handleOnBackStarted) then we need to reset the previous
+ // regular back.
+ onBackInstance?.apply {
+ if (!isPredictiveBack) {
+ cancel()
+ onBackInstance = null
+ }
+ }
+ if (onBackInstance == null) {
+ onBackInstance = OnBackInstance(onBackScope, false, currentOnBack, this)
+ }
+
+ // finally, we close the channel to ensure no more events can be sent
+ // but let the job complete normally
+ onBackInstance?.close()
+ onBackInstance?.isPredictiveBack = false
+ }
+
+ override fun handleOnBackCancelled() {
+ super.handleOnBackCancelled()
+ // cancel will purge the channel of any sent events that are yet to be received
+ onBackInstance?.cancel()
+ onBackInstance?.isPredictiveBack = false
+ }
+}
diff --git a/benchmark/benchmark-common/src/androidTest/assets/macro-legacy-trivialscrollbench.json b/benchmark/benchmark-common/src/androidTest/assets/macro-legacy-trivialscrollbench.json
index 9f828f4..fd4ad97 100644
--- a/benchmark/benchmark-common/src/androidTest/assets/macro-legacy-trivialscrollbench.json
+++ b/benchmark/benchmark-common/src/androidTest/assets/macro-legacy-trivialscrollbench.json
@@ -36,6 +36,7 @@
"minimum": 65.0,
"maximum": 65.0,
"median": 65.0,
+ "coefficientOfVariation": 0.0,
"runs": [
65.0,
65.0
@@ -45,6 +46,7 @@
"minimum": 474.123432,
"maximum": 481.377279,
"median": 477.75035549999996,
+ "coefficientOfVariation": 0.01073624403,
"runs": [
474.123432,
481.377279
@@ -69,6 +71,7 @@
"minimum": 78.0,
"maximum": 79.0,
"median": 78.5,
+ "coefficientOfVariation": 0.009007729697,
"runs": [
78.0,
79.0
@@ -78,6 +81,7 @@
"minimum": 490.891433,
"maximum": 497.585203,
"median": 494.238318,
+ "coefficientOfVariation": 0.009576777005,
"runs": [
490.891433,
497.585203
diff --git a/benchmark/benchmark-common/src/androidTest/assets/micro-legacy-trivialbench.json b/benchmark/benchmark-common/src/androidTest/assets/micro-legacy-trivialbench.json
index db96d0f..2ffda20 100644
--- a/benchmark/benchmark-common/src/androidTest/assets/micro-legacy-trivialbench.json
+++ b/benchmark/benchmark-common/src/androidTest/assets/micro-legacy-trivialbench.json
@@ -32,6 +32,7 @@
"minimum": 5.52001617001617,
"maximum": 5.658262878262878,
"median": 5.542584892584893,
+ "coefficientOfVariation": 0.006590340481,
"runs": [
5.563728343728344,
5.546317856317856,
@@ -89,6 +90,7 @@
"minimum": 0.0,
"maximum": 0.0,
"median": 0.0,
+ "coefficientOfVariation": 0.0,
"runs": [
0.0,
0.0,
@@ -113,6 +115,7 @@
"minimum": 5.52031108031108,
"maximum": 5.632142912142912,
"median": 5.548982828982829,
+ "coefficientOfVariation": 0.0,
"runs": [
5.553719873719873,
5.520551320551321,
@@ -170,6 +173,7 @@
"minimum": 0.0,
"maximum": 0.0,
"median": 0.0,
+ "coefficientOfVariation": 0.0,
"runs": [
0.0,
0.0,
diff --git a/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/MetricResultTest.kt b/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/MetricResultTest.kt
index bbfcf86..be93faa 100644
--- a/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/MetricResultTest.kt
+++ b/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/MetricResultTest.kt
@@ -35,12 +35,27 @@
}
@Test
+ fun zeros() {
+ val metricResult = MetricResult("test", listOf(0.0, 0.0))
+ assertEquals(0.0, metricResult.min, 0.0)
+ assertEquals(0.0, metricResult.max, 0.0)
+ assertEquals(0.0, metricResult.median, 0.0)
+ assertEquals(0.0, metricResult.standardDeviation, 0.0)
+ assertEquals(0.0, metricResult.coefficientOfVariation, 0.0)
+
+ assertEquals(0, metricResult.minIndex)
+ assertEquals(0, metricResult.maxIndex)
+ assertEquals(1, metricResult.medianIndex)
+ }
+
+ @Test
fun repeat() {
val metricResult = MetricResult("test", listOf(10.0, 10.0, 10.0, 10.0))
assertEquals(10.0, metricResult.min, 0.0)
assertEquals(10.0, metricResult.max, 0.0)
assertEquals(10.0, metricResult.median, 0.0)
assertEquals(0.0, metricResult.standardDeviation, 0.0)
+ assertEquals(0.0, metricResult.coefficientOfVariation, 0.0)
assertEquals(0, metricResult.minIndex)
assertEquals(0, metricResult.maxIndex)
@@ -54,6 +69,7 @@
assertEquals(10.0, metricResult.max, 0.0)
assertEquals(10.0, metricResult.median, 0.0)
assertEquals(0.0, metricResult.standardDeviation, 0.0)
+ assertEquals(0.0, metricResult.coefficientOfVariation, 0.0)
assertEquals(0, metricResult.minIndex)
assertEquals(0, metricResult.maxIndex)
@@ -67,6 +83,7 @@
assertEquals(100.0, metricResult.max, 0.0)
assertEquals(0.0, metricResult.min, 0.0)
assertEquals(29.3, metricResult.standardDeviation, 0.05)
+ assertEquals(0.586, metricResult.coefficientOfVariation, 0.0005)
assertEquals(0, metricResult.minIndex)
assertEquals(100, metricResult.maxIndex)
diff --git a/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/ResultWriterTest.kt b/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/ResultWriterTest.kt
index 638a21c..0f05072 100644
--- a/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/ResultWriterTest.kt
+++ b/benchmark/benchmark-common/src/androidTest/java/androidx/benchmark/ResultWriterTest.kt
@@ -127,6 +127,7 @@
"minimum": 100.0,
"maximum": 102.0,
"median": 101.0,
+ "coefficientOfVariation": 0.009900990099009901,
"runs": [
100.0,
101.0,
@@ -149,6 +150,7 @@
"minimum": 100.0,
"maximum": 102.0,
"median": 101.0,
+ "coefficientOfVariation": 0.009900990099009901,
"runs": [
100.0,
101.0,
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/MetricResult.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/MetricResult.kt
index 3fd1e36..d4b026d 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/MetricResult.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/MetricResult.kt
@@ -38,6 +38,7 @@
val max: Double
val maxIndex: Int
val standardDeviation: Double
+ val coefficientOfVariation: Double
val p50: Double
val p90: Double
@@ -70,6 +71,12 @@
val sum = values.map { (it - mean).pow(2) }.sum()
sqrt(sum / (size - 1).toDouble())
}
+ coefficientOfVariation =
+ if (mean == 0.0) {
+ 0.0
+ } else {
+ standardDeviation / mean
+ }
}
internal fun getSummary(): String {
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/json/BenchmarkData.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/json/BenchmarkData.kt
index 2d280cce..99dea1b 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/json/BenchmarkData.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/json/BenchmarkData.kt
@@ -225,6 +225,7 @@
val minimum: Double,
val maximum: Double,
val median: Double,
+ val coefficientOfVariation: Double,
val runs: List<Double>
) : MetricResult() {
constructor(
@@ -233,6 +234,7 @@
minimum = metricResult.min,
maximum = metricResult.max,
median = metricResult.median,
+ coefficientOfVariation = metricResult.coefficientOfVariation,
runs = metricResult.data
)
}
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt
index f3f9ffa..736ae72 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt
@@ -32,7 +32,7 @@
import androidx.camera.camera2.pipe.StreamId
import androidx.camera.camera2.pipe.config.Camera2ControllerScope
import androidx.camera.camera2.pipe.core.Log
-import androidx.camera.camera2.pipe.core.Threading.runBlockingWithTimeoutOrNull
+import androidx.camera.camera2.pipe.core.Threading.runBlockingCheckedOrNull
import androidx.camera.camera2.pipe.core.Threads
import androidx.camera.camera2.pipe.core.TimeSource
import androidx.camera.camera2.pipe.graph.GraphListener
@@ -320,7 +320,7 @@
// getting blocked for too long.
//
// [1] b/307594946 - [ANR] at Camera2CameraController.disconnectSessionAndCamera
- runBlockingWithTimeoutOrNull(threads.backgroundDispatcher, DISCONNECT_TIMEOUT_MS) {
+ runBlockingCheckedOrNull(threads.backgroundDispatcher, DISCONNECT_TIMEOUT_MS) {
deferred.await()
}
?: run {
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2DeviceCloser.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2DeviceCloser.kt
index 54f4524..a6081aa 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2DeviceCloser.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2DeviceCloser.kt
@@ -87,6 +87,7 @@
closeUnderError: Boolean,
androidCameraState: AndroidCameraState,
) {
+ Log.debug { "$this#closeCameraDevice($cameraDevice)" }
val cameraId = CameraId.fromCamera2Id(cameraDevice.id)
if (camera2Quirks.shouldCreateCaptureSessionBeforeClosing(cameraId) && !closeUnderError) {
Debug.trace("Camera2DeviceCloserImpl#createCaptureSession") {
@@ -95,7 +96,7 @@
Log.debug { "Empty capture session quirk completed" }
}
}
- Threading.runBlockingWithTimeout(threads.backgroundDispatcher, 5000L) {
+ Threading.runBlockingChecked(threads.backgroundDispatcher, 5000L) {
cameraDevice.closeWithTrace()
}
if (camera2Quirks.shouldWaitForCameraDeviceOnClosed(cameraId)) {
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/core/Threading.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/core/Threading.kt
index 1496985..d25b18a 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/core/Threading.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/core/Threading.kt
@@ -21,6 +21,7 @@
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -31,20 +32,26 @@
CoroutineScope(CoroutineName("GlobalThreadingScope") + SupervisorJob())
/**
- * runBlockingWithTime runs the specified [block] on a timeout of [timeoutMs] using the given
+ * runBlockingChecked runs the specified [block] on a timeout of [timeoutMs] using the given
* [dispatcher]. The function runs the given block asynchronously on a supervised scope,
* allowing it to return after the timeout completes, even if the calling thread is blocked.
- * Throws [kotlinx.coroutines.TimeoutCancellationException] when the execution of the [block]
- * times out.
+ * Throws [IllegalStateException] when the execution of the [block] times out.
*/
- fun <T> runBlockingWithTimeout(
+ fun <T> runBlockingChecked(
dispatcher: CoroutineDispatcher,
timeoutMs: Long,
block: suspend () -> T
- ): T? {
+ ): T {
return runBlocking {
val result = runAsyncSupervised(dispatcher, block)
- withTimeout(timeoutMs) { result.await() }
+ try {
+ withTimeout(timeoutMs) { result.await() }
+ } catch (e: TimeoutCancellationException) {
+ Log.error { "Timed out after ${timeoutMs}ms!" }
+ // For some reason, if TimeoutCancellationException is thrown, runBlocking can
+ // suspend indefinitely. Catch it and rethrow IllegalStateException.
+ throw IllegalStateException("Timed out after ${timeoutMs}ms!")
+ }
}
}
@@ -54,7 +61,7 @@
* allowing it to return after the timeout completes, even if the calling thread is blocked.
* Returns null when the execution of the [block] times out.
*/
- fun <T> runBlockingWithTimeoutOrNull(
+ fun <T> runBlockingCheckedOrNull(
dispatcher: CoroutineDispatcher,
timeoutMs: Long,
block: suspend () -> T
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/core/ThreadingTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/core/ThreadingTest.kt
index 3fefe2f..9eb385b 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/core/ThreadingTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/core/ThreadingTest.kt
@@ -23,7 +23,6 @@
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -31,10 +30,10 @@
@SdkSuppress(minSdkVersion = 21)
class ThreadingTest {
@Test
- fun runBlockingWithTimeoutThrowsOnTimeout() = runTest {
+ fun runBlockingCheckedThrowsOnTimeout() = runTest {
val latch = CountDownLatch(1)
- assertThrows<TimeoutCancellationException> {
- Threading.runBlockingWithTimeout(Dispatchers.IO, 500L) {
+ assertThrows<IllegalStateException> {
+ Threading.runBlockingChecked(Dispatchers.IO, 500L) {
// Simulate a long call that should time out.
latch.await(10, TimeUnit.SECONDS)
}
@@ -42,13 +41,32 @@
}
@Test
- fun runBlockingWithTimeoutOrNullReturnsNullOnTimeout() = runTest {
+ fun runBlockingCheckedDoesNotThrowWhenNotTimedOut() = runTest {
+ val latch = CountDownLatch(1)
+ Threading.runBlockingChecked(Dispatchers.IO, 10_000L) {
+ latch.await(500, TimeUnit.MILLISECONDS)
+ }
+ }
+
+ @Test
+ fun runBlockingCheckedOrNullReturnsNullOnTimeout() = runTest {
val latch = CountDownLatch(1)
val result =
- Threading.runBlockingWithTimeoutOrNull(Dispatchers.IO, 500L) {
+ Threading.runBlockingCheckedOrNull(Dispatchers.IO, 500L) {
// Simulate a long call that should time out.
latch.await(10, TimeUnit.SECONDS)
}
assertThat(result).isNull()
}
+
+ @Test
+ fun runBlockingCheckedOrNullReturnsNonNullWhenNotTimeout() = runTest {
+ val latch = CountDownLatch(1)
+ val result =
+ Threading.runBlockingCheckedOrNull(Dispatchers.IO, 10_000L) {
+ // Simulate a long call that should time out.
+ latch.await(500, TimeUnit.MILLISECONDS)
+ }
+ assertThat(result).isNotNull()
+ }
}
diff --git a/camera/camera-core/api/current.txt b/camera/camera-core/api/current.txt
index 475569e..ce51760 100644
--- a/camera/camera-core/api/current.txt
+++ b/camera/camera-core/api/current.txt
@@ -339,6 +339,7 @@
method public void setFlashMode(int);
method public void setScreenFlash(androidx.camera.core.ImageCapture.ScreenFlash?);
method public void setTargetRotation(int);
+ method public void takePicture(androidx.camera.core.ImageCapture.OutputFileOptions, androidx.camera.core.ImageCapture.OutputFileOptions, java.util.concurrent.Executor, androidx.camera.core.ImageCapture.OnImageSavedCallback);
method public void takePicture(androidx.camera.core.ImageCapture.OutputFileOptions, java.util.concurrent.Executor, androidx.camera.core.ImageCapture.OnImageSavedCallback);
method public void takePicture(java.util.concurrent.Executor, androidx.camera.core.ImageCapture.OnImageCapturedCallback);
field public static final int CAPTURE_MODE_MAXIMIZE_QUALITY = 0; // 0x0
@@ -355,6 +356,8 @@
field public static final int FLASH_MODE_SCREEN = 3; // 0x3
field @SuppressCompatibility @androidx.camera.core.ExperimentalImageCaptureOutputFormat public static final int OUTPUT_FORMAT_JPEG = 0; // 0x0
field @SuppressCompatibility @androidx.camera.core.ExperimentalImageCaptureOutputFormat public static final int OUTPUT_FORMAT_JPEG_ULTRA_HDR = 1; // 0x1
+ field public static final int OUTPUT_FORMAT_RAW = 2; // 0x2
+ field public static final int OUTPUT_FORMAT_RAW_JPEG = 3; // 0x3
}
public static final class ImageCapture.Builder implements androidx.camera.core.ExtendableBuilder<androidx.camera.core.ImageCapture!> {
@@ -414,6 +417,7 @@
}
public static class ImageCapture.OutputFileResults {
+ method public int getImageFormat();
method public android.net.Uri? getSavedUri();
}
diff --git a/camera/camera-core/api/restricted_current.txt b/camera/camera-core/api/restricted_current.txt
index 475569e..ce51760 100644
--- a/camera/camera-core/api/restricted_current.txt
+++ b/camera/camera-core/api/restricted_current.txt
@@ -339,6 +339,7 @@
method public void setFlashMode(int);
method public void setScreenFlash(androidx.camera.core.ImageCapture.ScreenFlash?);
method public void setTargetRotation(int);
+ method public void takePicture(androidx.camera.core.ImageCapture.OutputFileOptions, androidx.camera.core.ImageCapture.OutputFileOptions, java.util.concurrent.Executor, androidx.camera.core.ImageCapture.OnImageSavedCallback);
method public void takePicture(androidx.camera.core.ImageCapture.OutputFileOptions, java.util.concurrent.Executor, androidx.camera.core.ImageCapture.OnImageSavedCallback);
method public void takePicture(java.util.concurrent.Executor, androidx.camera.core.ImageCapture.OnImageCapturedCallback);
field public static final int CAPTURE_MODE_MAXIMIZE_QUALITY = 0; // 0x0
@@ -355,6 +356,8 @@
field public static final int FLASH_MODE_SCREEN = 3; // 0x3
field @SuppressCompatibility @androidx.camera.core.ExperimentalImageCaptureOutputFormat public static final int OUTPUT_FORMAT_JPEG = 0; // 0x0
field @SuppressCompatibility @androidx.camera.core.ExperimentalImageCaptureOutputFormat public static final int OUTPUT_FORMAT_JPEG_ULTRA_HDR = 1; // 0x1
+ field public static final int OUTPUT_FORMAT_RAW = 2; // 0x2
+ field public static final int OUTPUT_FORMAT_RAW_JPEG = 3; // 0x3
}
public static final class ImageCapture.Builder implements androidx.camera.core.ExtendableBuilder<androidx.camera.core.ImageCapture!> {
@@ -414,6 +417,7 @@
}
public static class ImageCapture.OutputFileResults {
+ method public int getImageFormat();
method public android.net.Uri? getSavedUri();
}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/ImageCapture.java b/camera/camera-core/src/main/java/androidx/camera/core/ImageCapture.java
index 3fac0e4..97b578e 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/ImageCapture.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/ImageCapture.java
@@ -316,14 +316,12 @@
/**
* Captures raw images in the {@link ImageFormat#RAW_SENSOR} image format.
*/
- @RestrictTo(Scope.LIBRARY_GROUP)
public static final int OUTPUT_FORMAT_RAW = 2;
/**
* Captures raw images in the {@link ImageFormat#RAW_SENSOR} and {@link ImageFormat#JPEG}
* image formats.
*/
- @RestrictTo(Scope.LIBRARY_GROUP)
public static final int OUTPUT_FORMAT_RAW_JPEG = 3;
/**
@@ -905,8 +903,8 @@
*
* <p>For simultaneous image capture with {@link #OUTPUT_FORMAT_RAW_JPEG}, the
* {@link OnImageCapturedCallback#onCaptureSuccess(ImageProxy)} will be triggered twice, one for
- * {@link ImageFormat#RAW_SENSOR} and the other for {@link ImageFormat#JPEG}. The order in which
- * image format is triggered first is not guaranteed.
+ * {@link ImageFormat#RAW_SENSOR} and the other for {@link ImageFormat#JPEG}. The order of the
+ * callbacks for which image format is triggered first is not guaranteed.
*
* @param executor The executor in which the callback methods will be run.
* @param callback Callback to be invoked for the newly captured image.
@@ -937,7 +935,8 @@
* @param imageSavedCallback Callback to be called for the newly captured image.
*
* @throws IllegalArgumentException If {@link ImageCapture#FLASH_MODE_SCREEN} is used without a
- * a non-null {@code ScreenFlash} instance set.
+ * a non-null {@code ScreenFlash} instance set. Also if
+ * {@link ImageCapture#OUTPUT_FORMAT_RAW_JPEG} is used.
* @see ViewPort
*/
public void takePicture(
@@ -961,7 +960,9 @@
* <p>Currently only {@link #OUTPUT_FORMAT_RAW_JPEG} is supporting simultaneous image capture.
* It needs two {@link OutputFileOptions}, the first one is used for
* {@link ImageFormat#RAW_SENSOR} image and the second one is for {@link ImageFormat#JPEG}. The
- * order in which image format is triggered first is not guaranteed.
+ * order of the callbacks for which image format is triggered first is not guaranteed. Check
+ * with {@link OutputFileResults#getImageFormat()} in
+ * {@link OnImageSavedCallback#onImageSaved(OutputFileResults)} for the image format.
*
* @param rawOutputFileOptions Options to store the newly captured raw image.
* @param jpegOutputFileOptions Options to store the newly captured jpeg image.
@@ -969,9 +970,10 @@
* @param imageSavedCallback Callback to be called for the newly captured image.
*
* @throws IllegalArgumentException If {@link ImageCapture#FLASH_MODE_SCREEN} is used without a
- * a non-null {@code ScreenFlash} instance set.
+ * a non-null {@code ScreenFlash} instance set. Also if
+ * non-{@link ImageCapture#OUTPUT_FORMAT_RAW_JPEG} format is
+ * used.
*/
- @RestrictTo(Scope.LIBRARY_GROUP)
public void takePicture(
final @NonNull OutputFileOptions rawOutputFileOptions,
final @NonNull OutputFileOptions jpegOutputFileOptions,
@@ -1476,6 +1478,16 @@
sendInvalidCameraError(executor, inMemoryCallback, onDiskCallback);
return;
}
+ boolean isSimultaneousCapture = getCurrentConfig()
+ .getSecondaryInputFormat() != ImageFormat.UNKNOWN;
+ if (isSimultaneousCapture && secondaryOutputFileOptions == null) {
+ throw new IllegalArgumentException(
+ "Simultaneous capture RAW and JPEG needs two output file options");
+ }
+ if (!isSimultaneousCapture && secondaryOutputFileOptions != null) {
+ throw new IllegalArgumentException(
+ "Non simultaneous capture cannot have two output file options");
+ }
requireNonNull(mTakePictureManager).offerRequest(TakePictureRequest.of(
executor,
inMemoryCallback,
@@ -1487,7 +1499,7 @@
getRelativeRotation(camera),
getJpegQualityInternal(),
getCaptureMode(),
- getCurrentConfig().getSecondaryInputFormat() != ImageFormat.UNKNOWN,
+ isSimultaneousCapture,
mSessionConfigBuilder.getSingleCameraCaptureCallbacks()));
}
@@ -2227,7 +2239,6 @@
/**
* Returns the {@link ImageFormat} of the saved file.
*/
- @RestrictTo(Scope.LIBRARY_GROUP)
public int getImageFormat() {
return mImageFormat;
}
diff --git a/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java b/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java
index 8609e1b..69e5e0e 100644
--- a/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java
+++ b/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java
@@ -976,7 +976,6 @@
}
}
- @SuppressLint("RestrictedApiAndroidX")
private void setUpTakePictureButton() {
mTakePicture.setOnClickListener(
new View.OnClickListener() {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/BasicMarquee.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/BasicMarquee.kt
index a3b1549..e02b5cf 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/BasicMarquee.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/BasicMarquee.kt
@@ -45,6 +45,8 @@
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.graphics.layer.GraphicsLayer
+import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.layout.IntrinsicMeasurable
import androidx.compose.ui.layout.IntrinsicMeasureScope
import androidx.compose.ui.layout.LayoutCoordinates
@@ -55,11 +57,13 @@
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.requireDensity
+import androidx.compose.ui.node.requireGraphicsContext
import androidx.compose.ui.node.requireLayoutDirection
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.dp
@@ -208,6 +212,7 @@
private var containerWidth by mutableIntStateOf(0)
private var hasFocus by mutableStateOf(false)
private var animationJob: Job? = null
+ private var marqueeLayer: GraphicsLayer? = null
var spacing: MarqueeSpacing by mutableStateOf(spacing)
var animationMode: MarqueeAnimationMode by mutableStateOf(animationMode)
@@ -225,12 +230,27 @@
}
override fun onAttach() {
+ val layer = marqueeLayer
+ val graphicsContext = requireGraphicsContext()
+ // Shouldn't happen as detach should be called in between in onAttach call but
+ // just in case
+ if (layer != null) {
+ graphicsContext.releaseGraphicsLayer(layer)
+ }
+
+ marqueeLayer = graphicsContext.createGraphicsLayer()
restartAnimation()
}
override fun onDetach() {
animationJob?.cancel()
animationJob = null
+
+ val layer = marqueeLayer
+ if (layer != null) {
+ requireGraphicsContext().releaseGraphicsLayer(layer)
+ marqueeLayer = null
+ }
}
fun update(
@@ -320,17 +340,31 @@
else -> -contentWidth - spacingPx
}.toFloat()
- clipRect(left = clipOffset, right = clipOffset + containerWidth) {
- // TODO(b/262284225) When both copies are visible, we call drawContent twice. This is
- // generally a bad practice, however currently the only alternative is to compose the
- // content twice, which can't be done with a modifier. In the future we might get the
- // ability to create intrinsic layers in draw scopes, which we should use here to avoid
- // invalidating the contents' draw scopes.
- if (firstCopyVisible) {
+ val drawHeight = size.height
+ marqueeLayer?.let { layer ->
+ layer.record(size = IntSize(contentWidth, drawHeight.roundToInt())) {
[email protected]()
}
- if (secondCopyVisible) {
- translate(left = secondCopyOffset) { [email protected]() }
+ }
+ clipRect(left = clipOffset, right = clipOffset + containerWidth) {
+ val layer = marqueeLayer
+ // Unless there are circumstances where the Modifier's draw call can be invoked without
+ // an attach call, the else case here is optional. However we can be safe and make sure
+ // that we definitely draw even when the layer could not be initialized for any reason.
+ if (layer != null) {
+ if (firstCopyVisible) {
+ drawLayer(layer)
+ }
+ if (secondCopyVisible) {
+ translate(left = secondCopyOffset) { drawLayer(layer) }
+ }
+ } else {
+ if (firstCopyVisible) {
+ [email protected]()
+ }
+ if (secondCopyVisible) {
+ translate(left = secondCopyOffset) { [email protected]() }
+ }
}
}
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt
index 23a9c30..9a578ca 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt
@@ -119,7 +119,9 @@
cachedBucket.clear()
}
- checkPrecondition(currentLine <= lineIndex) { "currentLine > lineIndex" }
+ checkPrecondition(currentLine <= lineIndex) {
+ "currentLine ($currentLine) > lineIndex ($lineIndex)"
+ }
while (currentLine < lineIndex && currentItemIndex < totalSize) {
if (cacheThisBucket) {
diff --git a/compose/integration-tests/hero/OWNERS b/compose/integration-tests/hero/OWNERS
index ee8658e..76f9fee 100644
--- a/compose/integration-tests/hero/OWNERS
+++ b/compose/integration-tests/hero/OWNERS
@@ -1,4 +1,5 @@
# Bug component: 343210
[email protected]
[email protected]
[email protected]
\ No newline at end of file
[email protected]
[email protected]
\ No newline at end of file
diff --git a/compose/material3/benchmark/src/androidTest/java/androidx/compose/material3/benchmark/SplitButtonBenchmark.kt b/compose/material3/benchmark/src/androidTest/java/androidx/compose/material3/benchmark/SplitButtonBenchmark.kt
index 9c1865c..7986432 100644
--- a/compose/material3/benchmark/src/androidTest/java/androidx/compose/material3/benchmark/SplitButtonBenchmark.kt
+++ b/compose/material3/benchmark/src/androidTest/java/androidx/compose/material3/benchmark/SplitButtonBenchmark.kt
@@ -29,7 +29,9 @@
import androidx.compose.material3.SplitButtonLayout
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.testutils.LayeredComposeTestCase
+import androidx.compose.testutils.ToggleableTestCase
import androidx.compose.testutils.benchmark.ComposeBenchmarkRule
import androidx.compose.testutils.benchmark.benchmarkFirstCompose
import androidx.compose.testutils.benchmark.benchmarkFirstDraw
@@ -87,8 +89,11 @@
}
}
-internal class SplitButtonTestCase(private val type: SplitButtonType) : LayeredComposeTestCase() {
- @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+internal class SplitButtonTestCase(private val type: SplitButtonType) :
+ LayeredComposeTestCase(), ToggleableTestCase {
+ private var trailingButtonChecked = mutableStateOf(false)
+
@Composable
override fun MeasuredContent() {
when (type) {
@@ -103,7 +108,7 @@
},
trailingButton = {
SplitButtonDefaults.TrailingButton(
- checked = false,
+ checked = trailingButtonChecked.value,
onCheckedChange = { /* Do Nothing */ },
) {
trailingContent()
@@ -121,7 +126,7 @@
},
trailingButton = {
SplitButtonDefaults.TonalTrailingButton(
- checked = false,
+ checked = trailingButtonChecked.value,
onCheckedChange = { /* Do Nothing */ },
) {
trailingContent()
@@ -139,7 +144,7 @@
},
trailingButton = {
SplitButtonDefaults.ElevatedTrailingButton(
- checked = false,
+ checked = trailingButtonChecked.value,
onCheckedChange = { /* Do Nothing */ },
) {
trailingContent()
@@ -157,7 +162,7 @@
},
trailingButton = {
SplitButtonDefaults.OutlinedTrailingButton(
- checked = false,
+ checked = trailingButtonChecked.value,
onCheckedChange = { /* Do Nothing */ },
) {
trailingContent()
@@ -171,6 +176,10 @@
override fun ContentWrappers(content: @Composable () -> Unit) {
MaterialTheme { content() }
}
+
+ override fun toggleState() {
+ trailingButtonChecked.value = !trailingButtonChecked.value
+ }
}
@Composable
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/SplitButtonTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/SplitButtonTest.kt
index 0a9014c..e11e8ad 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/SplitButtonTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/SplitButtonTest.kt
@@ -20,6 +20,10 @@
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.KeyboardArrowDown
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
@@ -30,10 +34,14 @@
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.assertIsOff
+import androidx.compose.ui.test.assertIsOn
+import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
@@ -85,6 +93,46 @@
}
@Test
+ fun filledSplitButton_trailingButtonChecked() {
+ rule.setMaterialContent(lightColorScheme()) {
+ var trailingButtonChecked by remember { mutableStateOf(false) }
+
+ SplitButtonLayout(
+ leadingButton = {
+ SplitButtonDefaults.LeadingButton(
+ onClick = { /* Do Nothing */ },
+ modifier = Modifier.testTag("leadingButton")
+ ) {
+ Icon(
+ Icons.Outlined.Edit,
+ contentDescription = "Leading Icon",
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text("My Button")
+ }
+ },
+ trailingButton = {
+ SplitButtonDefaults.TrailingButton(
+ modifier = Modifier.size(34.dp).testTag("trailingButton"),
+ checked = trailingButtonChecked,
+ onCheckedChange = { trailingButtonChecked = !trailingButtonChecked },
+ ) {
+ Icon(Icons.Outlined.KeyboardArrowDown, contentDescription = "Trailing Icon")
+ }
+ }
+ )
+ }
+
+ rule
+ .onNode(isToggleable())
+ .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox))
+ .assertIsEnabled()
+ .assertIsOff()
+ .performClick()
+ .assertIsOn()
+ }
+
+ @Test
fun filledSplitButton_defaultSemantics() {
rule.setMaterialContent(lightColorScheme()) {
SplitButtonLayout(
diff --git a/compose/runtime/runtime/proguard-rules.pro b/compose/runtime/runtime/proguard-rules.pro
index a0d0c23..6e78193 100644
--- a/compose/runtime/runtime/proguard-rules.pro
+++ b/compose/runtime/runtime/proguard-rules.pro
@@ -24,6 +24,10 @@
static java.lang.Void throw*Exception(...);
static java.lang.Void throw*ExceptionForNullCheck(...);
+ # For functions generating error messages
+ static java.lang.String exceptionMessage*(...);
+ java.lang.String exceptionMessage*(...);
+
static void compose*RuntimeError(...);
static java.lang.Void compose*RuntimeError(...);
}
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operations.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operations.kt
index 5ccd71e..c7f6dcb 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operations.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/changelist/Operations.kt
@@ -14,6 +14,8 @@
* limitations under the License.
*/
+@file:Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
+
package androidx.compose.runtime.changelist
import androidx.compose.runtime.Applier
@@ -101,8 +103,12 @@
// Resize arrays if needed
if (opCodesSize == opCodes.size) {
+ // Note: manual allocation + copy of the array produces much better code on Android
+ // than calling Array.copyOf()
val resizeAmount = opCodesSize.coerceAtMost(MaxResizeAmount)
- opCodes = opCodes.copyOf(opCodesSize + resizeAmount)
+ val newOpCodes = arrayOfNulls<Operation>(opCodesSize + resizeAmount)
+ opCodes.copyInto(newOpCodes, 0, 0, opCodesSize)
+ opCodes = newOpCodes
}
ensureIntArgsSizeAtLeast(intArgsSize + operation.ints)
ensureObjectArgsSizeAtLeast(objectArgsSize + operation.objects)
@@ -121,14 +127,22 @@
private fun ensureIntArgsSizeAtLeast(requiredSize: Int) {
val currentSize = intArgs.size
if (requiredSize > currentSize) {
- intArgs = intArgs.copyOf(determineNewSize(currentSize, requiredSize))
+ // Note: manual allocation + copy of the array produces much better code on Android
+ // than calling Array.copyOf()
+ val newIntArgs = IntArray(determineNewSize(currentSize, requiredSize))
+ intArgs.copyInto(newIntArgs, 0, 0, currentSize)
+ intArgs = newIntArgs
}
}
private fun ensureObjectArgsSizeAtLeast(requiredSize: Int) {
val currentSize = objectArgs.size
if (requiredSize > currentSize) {
- objectArgs = objectArgs.copyOf(determineNewSize(currentSize, requiredSize))
+ // Note: manual allocation + copy of the array produces much better code on Android
+ // than calling Array.copyOf()
+ val newObjectArgs = arrayOfNulls<Any>(determineNewSize(currentSize, requiredSize))
+ objectArgs.copyInto(newObjectArgs, 0, 0, currentSize)
+ objectArgs = newObjectArgs
}
}
@@ -140,13 +154,16 @@
* any arguments.
*/
fun push(operation: Operation) {
- requirePrecondition(operation.ints == 0 && operation.objects == 0) {
- "Cannot push $operation without arguments because it expects " +
- "${operation.ints} ints and ${operation.objects} objects."
+ requirePrecondition((operation.ints and operation.objects) == 0) {
+ exceptionMessageForOperationPushNoScope(operation)
}
@OptIn(InternalComposeApi::class) pushOp(operation)
}
+ private fun exceptionMessageForOperationPushNoScope(operation: Operation) =
+ "Cannot push $operation without arguments because it expects " +
+ "${operation.ints} ints and ${operation.objects} objects."
+
/**
* Adds an [operation] to the stack with arguments. To set arguments on the operation, call
* [WriteScope.setObject] and [WriteScope.setInt] inside of the [args] lambda.
@@ -169,42 +186,47 @@
pushedIntMask == createExpectedArgMask(operation.ints) &&
pushedObjectMask == createExpectedArgMask(operation.objects)
) {
- var missingIntCount = 0
- val missingInts = buildString {
- repeat(operation.ints) { arg ->
- if ((0b1 shl arg) and pushedIntMask != 0b0) {
- if (missingIntCount > 0) append(", ")
- append(operation.intParamName(IntParameter(arg)))
- missingIntCount++
- }
- }
- }
-
- var missingObjectCount = 0
- val missingObjects = buildString {
- repeat(operation.objects) { arg ->
- if ((0b1 shl arg) and pushedObjectMask != 0b0) {
- if (missingIntCount > 0) append(", ")
- append(operation.objectParamName(ObjectParameter<Nothing>(arg)))
- missingObjectCount++
- }
- }
- }
-
- "Error while pushing $operation. Not all arguments were provided. " +
- "Missing $missingIntCount int arguments ($missingInts) " +
- "and $missingObjectCount object arguments ($missingObjects)."
+ exceptionMessageForOperationPushWithScope(operation)
}
}
+ private fun exceptionMessageForOperationPushWithScope(operation: Operation): String {
+ var missingIntCount = 0
+ val missingInts = buildString {
+ repeat(operation.ints) { arg ->
+ if ((0b1 shl arg) and pushedIntMask != 0b0) {
+ if (missingIntCount > 0) append(", ")
+ append(operation.intParamName(IntParameter(arg)))
+ missingIntCount++
+ }
+ }
+ }
+
+ var missingObjectCount = 0
+ val missingObjects = buildString {
+ repeat(operation.objects) { arg ->
+ if ((0b1 shl arg) and pushedObjectMask != 0b0) {
+ if (missingIntCount > 0) append(", ")
+ append(operation.objectParamName(ObjectParameter<Nothing>(arg)))
+ missingObjectCount++
+ }
+ }
+ }
+
+ return "Error while pushing $operation. Not all arguments were provided. " +
+ "Missing $missingIntCount int arguments ($missingInts) " +
+ "and $missingObjectCount object arguments ($missingObjects)."
+ }
+
/**
* Returns a bitmask int where the bottommost [paramCount] bits are 1's, and the rest of the
* bits are 0's. This corresponds to what [pushedIntMask] and [pushedObjectMask] will equal if
* all [paramCount] arguments are set for the most recently pushed operation.
*/
- private fun createExpectedArgMask(paramCount: Int): Int {
+ private inline fun createExpectedArgMask(paramCount: Int): Int {
// Calling ushr(32) no-ops instead of returning 0, so add a special case if paramCount is 0
- return if (paramCount == 0) 0 else 0b0.inv().ushr(Int.SIZE_BITS - paramCount)
+ // Keep the if/else in the parenthesis so we generate a single csetm on aarch64
+ return (if (paramCount == 0) 0 else 0b0.inv()) ushr (Int.SIZE_BITS - paramCount)
}
/**
@@ -212,15 +234,16 @@
* references.
*/
fun pop() {
- if (isEmpty()) {
- throw NoSuchElementException("Cannot pop(), because the stack is empty.")
- }
+ // We could check for isEmpty(), instead we'll just let the array access throw an index out
+ // of bounds exception
val op = opCodes[--opCodesSize]!!
opCodes[opCodesSize] = null
repeat(op.objects) { objectArgs[--objectArgsSize] = null }
- repeat(op.ints) { intArgs[--intArgsSize] = 0 }
+ // We can just skip this work and leave the content of the array as is
+ // repeat(op.ints) { intArgs[--intArgsSize] = 0 }
+ intArgsSize -= op.ints
}
/**
@@ -229,34 +252,40 @@
*/
@OptIn(InternalComposeApi::class)
fun popInto(other: Operations) {
- if (isEmpty()) {
- throw NoSuchElementException("Cannot pop(), because the stack is empty.")
- }
+ // We could check for isEmpty(), instead we'll just let the array access throw an index out
+ // of bounds exception
+ val opCodes = opCodes
val op = opCodes[--opCodesSize]!!
opCodes[opCodesSize] = null
other.pushOp(op)
var thisObjIdx = objectArgsSize
+ val objectArgs = objectArgs
var otherObjIdx = other.objectArgsSize
+ val otherObjectArs = other.objectArgs
+
+ objectArgsSize -= op.objects
repeat(op.objects) {
otherObjIdx--
thisObjIdx--
- other.objectArgs[otherObjIdx] = objectArgs[thisObjIdx]
+ otherObjectArs[otherObjIdx] = objectArgs[thisObjIdx]
objectArgs[thisObjIdx] = null
}
var thisIntIdx = intArgsSize
+ val intArgs = intArgs
var otherIntIdx = other.intArgsSize
+ val otherIntArgs = other.intArgs
+
+ intArgsSize -= op.ints
repeat(op.ints) {
otherIntIdx--
thisIntIdx--
- other.intArgs[otherIntIdx] = intArgs[thisIntIdx]
- intArgs[thisIntIdx] = 0
+ otherIntArgs[otherIntIdx] = intArgs[thisIntIdx]
+ // We don't need to zero out the ints
+ // intArgs[thisIntIdx] = 0
}
-
- objectArgsSize -= op.objects
- intArgsSize -= op.ints
}
/**
@@ -405,7 +434,7 @@
var isFirstParam = true
val argLinePrefix = linePrefix.indent()
repeat(operation.ints) { offset ->
- val param = Operation.IntParameter(offset)
+ val param = IntParameter(offset)
val name = operation.intParamName(param)
if (!isFirstParam) append(", ") else isFirstParam = false
appendLine()
@@ -415,7 +444,7 @@
append(getInt(param))
}
repeat(operation.objects) { offset ->
- val param = Operation.ObjectParameter<Any?>(offset)
+ val param = ObjectParameter<Any?>(offset)
val name = operation.objectParamName(param)
if (!isFirstParam) append(", ") else isFirstParam = false
appendLine()
diff --git a/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/changelist/OperationsTest.kt b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/changelist/OperationsTest.kt
index 99e72a1..e3f9c87 100644
--- a/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/changelist/OperationsTest.kt
+++ b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/changelist/OperationsTest.kt
@@ -293,7 +293,7 @@
}
}
- @Test(expected = NoSuchElementException::class)
+ @Test(expected = IndexOutOfBoundsException::class)
fun testPop_throwsIfStackIsEmpty() {
stack.pop()
}
@@ -336,7 +336,7 @@
)
}
- @Test(expected = NoSuchElementException::class)
+ @Test(expected = IndexOutOfBoundsException::class)
fun testPopInto_throwsIfStackIsEmpty() {
stack.pop()
}
diff --git a/compose/ui/ui-text/build.gradle b/compose/ui/ui-text/build.gradle
index 8e574c8..86f8a51 100644
--- a/compose/ui/ui-text/build.gradle
+++ b/compose/ui/ui-text/build.gradle
@@ -153,4 +153,7 @@
namespace "androidx.compose.ui.text"
// TODO(b/328001575)
experimentalProperties["android.lint.useK2Uast"] = false
+ buildTypes.configureEach {
+ consumerProguardFiles("proguard-rules.pro")
+ }
}
diff --git a/compose/ui/ui-text/proguard-rules.pro b/compose/ui/ui-text/proguard-rules.pro
new file mode 100644
index 0000000..67d118b
--- /dev/null
+++ b/compose/ui/ui-text/proguard-rules.pro
@@ -0,0 +1,24 @@
+# Copyright (C) 2020 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Keep all the functions created to throw an exception. We don't want these functions to be
+# inlined in any way, which R8 will do by default. The whole point of these functions is to
+# reduce the amount of code generated at the call site.
+-keep,allowshrinking,allowobfuscation class androidx.compose.**.* {
+ static void throw*Exception(...);
+ static void throw*ExceptionForNullCheck(...);
+ # For methods returning Nothing
+ static java.lang.Void throw*Exception(...);
+ static java.lang.Void throw*ExceptionForNullCheck(...);
+}
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/AndroidParagraph.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/AndroidParagraph.android.kt
index 7425e87..f3b7e2a 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/AndroidParagraph.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/AndroidParagraph.android.kt
@@ -72,6 +72,7 @@
import androidx.compose.ui.text.android.style.IndentationFixSpan
import androidx.compose.ui.text.android.style.PlaceholderSpan
import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.internal.requirePrecondition
import androidx.compose.ui.text.platform.AndroidParagraphIntrinsics
import androidx.compose.ui.text.platform.AndroidTextPaint
import androidx.compose.ui.text.platform.extensions.setSpan
@@ -134,11 +135,11 @@
@VisibleForTesting internal val charSequence: CharSequence
init {
- require(constraints.minHeight == 0 && constraints.minWidth == 0) {
+ requirePrecondition(constraints.minHeight == 0 && constraints.minWidth == 0) {
"Setting Constraints.minWidth and Constraints.minHeight is not supported, " +
"these should be the default zero values instead."
}
- require(maxLines >= 1) { "maxLines should be greater than 0" }
+ requirePrecondition(maxLines >= 1) { "maxLines should be greater than 0" }
val style = paragraphIntrinsics.style
@@ -384,7 +385,7 @@
* the top, bottom, left and right of a character.
*/
override fun getBoundingBox(offset: Int): Rect {
- require(offset in charSequence.indices) {
+ requirePrecondition(offset in charSequence.indices) {
"offset($offset) is out of bounds [0,${charSequence.length})"
}
val rectF = layout.getBoundingBox(offset)
@@ -425,7 +426,7 @@
}
override fun getPathForRange(start: Int, end: Int): Path {
- require(start in 0..end && end <= charSequence.length) {
+ requirePrecondition(start in 0..end && end <= charSequence.length) {
"start($start) or end($end) is out of range [0..${charSequence.length}]," +
" or start > end!"
}
@@ -435,7 +436,7 @@
}
override fun getCursorRect(offset: Int): Rect {
- require(offset in 0..charSequence.length) {
+ requirePrecondition(offset in 0..charSequence.length) {
"offset($offset) is out of bounds [0,${charSequence.length}]"
}
val horizontal = layout.getPrimaryHorizontal(offset)
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/BoringLayoutFactory.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/BoringLayoutFactory.android.kt
index 287c6c9..133332e 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/BoringLayoutFactory.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/BoringLayoutFactory.android.kt
@@ -24,6 +24,7 @@
import android.text.TextPaint
import android.text.TextUtils.TruncateAt
import androidx.annotation.RequiresApi
+import androidx.compose.ui.text.internal.requirePrecondition
/** Factory Class for BoringLayout */
@OptIn(InternalPlatformTextApi::class)
@@ -74,8 +75,8 @@
ellipsize: TruncateAt? = null,
ellipsizedWidth: Int = width,
): BoringLayout {
- require(width >= 0) { "negative width" }
- require(ellipsizedWidth >= 0) { "negative ellipsized width" }
+ requirePrecondition(width >= 0) { "negative width" }
+ requirePrecondition(ellipsizedWidth >= 0) { "negative ellipsized width" }
return if (Build.VERSION.SDK_INT >= 33) {
BoringLayoutFactory33.create(
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/ListUtils.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/ListUtils.android.kt
index 781a651..5871d7f 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/ListUtils.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/ListUtils.android.kt
@@ -70,7 +70,7 @@
@OptIn(ExperimentalContracts::class)
internal inline fun <T, R> List<T>.fastZipWithNext(transform: (T, T) -> R): List<R> {
contract { callsInPlace(transform) }
- if (size == 0 || size == 1) return emptyList()
+ if (size <= 1) return emptyList()
val result = mutableListOf<R>()
var current = get(0)
// `until` as we don't want to invoke this for the last element, since that won't have a `next`
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/StaticLayoutFactory.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/StaticLayoutFactory.android.kt
index 256e746..01604ab 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/StaticLayoutFactory.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/StaticLayoutFactory.android.kt
@@ -32,6 +32,7 @@
import androidx.compose.ui.text.android.LayoutCompat.JustificationMode
import androidx.compose.ui.text.android.LayoutCompat.LineBreakStyle
import androidx.compose.ui.text.android.LayoutCompat.LineBreakWordStyle
+import androidx.compose.ui.text.internal.requirePrecondition
import java.lang.reflect.Constructor
import java.lang.reflect.InvocationTargetException
@@ -139,12 +140,12 @@
val rightIndents: IntArray?
) {
init {
- require(start in 0..end) { "invalid start value" }
- require(end in 0..text.length) { "invalid end value" }
- require(maxLines >= 0) { "invalid maxLines value" }
- require(width >= 0) { "invalid width value" }
- require(ellipsizedWidth >= 0) { "invalid ellipsizedWidth value" }
- require(lineSpacingMultiplier >= 0f) { "invalid lineSpacingMultiplier value" }
+ requirePrecondition(start in 0..end) { "invalid start value" }
+ requirePrecondition(end in 0..text.length) { "invalid end value" }
+ requirePrecondition(maxLines >= 0) { "invalid maxLines value" }
+ requirePrecondition(width >= 0) { "invalid width value" }
+ requirePrecondition(ellipsizedWidth >= 0) { "invalid ellipsizedWidth value" }
+ requirePrecondition(lineSpacingMultiplier >= 0f) { "invalid lineSpacingMultiplier value" }
}
}
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/TextLayout.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/TextLayout.android.kt
index 81d7f19..5f5fd9a 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/TextLayout.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/TextLayout.android.kt
@@ -72,6 +72,7 @@
import androidx.compose.ui.text.android.style.LineHeightStyleSpan
import androidx.compose.ui.text.android.style.getEllipsizedLeftPadding
import androidx.compose.ui.text.android.style.getEllipsizedRightPadding
+import androidx.compose.ui.text.internal.requirePrecondition
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.max
@@ -603,7 +604,7 @@
val range = lineEndOffset - lineStartOffset
val minArraySize = range * 2
- require(array.size >= minArraySize) {
+ requirePrecondition(array.size >= minArraySize) {
"array.size - arrayStart must be greater or equal than (endOffset - startOffset) * 2"
}
@@ -670,15 +671,21 @@
*/
fun fillBoundingBoxes(startOffset: Int, endOffset: Int, array: FloatArray, arrayStart: Int) {
val textLength = text.length
- require(startOffset >= 0) { "startOffset must be > 0" }
- require(startOffset < textLength) { "startOffset must be less than text length" }
- require(endOffset > startOffset) { "endOffset must be greater than startOffset" }
- require(endOffset <= textLength) { "endOffset must be smaller or equal to text length" }
+ requirePrecondition(startOffset >= 0) { "startOffset must be > 0" }
+ requirePrecondition(startOffset < textLength) {
+ "startOffset must be less than text length"
+ }
+ requirePrecondition(endOffset > startOffset) {
+ "endOffset must be greater than startOffset"
+ }
+ requirePrecondition(endOffset <= textLength) {
+ "endOffset must be smaller or equal to text length"
+ }
val range = endOffset - startOffset
val minArraySize = range * 4
- require((array.size - arrayStart) >= minArraySize) {
+ requirePrecondition((array.size - arrayStart) >= minArraySize) {
"array.size - arrayStart must be greater or equal than (endOffset - startOffset) * 4"
}
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/selection/WordIterator.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/selection/WordIterator.android.kt
index 0d43401..588382f 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/selection/WordIterator.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/selection/WordIterator.android.kt
@@ -16,6 +16,7 @@
package androidx.compose.ui.text.android.selection
import androidx.compose.ui.text.android.CharSequenceCharacterIterator
+import androidx.compose.ui.text.internal.requirePrecondition
import androidx.emoji2.text.EmojiCompat
import java.text.BreakIterator
import java.util.Locale
@@ -40,8 +41,12 @@
private val iterator: BreakIterator
init {
- require(start in 0..charSequence.length) { "input start index is outside the CharSequence" }
- require(end in 0..charSequence.length) { "input end index is outside the CharSequence" }
+ requirePrecondition(start in 0..charSequence.length) {
+ "input start index is outside the CharSequence"
+ }
+ requirePrecondition(end in 0..charSequence.length) {
+ "input end index is outside the CharSequence"
+ }
iterator = BreakIterator.getWordInstance(locale)
this.start = max(0, start - WINDOW_WIDTH)
this.end = min(charSequence.length, end + WINDOW_WIDTH)
@@ -315,8 +320,8 @@
/** Check if the given offset is in the given range. */
private fun checkOffsetIsValid(offset: Int) {
- require(offset in start..end) {
- ("Invalid offset: $offset. Valid range is [$start , $end]")
+ requirePrecondition(offset in start..end) {
+ "Invalid offset: $offset. Valid range is [$start , $end]"
}
}
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpan.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpan.android.kt
index e0d6cf0..1114446 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpan.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/LineHeightStyleSpan.android.kt
@@ -17,6 +17,7 @@
import android.graphics.Paint.FontMetricsInt
import androidx.annotation.FloatRange
+import androidx.compose.ui.text.internal.checkPrecondition
import kotlin.math.abs
import kotlin.math.ceil
@@ -64,7 +65,9 @@
private set
init {
- check(topRatio in 0f..1f || topRatio == -1f) { "topRatio should be in [0..1] range or -1" }
+ checkPrecondition(topRatio in 0f..1f || topRatio == -1f) {
+ "topRatio should be in [0..1] range or -1"
+ }
}
override fun chooseHeight(
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/PlaceholderSpan.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/PlaceholderSpan.android.kt
index 4f665e8..8eb5c28 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/PlaceholderSpan.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/android/style/PlaceholderSpan.android.kt
@@ -21,6 +21,10 @@
import android.graphics.Paint
import android.text.style.ReplacementSpan
import androidx.annotation.IntDef
+import androidx.compose.ui.text.internal.checkPrecondition
+import androidx.compose.ui.text.internal.requirePrecondition
+import androidx.compose.ui.text.internal.throwIllegalArgumentException
+import androidx.compose.ui.text.internal.throwIllegalArgumentExceptionForNullCheck
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
@@ -85,7 +89,7 @@
var widthPx: Int = 0
private set
get() {
- check(isLaidOut) { "PlaceholderSpan is not laid out yet." }
+ checkPrecondition(isLaidOut) { "PlaceholderSpan is not laid out yet." }
return field
}
@@ -93,7 +97,7 @@
var heightPx: Int = 0
private set
get() {
- check(isLaidOut) { "PlaceholderSpan is not laid out yet." }
+ checkPrecondition(isLaidOut) { "PlaceholderSpan is not laid out yet." }
return field
}
@@ -111,7 +115,7 @@
isLaidOut = true
val fontSize = paint.textSize
fontMetrics = paint.fontMetricsInt
- require(fontMetrics.descent > fontMetrics.ascent) {
+ requirePrecondition(fontMetrics.descent > fontMetrics.ascent) {
"Invalid fontMetrics: line height can not be negative."
}
@@ -119,14 +123,14 @@
when (widthUnit) {
UNIT_SP -> width * pxPerSp
UNIT_EM -> width * fontSize
- else -> throw IllegalArgumentException("Unsupported unit.")
+ else -> throwIllegalArgumentExceptionForNullCheck("Unsupported unit.")
}.ceilToInt()
heightPx =
when (heightUnit) {
UNIT_SP -> (height * pxPerSp).ceilToInt()
UNIT_EM -> (height * fontSize).ceilToInt()
- else -> throw IllegalArgumentException("Unsupported unit.")
+ else -> throwIllegalArgumentExceptionForNullCheck("Unsupported unit.")
}
fm?.apply {
@@ -159,7 +163,7 @@
if (ascent > -heightPx) {
ascent = -heightPx
}
- else -> throw IllegalArgumentException("Unknown verticalAlign.")
+ else -> throwIllegalArgumentException("Unknown verticalAlign.")
}
// make top/bottom at least same as ascent/descent.
top = min(fontMetrics.top, ascent)
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/font/DeviceFontFamilyNameFont.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/font/DeviceFontFamilyNameFont.android.kt
index 21c5b32..073ed59 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/font/DeviceFontFamilyNameFont.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/font/DeviceFontFamilyNameFont.android.kt
@@ -20,6 +20,7 @@
import android.content.Context
import android.graphics.Typeface
+import androidx.compose.ui.text.internal.requirePrecondition
/**
* Describes a system-installed font that may be present on some Android devices.
@@ -74,7 +75,7 @@
@JvmInline
value class DeviceFontFamilyName(val name: String) {
init {
- require(name.isNotEmpty()) { "name may not be empty" }
+ requirePrecondition(name.isNotEmpty()) { "name may not be empty" }
}
}
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidFontListTypeface.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidFontListTypeface.android.kt
index aaa9578..8e6a073 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidFontListTypeface.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidFontListTypeface.android.kt
@@ -34,6 +34,9 @@
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.ResourceFont
import androidx.compose.ui.text.font.synthesizeTypeface
+import androidx.compose.ui.text.internal.checkPrecondition
+import androidx.compose.ui.text.internal.checkPreconditionNotNull
+import androidx.compose.ui.text.internal.throwIllegalStateException
import androidx.compose.ui.util.fastDistinctBy
import androidx.compose.ui.util.fastFilter
import androidx.compose.ui.util.fastFilterNotNull
@@ -69,14 +72,14 @@
?.fastFilterNotNull()
?.fastDistinctBy { it }
val targetFonts = matchedFonts ?: blockingFonts
- check(targetFonts.isNotEmpty()) { "Could not match font" }
+ checkPrecondition(targetFonts.isNotEmpty()) { "Could not match font" }
val typefaces = mutableMapOf<Font, Typeface>()
targetFonts.fastForEach {
try {
typefaces[it] = AndroidTypefaceCache.getOrCreate(context, it)
} catch (e: Exception) {
- throw IllegalStateException("Cannot create Typeface from $it")
+ throwIllegalStateException("Cannot create Typeface from $it")
}
}
@@ -94,10 +97,10 @@
fontMatcher
.matchFont(ArrayList(loadedTypefaces.keys), fontWeight, fontStyle)
.firstOrNull()
- checkNotNull(font) { "Could not load font" }
+ checkPreconditionNotNull(font) { "Could not load font" }
val typeface = loadedTypefaces[font]
- checkNotNull(typeface) { "Could not load typeface" }
+ checkPreconditionNotNull(typeface) { "Could not load typeface" }
return synthesis.synthesizeTypeface(typeface, font, fontWeight, fontStyle) as Typeface
}
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/AnnotatedString.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/AnnotatedString.kt
index 380f9d4..d407002 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/AnnotatedString.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/AnnotatedString.kt
@@ -22,14 +22,16 @@
import androidx.compose.ui.text.AnnotatedString.Annotation
import androidx.compose.ui.text.AnnotatedString.Builder
import androidx.compose.ui.text.AnnotatedString.Range
+import androidx.compose.ui.text.internal.checkPrecondition
+import androidx.compose.ui.text.internal.requirePrecondition
import androidx.compose.ui.text.intl.LocaleList
import androidx.compose.ui.util.fastAny
+import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.fastFilter
+import androidx.compose.ui.util.fastFilteredMap
import androidx.compose.ui.util.fastFlatMap
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMap
-import kotlin.contracts.ExperimentalContracts
-import kotlin.contracts.contract
import kotlin.jvm.JvmName
/**
@@ -44,12 +46,12 @@
internal val spanStylesOrNull: List<Range<SpanStyle>>?
/** All [SpanStyle] that have been applied to a range of this String */
val spanStyles: List<Range<SpanStyle>>
- get() = spanStylesOrNull ?: emptyList()
+ get() = spanStylesOrNull ?: listOf()
internal val paragraphStylesOrNull: List<Range<ParagraphStyle>>?
/** All [ParagraphStyle] that have been applied to a range of this String */
val paragraphStyles: List<Range<ParagraphStyle>>
- get() = paragraphStylesOrNull ?: emptyList()
+ get() = paragraphStylesOrNull ?: listOf()
/**
* The basic data structure of text with multiple styles. To construct an [AnnotatedString] you
@@ -61,7 +63,7 @@
* @param spanStyles a list of [Range]s that specifies [SpanStyle]s on certain portion of the
* text. These styles will be applied in the order of the list. And the [SpanStyle]s applied
* later can override the former styles. Notice that [SpanStyle] attributes which are null or
- * [Unspecified] won't change the current ones.
+ * unspecified won't change the current ones.
* @param paragraphStyles a list of [Range]s that specifies [ParagraphStyle]s on certain portion
* of the text. Each [ParagraphStyle] with a [Range] defines a paragraph of text. It's
* required that [Range]s of paragraphs don't overlap with each other. If there are gaps
@@ -99,7 +101,7 @@
*/
constructor(
text: String,
- annotations: List<Range<out Annotation>> = emptyList()
+ annotations: List<Range<out Annotation>> = listOf()
) : this(annotations.ifEmpty { null }, text)
init {
@@ -127,10 +129,10 @@
paragraphStylesOrNull
?.sortedBy { it.start }
?.fastForEach { paragraphStyle ->
- require(paragraphStyle.start >= lastStyleEnd) {
+ requirePrecondition(paragraphStyle.start >= lastStyleEnd) {
"ParagraphStyle should not overlap"
}
- require(paragraphStyle.end <= text.length) {
+ requirePrecondition(paragraphStyle.end <= text.length) {
"ParagraphStyle range [${paragraphStyle.start}, ${paragraphStyle.end})" +
" is out of boundary"
}
@@ -151,7 +153,7 @@
* @param endIndex the exclusive end offset of the range
*/
override fun subSequence(startIndex: Int, endIndex: Int): AnnotatedString {
- require(startIndex <= endIndex) {
+ requirePrecondition(startIndex <= endIndex) {
"start ($startIndex) should be less or equal to end ($endIndex)"
}
if (startIndex == 0 && endIndex == text.length) return this
@@ -193,13 +195,13 @@
* with the range [start, end) will be returned. When [start] is bigger than [end], an empty
* list will be returned.
*/
- @Suppress("UNCHECKED_CAST")
+ @Suppress("UNCHECKED_CAST", "KotlinRedundantDiagnosticSuppress")
fun getStringAnnotations(tag: String, start: Int, end: Int): List<Range<String>> =
- (annotations?.fastFilterMap({
+ (annotations?.fastFilteredMap({
it.item is StringAnnotation && tag == it.tag && intersect(start, end, it.start, it.end)
}) {
it.unbox()
- } ?: emptyList())
+ } ?: listOf())
/**
* Returns true if [getStringAnnotations] with the same parameters would return a non-empty list
@@ -218,13 +220,13 @@
* with the range [start, end) will be returned. When [start] is bigger than [end], an empty
* list will be returned.
*/
- @Suppress("UNCHECKED_CAST")
+ @Suppress("UNCHECKED_CAST", "KotlinRedundantDiagnosticSuppress")
fun getStringAnnotations(start: Int, end: Int): List<Range<String>> =
- annotations?.fastFilterMap({
+ annotations?.fastFilteredMap({
it.item is StringAnnotation && intersect(start, end, it.start, it.end)
}) {
it.unbox()
- } ?: emptyList()
+ } ?: listOf()
/**
* Query all of the [TtsAnnotation]s attached on this [AnnotatedString].
@@ -239,7 +241,7 @@
fun getTtsAnnotations(start: Int, end: Int): List<Range<TtsAnnotation>> =
((annotations?.fastFilter {
it.item is TtsAnnotation && intersect(start, end, it.start, it.end)
- } ?: emptyList())
+ } ?: listOf())
as List<Range<TtsAnnotation>>)
/**
@@ -257,7 +259,7 @@
fun getUrlAnnotations(start: Int, end: Int): List<Range<UrlAnnotation>> =
((annotations?.fastFilter {
it.item is UrlAnnotation && intersect(start, end, it.start, it.end)
- } ?: emptyList())
+ } ?: listOf())
as List<Range<UrlAnnotation>>)
/**
@@ -273,7 +275,7 @@
fun getLinkAnnotations(start: Int, end: Int): List<Range<LinkAnnotation>> =
((annotations?.fastFilter {
it.item is LinkAnnotation && intersect(start, end, it.start, it.end)
- } ?: emptyList())
+ } ?: listOf())
as List<Range<LinkAnnotation>>)
/**
@@ -360,7 +362,7 @@
constructor(item: T, start: Int, end: Int) : this(item, start, end, "")
init {
- require(start <= end) { "Reversed range is not supported" }
+ requirePrecondition(start <= end) { "Reversed range is not supported" }
}
}
@@ -391,7 +393,7 @@
*/
fun toRange(defaultEnd: Int = Int.MIN_VALUE): Range<T> {
val end = if (end == Int.MIN_VALUE) defaultEnd else end
- check(end != Int.MIN_VALUE) { "Item.end should be set first" }
+ checkPrecondition(end != Int.MIN_VALUE) { "Item.end should be set first" }
return Range(item = item, start = start, end = end, tag = tag)
}
@@ -402,12 +404,12 @@
*/
fun <R> toRange(transform: (T) -> R, defaultEnd: Int = Int.MIN_VALUE): Range<R> {
val end = if (end == Int.MIN_VALUE) defaultEnd else end
- check(end != Int.MIN_VALUE) { "Item.end should be set first" }
+ checkPrecondition(end != Int.MIN_VALUE) { "Item.end should be set first" }
return Range(item = transform(item), start = start, end = end, tag = tag)
}
companion object {
- fun <T> fromRange(range: AnnotatedString.Range<T>) =
+ fun <T> fromRange(range: Range<T>) =
MutableRange(range.item, range.start, range.end, range.tag)
}
}
@@ -545,7 +547,7 @@
}
/**
- * Set a [SpanStyle] for the given [range].
+ * Set a [SpanStyle] for the given range defined by [start] and [end].
*
* @param style [SpanStyle] to be applied
* @param start the inclusive starting offset of the range
@@ -556,8 +558,9 @@
}
/**
- * Set a [ParagraphStyle] for the given [range]. When a [ParagraphStyle] is applied to the
- * [AnnotatedString], it will be rendered as a separate paragraph.
+ * Set a [ParagraphStyle] for the given range defined by [start] and [end]. When a
+ * [ParagraphStyle] is applied to the [AnnotatedString], it will be rendered as a separate
+ * paragraph.
*
* @param style [ParagraphStyle] to be applied
* @param start the inclusive starting offset of the range
@@ -568,7 +571,7 @@
}
/**
- * Set an Annotation for the given [range].
+ * Set an Annotation for the given range defined by [start] and [end].
*
* @param tag the tag used to distinguish annotations
* @param annotation the string annotation that is attached
@@ -589,7 +592,7 @@
}
/**
- * Set a [TtsAnnotation] for the given [range].
+ * Set a [TtsAnnotation] for the given range defined by [start] and [end].
*
* @param ttsAnnotation an object that stores text to speech metadata that intended for the
* TTS engine.
@@ -605,9 +608,9 @@
}
/**
- * Set a [UrlAnnotation] for the given [range]. URLs may be treated specially by screen
- * readers, including being identified while reading text with an audio icon or being
- * summarized in a links menu.
+ * Set a [UrlAnnotation] for the given range defined by [start] and [end]. URLs may be
+ * treated specially by screen readers, including being identified while reading text with
+ * an audio icon or being summarized in a links menu.
*
* @param urlAnnotation A [UrlAnnotation] object that stores the URL being linked to.
* @param start the inclusive starting offset of the range
@@ -626,10 +629,10 @@
}
/**
- * Set a [LinkAnnotation.Url] for the given [range].
+ * Set a [LinkAnnotation.Url] for the given range defined by [start] and [end].
*
- * When clicking on the text in [range], the corresponding URL from the [url] annotation
- * will be opened using [androidx.compose.ui.platform.UriHandler].
+ * When clicking on the text in range, the corresponding URL from the [url] annotation will
+ * be opened using [androidx.compose.ui.platform.UriHandler].
*
* URLs may be treated specially by screen readers, including being identified while reading
* text with an audio icon or being summarized in a links menu.
@@ -645,9 +648,9 @@
}
/**
- * Set a [LinkAnnotation.Clickable] for the given [range].
+ * Set a [LinkAnnotation.Clickable] for the given range defined by [start] and [end].
*
- * When clicking on the text in [range], a [LinkInteractionListener] will be triggered with
+ * When clicking on the text in range, a [LinkInteractionListener] will be triggered with
* the [clickable] object.
*
* Clickable link may be treated specially by screen readers, including being identified
@@ -775,7 +778,7 @@
* @see pushStringAnnotation
*/
fun pop() {
- check(styleStack.isNotEmpty()) { "Nothing to pop." }
+ checkPrecondition(styleStack.isNotEmpty()) { "Nothing to pop." }
// pop the last element
val item = styleStack.removeAt(styleStack.size - 1)
item.end = text.length
@@ -792,7 +795,9 @@
* @see pushStringAnnotation
*/
fun pop(index: Int) {
- check(index < styleStack.size) { "$index should be less than ${styleStack.size}" }
+ checkPrecondition(index < styleStack.size) {
+ "$index should be less than ${styleStack.size}"
+ }
while ((styleStack.size - 1) >= index) {
pop()
}
@@ -842,7 +847,7 @@
sealed interface Annotation
// Unused private subclass of the marker interface to avoid exhaustive "when" statement
- private class ExhaustiveAnnotation : Annotation
+ @Suppress("unused") private class ExhaustiveAnnotation : Annotation
companion object {
/**
@@ -891,7 +896,7 @@
defaultParagraphStyle: ParagraphStyle
): List<Range<ParagraphStyle>> {
val length = text.length
- val paragraphStyles = paragraphStylesOrNull ?: emptyList()
+ val paragraphStyles = paragraphStylesOrNull ?: listOf()
var lastOffset = 0
val result = mutableListOf<Range<ParagraphStyle>>()
@@ -928,8 +933,12 @@
if (start == 0 && end >= this.text.length) {
return spanStyles
}
- return spanStyles.fastFilterMap({ intersect(start, end, it.start, it.end) }) {
- Range(it.item, it.start.coerceIn(start, end) - start, it.end.coerceIn(start, end) - start)
+ return spanStyles.fastFilteredMap({ intersect(start, end, it.start, it.end) }) {
+ Range(
+ it.item,
+ it.start.fastCoerceIn(start, end) - start,
+ it.end.fastCoerceIn(start, end) - start
+ )
}
}
@@ -950,8 +959,12 @@
if (start == 0 && end >= this.text.length) {
return paragraphStyles
}
- return paragraphStyles.fastFilterMap({ intersect(start, end, it.start, it.end) }) {
- Range(it.item, it.start.coerceIn(start, end) - start, it.end.coerceIn(start, end) - start)
+ return paragraphStyles.fastFilteredMap({ intersect(start, end, it.start, it.end) }) {
+ Range(
+ it.item,
+ it.start.fastCoerceIn(start, end) - start,
+ it.end.fastCoerceIn(start, end) - start
+ )
}
}
@@ -965,14 +978,14 @@
private fun AnnotatedString.getLocalAnnotations(
start: Int,
end: Int
-): List<Range<out AnnotatedString.Annotation>>? {
+): List<Range<out Annotation>>? {
if (start == end) return null
val annotations = annotations ?: return null
// If the given range covers the whole AnnotatedString, return it without conversion.
if (start == 0 && end >= this.text.length) {
return annotations
}
- return annotations.fastFilterMap({ intersect(start, end, it.start, it.end) }) {
+ return annotations.fastFilteredMap({ intersect(start, end, it.start, it.end) }) {
Range(
tag = it.tag,
item = it.item,
@@ -994,7 +1007,7 @@
private fun AnnotatedString.substringWithoutParagraphStyles(start: Int, end: Int): AnnotatedString {
return AnnotatedString(
text = if (start != end) text.substring(start, end) else "",
- annotations = getLocalSpanStyles(start, end) ?: emptyList()
+ annotations = getLocalSpanStyles(start, end) ?: listOf()
)
}
@@ -1249,11 +1262,13 @@
* @param end the exclusive end offset of the text range
*/
private fun <T> filterRanges(ranges: List<Range<out T>>?, start: Int, end: Int): List<Range<T>>? {
- require(start <= end) { "start ($start) should be less than or equal to end ($end)" }
+ requirePrecondition(start <= end) {
+ "start ($start) should be less than or equal to end ($end)"
+ }
val nonNullRange = ranges ?: return null
return nonNullRange
- .fastFilterMap({ intersect(start, end, it.start, it.end) }) {
+ .fastFilteredMap({ intersect(start, end, it.start, it.end) }) {
Range(
item = it.item,
start = maxOf(start, it.start) - start,
@@ -1300,43 +1315,30 @@
Builder().apply(builder).toAnnotatedString()
/**
- * Helper function that checks if the range [baseStart, baseEnd) contains the range [targetStart,
- * targetEnd).
- *
- * @return true if
- * [baseStart, baseEnd) contains [targetStart, targetEnd), vice versa. When [baseStart]==[baseEnd]
- * it return true iff [targetStart]==[targetEnd]==[baseStart].
- */
-internal fun contains(baseStart: Int, baseEnd: Int, targetStart: Int, targetEnd: Int) =
- (baseStart <= targetStart && targetEnd <= baseEnd) &&
- (baseEnd != targetEnd || (targetStart == targetEnd) == (baseStart == baseEnd))
-
-/**
* Helper function that checks if the range [lStart, lEnd) intersects with the range [rStart, rEnd).
*
* @return [lStart, lEnd) intersects with range [rStart, rEnd), vice versa.
*/
-internal fun intersect(lStart: Int, lEnd: Int, rStart: Int, rEnd: Int) =
- maxOf(lStart, rStart) < minOf(lEnd, rEnd) ||
- contains(lStart, lEnd, rStart, rEnd) ||
- contains(rStart, rEnd, lStart, lEnd)
+internal fun intersect(lStart: Int, lEnd: Int, rStart: Int, rEnd: Int): Boolean {
+ // We can check if two ranges intersect just by performing the following operation:
+ //
+ // lStart < rEnd && rStart < lEnd
+ //
+ // This operation handles all cases, including when one of the ranges is fully included in the
+ // other ranges. This is however not enough in this particular case because our ranges are open
+ // at the end, but closed at the start.
+ //
+ // This means the test above would fail cases like: [1, 4) intersect [1, 1)
+ // To address this we check if either one of the ranges is a "point" (empty selection). If
+ // that's the case and both ranges share the same start point, then they intersect.
+ //
+ // In addition, we use bitwise operators (or, and) instead of boolean operators (||, &&) to
+ // generate branchless code.
+ return ((lStart == lEnd) or (rStart == rEnd) and (lStart == rStart)) or
+ ((lStart < rEnd) and (rStart < lEnd))
+}
private val EmptyAnnotatedString: AnnotatedString = AnnotatedString("")
/** Returns an AnnotatedString with empty text and no annotations. */
internal fun emptyAnnotatedString() = EmptyAnnotatedString
-
-@OptIn(ExperimentalContracts::class)
-@Suppress("BanInlineOptIn")
-private inline fun <T, R> List<T>.fastFilterMap(
- predicate: (T) -> Boolean,
- transform: (T) -> R
-): List<R> {
- contract {
- callsInPlace(predicate)
- callsInPlace(transform)
- }
- val target = ArrayList<R>(size)
- fastForEach { if (predicate(it)) target += transform(it) }
- return target
-}
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraph.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraph.kt
index fff373a..b6d6baf 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraph.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraph.kt
@@ -30,6 +30,7 @@
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.createFontFamilyResolver
+import androidx.compose.ui.text.internal.requirePrecondition
import androidx.compose.ui.text.platform.drawMultiParagraph
import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.text.style.TextDecoration
@@ -378,7 +379,7 @@
internal val paragraphInfoList: List<ParagraphInfo>
init {
- require(constraints.minWidth == 0 && constraints.minHeight == 0) {
+ requirePrecondition(constraints.minWidth == 0 && constraints.minHeight == 0) {
"Setting Constraints.minWidth and Constraints.minHeight is not supported, " +
"these should be the default zero values instead."
}
@@ -511,7 +512,7 @@
/** Returns path that enclose the given text range. */
fun getPathForRange(start: Int, end: Int): Path {
- require(start in 0..end && end <= annotatedString.text.length) {
+ requirePrecondition(start in 0..end && end <= annotatedString.text.length) {
"Start($start) or End($end) is out of range [0..${annotatedString.text.length})," +
" or start > end!"
}
@@ -977,19 +978,19 @@
}
private fun requireIndexInRange(offset: Int) {
- require(offset in annotatedString.text.indices) {
+ requirePrecondition(offset in annotatedString.text.indices) {
"offset($offset) is out of bounds [0, ${annotatedString.length})"
}
}
private fun requireIndexInRangeInclusiveEnd(offset: Int) {
- require(offset in 0..annotatedString.text.length) {
+ requirePrecondition(offset in 0..annotatedString.text.length) {
"offset($offset) is out of bounds [0, ${annotatedString.length}]"
}
}
private fun requireLineIndexInRange(lineIndex: Int) {
- require(lineIndex in 0 until lineCount) {
+ requirePrecondition(lineIndex in 0 until lineCount) {
"lineIndex($lineIndex) is out of bounds [0, $lineCount)"
}
}
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraphIntrinsics.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraphIntrinsics.kt
index 2c35bcb..252c853 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraphIntrinsics.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/MultiParagraphIntrinsics.kt
@@ -19,11 +19,11 @@
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.createFontFamilyResolver
+import androidx.compose.ui.text.internal.requirePrecondition
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.Density
import androidx.compose.ui.util.fastAny
-import androidx.compose.ui.util.fastFilter
-import androidx.compose.ui.util.fastMap
+import androidx.compose.ui.util.fastFilteredMap
import androidx.compose.ui.util.fastMaxBy
/**
@@ -142,13 +142,12 @@
}
private fun List<AnnotatedString.Range<Placeholder>>.getLocalPlaceholders(start: Int, end: Int) =
- fastFilter { intersect(start, end, it.start, it.end) }
- .fastMap {
- require(start <= it.start && it.end <= end) {
- "placeholder can not overlap with paragraph."
- }
- AnnotatedString.Range(it.item, it.start - start, it.end - start)
+ fastFilteredMap({ intersect(start, end, it.start, it.end) }) {
+ requirePrecondition(start <= it.start && it.end <= end) {
+ "placeholder can not overlap with paragraph."
}
+ AnnotatedString.Range(it.item, it.start - start, it.end - start)
+ }
internal data class ParagraphIntrinsicInfo(
val intrinsics: ParagraphIntrinsics,
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphStyle.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphStyle.kt
index afeeab0..96b97b3 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphStyle.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphStyle.kt
@@ -18,6 +18,7 @@
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
+import androidx.compose.ui.text.internal.checkPrecondition
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.LineHeightStyle
@@ -204,7 +205,9 @@
init {
if (lineHeight != TextUnit.Unspecified) {
// Since we are checking if it's negative, no need to convert Sp into Px at this point.
- check(lineHeight.value >= 0f) { "lineHeight can't be negative (${lineHeight.value})" }
+ checkPrecondition(lineHeight.value >= 0f) {
+ "lineHeight can't be negative (${lineHeight.value})"
+ }
}
}
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Placeholder.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Placeholder.kt
index 4afec70..a7fe2c5 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Placeholder.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/Placeholder.kt
@@ -17,6 +17,7 @@
package androidx.compose.ui.text
import androidx.compose.runtime.Immutable
+import androidx.compose.ui.text.internal.requirePrecondition
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.isUnspecified
@@ -40,8 +41,8 @@
val placeholderVerticalAlign: PlaceholderVerticalAlign
) {
init {
- require(!width.isUnspecified) { "width cannot be TextUnit.Unspecified" }
- require(!height.isUnspecified) { "height cannot be TextUnit.Unspecified" }
+ requirePrecondition(!width.isUnspecified) { "width cannot be TextUnit.Unspecified" }
+ requirePrecondition(!height.isUnspecified) { "height cannot be TextUnit.Unspecified" }
}
fun copy(
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextMeasurer.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextMeasurer.kt
index cd65c98..b35f666 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextMeasurer.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextMeasurer.kt
@@ -144,7 +144,7 @@
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
- placeholders: List<AnnotatedString.Range<Placeholder>> = emptyList(),
+ placeholders: List<AnnotatedString.Range<Placeholder>> = listOf(),
constraints: Constraints = Constraints(),
layoutDirection: LayoutDirection = this.defaultLayoutDirection,
density: Density = this.defaultDensity,
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextPainter.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextPainter.kt
index 4ef4dad..cd3885e 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextPainter.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextPainter.kt
@@ -148,7 +148,7 @@
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
- placeholders: List<AnnotatedString.Range<Placeholder>> = emptyList(),
+ placeholders: List<AnnotatedString.Range<Placeholder>> = listOf(),
size: Size = Size.Unspecified,
blendMode: BlendMode = DrawScope.DefaultBlendMode
) {
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextRange.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextRange.kt
index 209d1f6..f9c3c0a 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextRange.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TextRange.kt
@@ -17,9 +17,13 @@
package androidx.compose.ui.text
import androidx.compose.runtime.Immutable
+import androidx.compose.ui.text.internal.requirePrecondition
+import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.packInts
import androidx.compose.ui.util.unpackInt1
import androidx.compose.ui.util.unpackInt2
+import kotlin.math.max
+import kotlin.math.min
fun CharSequence.substring(range: TextRange): String = this.substring(range.min, range.max)
@@ -53,11 +57,11 @@
/** The minimum offset of the range. */
val min: Int
- get() = if (start > end) end else start
+ get() = min(start, end)
/** The maximum offset of the range. */
val max: Int
- get() = if (start > end) start else end
+ get() = max(start, end)
/** Returns true if the range is collapsed */
val collapsed: Boolean
@@ -72,10 +76,10 @@
get() = max - min
/** Returns true if the given range has intersection with this range */
- fun intersects(other: TextRange): Boolean = min < other.max && other.min < max
+ fun intersects(other: TextRange): Boolean = (min < other.max) and (other.min < max)
/** Returns true if this range covers including equals with the given range. */
- operator fun contains(other: TextRange): Boolean = min <= other.min && other.max <= max
+ operator fun contains(other: TextRange): Boolean = (min <= other.min) and (other.max <= max)
/** Returns true if the given offset is a part of this range. */
operator fun contains(offset: Int): Boolean = offset in min until max
@@ -102,8 +106,8 @@
* @param maximumValue the exclusive maximum value that [TextRange.start] or [TextRange.end] can be.
*/
fun TextRange.coerceIn(minimumValue: Int, maximumValue: Int): TextRange {
- val newStart = start.coerceIn(minimumValue, maximumValue)
- val newEnd = end.coerceIn(minimumValue, maximumValue)
+ val newStart = start.fastCoerceIn(minimumValue, maximumValue)
+ val newEnd = end.fastCoerceIn(minimumValue, maximumValue)
if (newStart != start || newEnd != end) {
return TextRange(newStart, newEnd)
}
@@ -111,7 +115,8 @@
}
private fun packWithCheck(start: Int, end: Int): Long {
- require(start >= 0) { "start cannot be negative. [start: $start, end: $end]" }
- require(end >= 0) { "end cannot be negative. [start: $start, end: $end]" }
+ requirePrecondition(start >= 0 && end >= 0) {
+ "start and end cannot be negative. [start: $start, end: $end]"
+ }
return packInts(start, end)
}
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontFamily.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontFamily.kt
index b9953c1..fa98708 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontFamily.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontFamily.kt
@@ -19,6 +19,7 @@
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
+import androidx.compose.ui.text.internal.checkPrecondition
/**
* The primary typography interface for Compose applications.
@@ -164,7 +165,7 @@
val fonts: List<Font>
) : FileBasedFontFamily(), List<Font> by fonts {
init {
- check(fonts.isNotEmpty()) { "At least one font should be passed to FontFamily" }
+ checkPrecondition(fonts.isNotEmpty()) { "At least one font should be passed to FontFamily" }
}
override fun equals(other: Any?): Boolean {
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontListFontFamilyTypefaceAdapter.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontListFontFamilyTypefaceAdapter.kt
index 54c0aa1..1c22f1f 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontListFontFamilyTypefaceAdapter.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontListFontFamilyTypefaceAdapter.kt
@@ -26,7 +26,7 @@
import androidx.compose.ui.text.platform.createSynchronizedObject
import androidx.compose.ui.text.platform.synchronized
import androidx.compose.ui.util.fastDistinctBy
-import androidx.compose.ui.util.fastFilter
+import androidx.compose.ui.util.fastFilteredMap
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMap
import kotlin.coroutines.CoroutineContext
@@ -68,8 +68,9 @@
// only preload styles that can be satisfied by async fonts
val asyncStyles =
family.fonts
- .fastFilter { it.loadingStrategy == FontLoadingStrategy.Async }
- .fastMap { it.weight to it.style }
+ .fastFilteredMap({ it.loadingStrategy == FontLoadingStrategy.Async }) {
+ it.weight to it.style
+ }
.fastDistinctBy { it }
val asyncLoads: MutableList<Font> = mutableListOf()
@@ -252,8 +253,7 @@
return asyncFontsToLoad to fallbackTypeface
}
-internal class AsyncFontListLoader
-constructor(
+internal class AsyncFontListLoader(
private val fontList: List<Font>,
initialType: Any,
private val typefaceRequest: TypefaceRequest,
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontMatcher.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontMatcher.kt
index 43dbe88..7de23a6 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontMatcher.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontMatcher.kt
@@ -99,7 +99,7 @@
return result
}
- @Suppress("NOTHING_TO_INLINE")
+ @Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
// @VisibleForTesting
internal inline fun List<Font>.filterByClosestWeight(
fontWeight: FontWeight,
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontSynthesis.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontSynthesis.kt
index 00e6084..e972956 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontSynthesis.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontSynthesis.kt
@@ -15,6 +15,10 @@
*/
package androidx.compose.ui.text.font
+private const val AllFlags = 0xffff
+private const val WeightFlag = 0x1
+private const val StyleFlag = 0x2
+
/**
* Possible options for font synthesis.
*
@@ -39,13 +43,14 @@
override fun toString(): String {
return when (this) {
None -> "None"
- All -> "All"
Weight -> "Weight"
Style -> "Style"
+ All -> "All"
else -> "Invalid"
}
}
+ // NOTE: The values below are selected to be used as flags. See isWeightOn for instance.
companion object {
/**
* Turns off font synthesis. Neither bold nor slanted faces are synthesized if they don't
@@ -54,29 +59,29 @@
val None = FontSynthesis(0)
/**
- * The system synthesizes both bold and slanted fonts if either of them are not available in
- * the [FontFamily]
- */
- val All = FontSynthesis(1)
-
- /**
* Only a bold font is synthesized, if it is not available in the [FontFamily]. Slanted
* fonts will not be synthesized.
*/
- val Weight = FontSynthesis(2)
+ val Weight = FontSynthesis(WeightFlag)
/**
* Only an slanted font is synthesized, if it is not available in the [FontFamily]. Bold
* fonts will not be synthesized.
*/
- val Style = FontSynthesis(3)
+ val Style = FontSynthesis(StyleFlag)
+
+ /**
+ * The system synthesizes both bold and slanted fonts if either of them are not available in
+ * the [FontFamily]
+ */
+ val All = FontSynthesis(AllFlags)
}
internal val isWeightOn: Boolean
- get() = this == All || this == Weight
+ get() = value and WeightFlag != 0
internal val isStyleOn: Boolean
- get() = this == All || this == Style
+ get() = value and StyleFlag != 0
}
/**
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontVariation.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontVariation.kt
index 53dcf8b..0537796c 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontVariation.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontVariation.kt
@@ -17,6 +17,8 @@
package androidx.compose.ui.text.font
import androidx.compose.runtime.Immutable
+import androidx.compose.ui.text.internal.requirePrecondition
+import androidx.compose.ui.text.internal.requirePreconditionNotNull
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.util.fastAny
@@ -130,7 +132,7 @@
override fun toVariationValue(density: Density?): Float {
// we don't care about pixel density as 12sp is the same "visual" size on all devices
// instead we only care about font scaling, which changes visual size
- requireNotNull(density) { "density must not be null" }
+ requirePreconditionNotNull(density) { "density must not be null" }
return value.value * density.fontScale
}
@@ -206,7 +208,9 @@
* @param value value for axis, not validated and directly passed to font
*/
fun Setting(name: String, value: Float): Setting {
- require(name.length == 4) { "Name must be exactly four characters. Actual: '$name'" }
+ requirePrecondition(name.length == 4) {
+ "Name must be exactly four characters. Actual: '$name'"
+ }
return SettingFloat(name, value)
}
@@ -226,7 +230,7 @@
* @param value [0.0f, 1.0f]
*/
fun italic(value: Float): Setting {
- require(value in 0.0f..1.0f) { "'ital' must be in 0.0f..1.0f. Actual: $value" }
+ requirePrecondition(value in 0.0f..1.0f) { "'ital' must be in 0.0f..1.0f. Actual: $value" }
return SettingFloat("ital", value)
}
@@ -248,7 +252,7 @@
* @param textSize font-size at the expected display, must be in sp
*/
fun opticalSizing(textSize: TextUnit): Setting {
- require(textSize.isSp) { "'opsz' must be provided in sp units" }
+ requirePrecondition(textSize.isSp) { "'opsz' must be provided in sp units" }
return SettingTextUnit("opsz", textSize)
}
@@ -263,7 +267,7 @@
* @param value -90f to 90f, represents an angle
*/
fun slant(value: Float): Setting {
- require(value in -90f..90f) { "'slnt' must be in -90f..90f. Actual: $value" }
+ requirePrecondition(value in -90f..90f) { "'slnt' must be in -90f..90f. Actual: $value" }
return SettingFloat("slnt", value)
}
@@ -279,7 +283,7 @@
* @param value > 0.0f represents the width
*/
fun width(value: Float): Setting {
- require(value > 0.0f) { "'wdth' must be strictly > 0.0f. Actual: $value" }
+ requirePrecondition(value > 0.0f) { "'wdth' must be strictly > 0.0f. Actual: $value" }
return SettingFloat("wdth", value)
}
@@ -304,7 +308,9 @@
* @param value weight, in 1..1000
*/
fun weight(value: Int): Setting {
- require(value in 1..1000) { "'wght' value must be in [1, 1000]. Actual: $value" }
+ requirePrecondition(value in 1..1000) {
+ "'wght' value must be in [1, 1000]. Actual: $value"
+ }
return SettingInt("wght", value)
}
@@ -324,7 +330,7 @@
* @param value grade, in -1000..1000
*/
fun grade(value: Int): Setting {
- require(value in -1000..1000) { "'GRAD' must be in -1000..1000" }
+ requirePrecondition(value in -1000..1000) { "'GRAD' must be in -1000..1000" }
return SettingInt("GRAD", value)
}
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontWeight.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontWeight.kt
index 20795b3..b3a8e29 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontWeight.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/font/FontWeight.kt
@@ -17,6 +17,7 @@
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
+import androidx.compose.ui.text.internal.requirePrecondition
import androidx.compose.ui.util.lerp
/**
@@ -74,7 +75,7 @@
}
init {
- require(weight in 1..1000) {
+ requirePrecondition(weight in 1..1000) {
"Font weight can be in range [1, 1000]. Current value: $weight"
}
}
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditCommand.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditCommand.kt
index 0034692..018da98 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditCommand.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditCommand.kt
@@ -19,6 +19,7 @@
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.findFollowingBreak
import androidx.compose.ui.text.findPrecedingBreak
+import androidx.compose.ui.text.internal.requirePrecondition
/**
* [EditCommand] is a command representation for the platform IME API function calls. The commands
@@ -246,7 +247,7 @@
class DeleteSurroundingTextCommand(val lengthBeforeCursor: Int, val lengthAfterCursor: Int) :
EditCommand {
init {
- require(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) {
+ requirePrecondition(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) {
"Expected lengthBeforeCursor and lengthAfterCursor to be non-negative, were " +
"$lengthBeforeCursor and $lengthAfterCursor respectively."
}
@@ -305,7 +306,7 @@
val lengthAfterCursor: Int
) : EditCommand {
init {
- require(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) {
+ requirePrecondition(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) {
"Expected lengthBeforeCursor and lengthAfterCursor to be non-negative, were " +
"$lengthBeforeCursor and $lengthAfterCursor respectively."
}
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditingBuffer.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditingBuffer.kt
index 4e1a985..26031c8 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditingBuffer.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/EditingBuffer.kt
@@ -19,6 +19,7 @@
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.InternalTextApi
import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.internal.requirePrecondition
/**
* The editing buffer
@@ -46,14 +47,18 @@
/** The inclusive selection start offset */
internal var selectionStart = selection.min
private set(value) {
- require(value >= 0) { "Cannot set selectionStart to a negative value: $value" }
+ requirePrecondition(value >= 0) {
+ "Cannot set selectionStart to a negative value: $value"
+ }
field = value
}
/** The exclusive selection end offset */
internal var selectionEnd = selection.max
private set(value) {
- require(value >= 0) { "Cannot set selectionEnd to a negative value: $value" }
+ requirePrecondition(value >= 0) {
+ "Cannot set selectionEnd to a negative value: $value"
+ }
field = value
}
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/GapBuffer.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/GapBuffer.kt
index 1275d48..8940352a 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/GapBuffer.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/GapBuffer.kt
@@ -18,6 +18,7 @@
import androidx.annotation.RestrictTo
import androidx.compose.ui.text.InternalTextApi
+import androidx.compose.ui.text.internal.requirePrecondition
/**
* Like [toCharArray] but copies the entire source string. Workaround for compiler error when giving
@@ -238,10 +239,10 @@
* @param text a text to replace
*/
fun replace(start: Int, end: Int, text: String) {
- require(start <= end) {
+ requirePrecondition(start <= end) {
"start index must be less than or equal to end index: $start > $end"
}
- require(start >= 0) { "start must be non-negative, but was $start" }
+ requirePrecondition(start >= 0) { "start must be non-negative, but was $start" }
val buffer = buffer
if (buffer == null) { // First time to create gap buffer
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/MathUtils.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/MathUtils.kt
index 9f7a900..c4597f8 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/MathUtils.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/MathUtils.kt
@@ -16,17 +16,27 @@
package androidx.compose.ui.text.input
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+
/** Adds [this] and [right], and if an overflow occurs returns result of [defaultValue]. */
+@Suppress("BanInlineOptIn")
+@OptIn(ExperimentalContracts::class)
internal inline fun Int.addExactOrElse(right: Int, defaultValue: () -> Int): Int {
+ contract { callsInPlace(defaultValue, InvocationKind.AT_MOST_ONCE) }
val result = this + right
- // HD 2-12 Overflow iff both arguments have the opposite sign of the result
+ // HD 2-12 Overflow if both arguments have the opposite sign of the result
return if (this xor result and (right xor result) < 0) defaultValue() else result
}
/** Subtracts [right] from [this], and if an overflow occurs returns result of [defaultValue]. */
+@Suppress("BanInlineOptIn")
+@OptIn(ExperimentalContracts::class)
internal inline fun Int.subtractExactOrElse(right: Int, defaultValue: () -> Int): Int {
+ contract { callsInPlace(defaultValue, InvocationKind.AT_MOST_ONCE) }
val result = this - right
- // HD 2-12 Overflow iff the arguments have different signs and
+ // HD 2-12 Overflow if the arguments have different signs and
// the sign of the result is different from the sign of x
return if (this xor right and (this xor result) < 0) defaultValue() else result
}
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/internal/InlineClassHelper.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/internal/InlineClassHelper.kt
new file mode 100644
index 0000000..036f341d
--- /dev/null
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/internal/InlineClassHelper.kt
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
+
+package androidx.compose.ui.text.internal
+
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+
+/**
+ * Throws an [IllegalStateException] with the specified [message]. This function is guaranteed to
+ * not be inlined, which reduces the amount of code generated at the call site. This code size
+ * reduction helps performance by not pre-caching instructions that will rarely/never be executed.
+ */
+internal fun throwIllegalStateException(message: String) {
+ throw IllegalStateException(message)
+}
+
+/**
+ * Throws an [IllegalStateException] with the specified [message]. This function is guaranteed to
+ * not be inlined, which reduces the amount of code generated at the call site. This code size
+ * reduction helps performance by not pre-caching instructions that will rarely/never be executed.
+ *
+ * This function returns [Nothing] to tell the compiler it's a terminating branch in the code,
+ * making it suitable for use in a `when` statement or when doing a `null` check to force a smart
+ * cast to non-null (see [checkPreconditionNotNull].
+ */
+internal fun throwIllegalStateExceptionForNullCheck(message: String): Nothing {
+ throw IllegalStateException(message)
+}
+
+/**
+ * Throws an [IllegalArgumentException] with the specified [message]. This function is guaranteed to
+ * not be inlined, which reduces the amount of code generated at the call site. This code size
+ * reduction helps performance by not pre-caching instructions that will rarely/never be executed.
+ */
+internal fun throwIllegalArgumentException(message: String) {
+ throw IllegalArgumentException(message)
+}
+
+/**
+ * Throws an [IllegalArgumentException] with the specified [message]. This function is guaranteed to
+ * not be inlined, which reduces the amount of code generated at the call site. This code size
+ * reduction helps performance by not pre-caching instructions that will rarely/never be executed.
+ *
+ * This function returns [Nothing] to tell the compiler it's a terminating branch in the code,
+ * making it suitable for use in a `when` statement or when doing a `null` check to force a smart
+ * cast to non-null (see [requirePreconditionNotNull].
+ */
+internal fun throwIllegalArgumentExceptionForNullCheck(message: String): Nothing {
+ throw IllegalArgumentException(message)
+}
+
+/**
+ * Like Kotlin's [check] but without the `toString()` call on the output of the lambda, and a
+ * non-inline throw. This implementation generates less code at the call site, which can help
+ * performance by not loading instructions that will rarely/never execute.
+ */
+@Suppress("BanInlineOptIn")
+@OptIn(ExperimentalContracts::class)
+internal inline fun checkPrecondition(value: Boolean, lazyMessage: () -> String) {
+ contract {
+ callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE)
+ returns() implies value
+ }
+ if (!value) {
+ throwIllegalStateException(lazyMessage())
+ }
+}
+
+/**
+ * Like Kotlin's [checkNotNull] but without the `toString()` call on the output of the lambda, and a
+ * non-inline throw. This implementation generates less code at the call site, which can help
+ * performance by not loading instructions that will rarely/never execute.
+ */
+@Suppress("BanInlineOptIn")
+@OptIn(ExperimentalContracts::class)
+internal inline fun <T : Any> checkPreconditionNotNull(value: T?, lazyMessage: () -> String): T {
+ contract {
+ callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE)
+ returns() implies (value != null)
+ }
+
+ if (value == null) {
+ throwIllegalStateExceptionForNullCheck(lazyMessage())
+ }
+
+ return value
+}
+
+/**
+ * Like Kotlin's [require] but without the `toString()` call on the output of the lambda, and a
+ * non-inline throw. This implementation generates less code at the call site, which can help
+ * performance by not loading instructions that will rarely/never execute.
+ */
+@Suppress("BanInlineOptIn")
+@OptIn(ExperimentalContracts::class) // same opt-in as using Kotlin's require()
+internal inline fun requirePrecondition(value: Boolean, lazyMessage: () -> String) {
+ contract {
+ callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE)
+ returns() implies value
+ }
+ if (!value) {
+ throwIllegalArgumentException(lazyMessage())
+ }
+}
+
+/**
+ * Like Kotlin's [requireNotNull] but without the `toString()` call on the output of the lambda, and
+ * a non-inline throw. This implementation generates less code at the call site, which can help
+ * performance by not loading instructions that will rarely/never execute.
+ */
+@Suppress("BanInlineOptIn")
+@OptIn(ExperimentalContracts::class)
+internal inline fun <T : Any> requirePreconditionNotNull(value: T?, lazyMessage: () -> String): T {
+ contract {
+ callsInPlace(lazyMessage, InvocationKind.AT_MOST_ONCE)
+ returns() implies (value != null)
+ }
+
+ if (value == null) {
+ throwIllegalArgumentExceptionForNullCheck(lazyMessage())
+ }
+
+ return value
+}
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/intl/LocaleList.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/intl/LocaleList.kt
index 6cb71cf..f48e5f4 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/intl/LocaleList.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/intl/LocaleList.kt
@@ -35,7 +35,7 @@
* An empty instance of [LocaleList]. Usually used to reference a lack of explicit [Locale]
* configuration.
*/
- val Empty = LocaleList(emptyList())
+ val Empty = LocaleList(listOf())
/** Returns Locale object which represents current locale */
val current: LocaleList
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/LineHeightStyle.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/LineHeightStyle.kt
index 5e5af9d..414d048 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/LineHeightStyle.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/LineHeightStyle.kt
@@ -17,6 +17,7 @@
package androidx.compose.ui.text.style
import androidx.compose.ui.text.PlatformParagraphStyle
+import androidx.compose.ui.text.internal.checkPrecondition
import kotlin.jvm.JvmInline
/**
@@ -207,7 +208,7 @@
value class Alignment constructor(internal val topRatio: Float) {
init {
- check(topRatio in 0f..1f || topRatio == -1f) {
+ checkPrecondition(topRatio in 0f..1f || topRatio == -1f) {
"topRatio should be in [0..1] range or -1"
}
}
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/TextForegroundStyle.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/TextForegroundStyle.kt
index 9cd6708..7b7878d 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/TextForegroundStyle.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/style/TextForegroundStyle.kt
@@ -23,6 +23,7 @@
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.lerp as lerpColor
+import androidx.compose.ui.text.internal.requirePrecondition
import androidx.compose.ui.text.lerpDiscrete
import androidx.compose.ui.util.lerp
import kotlin.jvm.JvmName
@@ -89,7 +90,7 @@
private data class ColorStyle(val value: Color) : TextForegroundStyle {
init {
- require(value.isSpecified) {
+ requirePrecondition(value.isSpecified) {
"ColorStyle value must be specified, use TextForegroundStyle.Unspecified instead."
}
}
diff --git a/compose/ui/ui-util/api/current.txt b/compose/ui/ui-util/api/current.txt
index e18cbf3..fbfa507 100644
--- a/compose/ui/ui-util/api/current.txt
+++ b/compose/ui/ui-util/api/current.txt
@@ -39,6 +39,7 @@
method public static inline <T, K> java.util.List<T> fastDistinctBy(java.util.List<? extends T>, kotlin.jvm.functions.Function1<? super T,? extends K> selector);
method public static inline <T> java.util.List<T> fastFilter(java.util.List<? extends T>, kotlin.jvm.functions.Function1<? super T,java.lang.Boolean> predicate);
method public static <T> java.util.List<T> fastFilterNotNull(java.util.List<? extends T?>);
+ method public static inline <T, R> java.util.List<R> fastFilteredMap(java.util.List<? extends T>, kotlin.jvm.functions.Function1<? super T,java.lang.Boolean> predicate, kotlin.jvm.functions.Function1<? super T,? extends R> transform);
method public static inline <T> T fastFirst(java.util.List<? extends T>, kotlin.jvm.functions.Function1<? super T,java.lang.Boolean> predicate);
method public static inline <T> T? fastFirstOrNull(java.util.List<? extends T>, kotlin.jvm.functions.Function1<? super T,java.lang.Boolean> predicate);
method public static inline <T, R> java.util.List<R> fastFlatMap(java.util.List<? extends T>, kotlin.jvm.functions.Function1<? super T,? extends java.lang.Iterable<? extends R>> transform);
diff --git a/compose/ui/ui-util/api/restricted_current.txt b/compose/ui/ui-util/api/restricted_current.txt
index be90ec4..3ef27e1 100644
--- a/compose/ui/ui-util/api/restricted_current.txt
+++ b/compose/ui/ui-util/api/restricted_current.txt
@@ -39,6 +39,7 @@
method public static inline <T, K> java.util.List<T> fastDistinctBy(java.util.List<? extends T>, kotlin.jvm.functions.Function1<? super T,? extends K> selector);
method public static inline <T> java.util.List<T> fastFilter(java.util.List<? extends T>, kotlin.jvm.functions.Function1<? super T,java.lang.Boolean> predicate);
method public static <T> java.util.List<T> fastFilterNotNull(java.util.List<? extends T?>);
+ method public static inline <T, R> java.util.List<R> fastFilteredMap(java.util.List<? extends T>, kotlin.jvm.functions.Function1<? super T,java.lang.Boolean> predicate, kotlin.jvm.functions.Function1<? super T,? extends R> transform);
method public static inline <T> T fastFirst(java.util.List<? extends T>, kotlin.jvm.functions.Function1<? super T,java.lang.Boolean> predicate);
method public static inline <T> T? fastFirstOrNull(java.util.List<? extends T>, kotlin.jvm.functions.Function1<? super T,java.lang.Boolean> predicate);
method public static inline <T, R> java.util.List<R> fastFlatMap(java.util.List<? extends T>, kotlin.jvm.functions.Function1<? super T,? extends java.lang.Iterable<? extends R>> transform);
diff --git a/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/ListUtils.kt b/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/ListUtils.kt
index 889ba5c..ff8ae9b 100644
--- a/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/ListUtils.kt
+++ b/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/ListUtils.kt
@@ -234,6 +234,26 @@
}
/**
+ * Returns a list containing only elements matching the given [predicate], applying the given
+ * [transform] function to each element.
+ *
+ * **Do not use for collections that come from public APIs**, since they may not support random
+ * access in an efficient way, and this method may actually be a lot slower. Only use for
+ * collections that are created by code we control and are known to support random access.
+ */
+@Suppress("BanInlineOptIn") // Treat Kotlin Contracts as non-experimental.
+@OptIn(ExperimentalContracts::class)
+inline fun <T, R> List<T>.fastFilteredMap(predicate: (T) -> Boolean, transform: (T) -> R): List<R> {
+ contract {
+ callsInPlace(predicate)
+ callsInPlace(transform)
+ }
+ val target = ArrayList<R>(size)
+ fastForEach { if (predicate(it)) target += transform(it) }
+ return target
+}
+
+/**
* Accumulates value starting with [initial] value and applying [operation] from left to right to
* current accumulator value and each element.
*
diff --git a/compose/ui/ui/proguard-rules.pro b/compose/ui/ui/proguard-rules.pro
index f5603a7..fa5b4c3 100644
--- a/compose/ui/ui/proguard-rules.pro
+++ b/compose/ui/ui/proguard-rules.pro
@@ -41,6 +41,9 @@
# For methods returning Nothing
static java.lang.Void throw*Exception(...);
static java.lang.Void throw*ExceptionForNullCheck(...);
+ # For functions generating error messages
+ static java.lang.String exceptionMessage*(...);
+ java.lang.String exceptionMessage*(...);
}
# When pointer input modifier nodes are added dynamically and have the same keys (common when
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 4229de0..05d8c98 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
@@ -279,19 +279,8 @@
* then [instance] will become [attach]ed also. [instance] must have a `null` [parent].
*/
internal fun insertAt(index: Int, instance: LayoutNode) {
- checkPrecondition(instance._foldedParent == null) {
- "Cannot insert $instance because it already has a parent." +
- " This tree: " +
- debugTreeToString() +
- " Other tree: " +
- instance._foldedParent?.debugTreeToString()
- }
- checkPrecondition(instance.owner == null) {
- "Cannot insert $instance because it already has an owner." +
- " This tree: " +
- debugTreeToString() +
- " Other tree: " +
- instance.debugTreeToString()
+ checkPrecondition(instance._foldedParent == null || instance.owner == null) {
+ exceptionMessageForParentingOrOwnership(instance)
}
if (DebugChanges) {
@@ -317,6 +306,13 @@
}
}
+ private fun exceptionMessageForParentingOrOwnership(instance: LayoutNode) =
+ "Cannot insert $instance because it already has a parent or an owner." +
+ " This tree: " +
+ debugTreeToString() +
+ " Other tree: " +
+ instance._foldedParent?.debugTreeToString()
+
internal fun onZSortedChildrenInvalidated() {
if (isVirtual) {
parent?.onZSortedChildrenInvalidated()
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/CreateRestoreCredentialRequestJavaTest.java b/credentials/credentials/src/androidTest/java/androidx/credentials/CreateRestoreCredentialRequestJavaTest.java
new file mode 100644
index 0000000..fcaaada
--- /dev/null
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/CreateRestoreCredentialRequestJavaTest.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.credentials;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class CreateRestoreCredentialRequestJavaTest {
+ private static final String TEST_USERNAME = "[email protected]";
+ private static final String TEST_USER_DISPLAYNAME = "Test User";
+ private static final String TEST_REQUEST_JSON = String.format("{\"rp\":{\"name\":true,"
+ + "\"id\":\"app-id\"},\"user\":{\"name\":\"%s\",\"id\":\"id-value\","
+ + "\"displayName\":\"%s\",\"icon\":true}, \"challenge\":true,"
+ + "\"pubKeyCredParams\":true,\"excludeCredentials\":true,"
+ + "\"attestation\":true}", TEST_USERNAME,
+ TEST_USER_DISPLAYNAME);
+
+ private Context mContext = InstrumentationRegistry.getInstrumentation().getContext();
+
+ @Test
+ public void constructor_emptyJson_throwsIllegalArgumentException() {
+ assertThrows("Expected empty Json to throw error",
+ IllegalArgumentException.class,
+ () -> new CreateRestoreCredentialRequest("")
+ );
+ }
+
+ @Test
+ public void constructor_invalidJson_throwsIllegalArgumentException() {
+ assertThrows("Expected empty Json to throw error",
+ IllegalArgumentException.class,
+ () -> new CreateRestoreCredentialRequest("invalid")
+ );
+ }
+
+ @Test
+ public void constructor_jsonMissingUserName_throwsIllegalArgumentException() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> new CreateRestoreCredentialRequest(
+ "{\"key\":{\"value\":{\"lol\":\"Value\"}}}"
+ )
+ );
+ }
+
+ @Test
+ public void constructor_nullJson_throwsNullPointerException() {
+ assertThrows("Expected null Json to throw NPE",
+ NullPointerException.class,
+ () -> new CreateRestoreCredentialRequest(null)
+ );
+ }
+
+ @Test
+ public void constructor_success() {
+ new CreateRestoreCredentialRequest(TEST_REQUEST_JSON);
+ }
+
+ @Test
+ public void constructor_setsIsCloudBackupEnabledByDefault() {
+ CreateRestoreCredentialRequest createRestoreCredentialRequest =
+ new CreateRestoreCredentialRequest(TEST_REQUEST_JSON);
+
+ assertThat(createRestoreCredentialRequest.isCloudBackupEnabled()).isTrue();
+ }
+
+ @Test
+ public void getter_requestJson_success() {
+ String testJsonExpected = TEST_REQUEST_JSON;
+ CreateRestoreCredentialRequest createRestoreCredentialRequest =
+ new CreateRestoreCredentialRequest(testJsonExpected);
+
+ String testJsonActual = createRestoreCredentialRequest.getRequestJson();
+ assertThat(testJsonActual).isEqualTo(testJsonExpected);
+ }
+}
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/CreateRestoreCredentialRequestTest.kt b/credentials/credentials/src/androidTest/java/androidx/credentials/CreateRestoreCredentialRequestTest.kt
new file mode 100644
index 0000000..e55acd4
--- /dev/null
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/CreateRestoreCredentialRequestTest.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.credentials
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class CreateRestoreCredentialRequestTest {
+ companion object Constant {
+ private const val TEST_USERNAME = "[email protected]"
+ private const val TEST_USER_DISPLAYNAME = "Test User"
+ private const val TEST_REQUEST_JSON =
+ "{\"rp\":{\"name\":true,\"id\":\"app-id\"}," +
+ "\"user\":{\"name\":\"$TEST_USERNAME\",\"id\":\"id-value\",\"displayName" +
+ "\":\"$TEST_USER_DISPLAYNAME\",\"icon\":true}, \"challenge\":true," +
+ "\"pubKeyCredParams\":true,\"excludeCredentials\":true," +
+ "\"attestation\":true}"
+ }
+
+ @Test
+ fun constructor_emptyJson_throwsIllegalArgumentException() {
+ assertThrows("Expected empty Json to throw error", IllegalArgumentException::class.java) {
+ CreateRestoreCredentialRequest("")
+ }
+ }
+
+ @Test
+ fun constructor_invalidJson_throwsIllegalArgumentException() {
+ assertThrows("Expected empty Json to throw error", IllegalArgumentException::class.java) {
+ CreateRestoreCredentialRequest("invalid")
+ }
+ }
+
+ @Test
+ fun constructor_jsonMissingUserName_throwsIllegalArgumentException() {
+ assertThrows(IllegalArgumentException::class.java) {
+ CreateRestoreCredentialRequest("{\"hey\":{\"hi\":{\"hello\":\"hii\"}}}")
+ }
+ }
+
+ @Test
+ fun constructor_setsIsCloudBackupEnabledByDefault() {
+ val createRestoreCredentialRequest = CreateRestoreCredentialRequest(TEST_REQUEST_JSON)
+
+ assertThat(createRestoreCredentialRequest.isCloudBackupEnabled).isTrue()
+ }
+
+ @Test
+ fun constructor_setsIsCloudBackupEnabledToFalse() {
+ val createRestoreCredentialRequest =
+ CreateRestoreCredentialRequest(TEST_REQUEST_JSON, /* isCloudBackupEnabled= */ false)
+
+ assertThat(createRestoreCredentialRequest.isCloudBackupEnabled).isFalse()
+ }
+
+ @Test
+ fun getter_requestJson_success() {
+ val createRestoreCredentialRequest = CreateRestoreCredentialRequest(TEST_REQUEST_JSON)
+
+ assertThat(createRestoreCredentialRequest.requestJson).isEqualTo(TEST_REQUEST_JSON)
+ }
+}
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/CreateRestoreCredentialResponseJavaTest.java b/credentials/credentials/src/androidTest/java/androidx/credentials/CreateRestoreCredentialResponseJavaTest.java
new file mode 100644
index 0000000..e58f803
--- /dev/null
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/CreateRestoreCredentialResponseJavaTest.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.credentials;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class CreateRestoreCredentialResponseJavaTest {
+ private static final String TEST_RESPONSE_JSON = "{\"hi\":{\"there\":{\"lol\":\"Value\"}}}";
+
+ @Test
+ public void constructor_emptyJson_throwsIllegalArgumentException() {
+ assertThrows("Expected empty Json to throw error",
+ IllegalArgumentException.class,
+ () -> new CreateRestoreCredentialResponse("")
+ );
+ }
+
+ @Test
+ public void constructor_nullJson_throwsNullPointerException() {
+ assertThrows("Expected null Json to throw NullPointerException",
+ NullPointerException.class,
+ () -> new CreateRestoreCredentialResponse(null)
+ );
+ }
+
+ @Test
+ public void constructor_success() {
+ new CreateRestoreCredentialResponse(TEST_RESPONSE_JSON);
+ }
+
+ @Test
+ public void getter_registrationResponseJson_success() {
+ String testJsonExpected = "{\"input\":5}";
+ CreateRestoreCredentialResponse createRestoreCredentialResponse =
+ new CreateRestoreCredentialResponse(testJsonExpected);
+ String testJsonActual = createRestoreCredentialResponse.getResponseJson();
+ assertThat(testJsonActual).isEqualTo(testJsonExpected);
+ }
+}
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/CreateRestoreCredentialResponseTest.kt b/credentials/credentials/src/androidTest/java/androidx/credentials/CreateRestoreCredentialResponseTest.kt
new file mode 100644
index 0000000..ad1d318
--- /dev/null
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/CreateRestoreCredentialResponseTest.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.credentials
+
+import android.os.Bundle
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class CreateRestoreCredentialResponseTest {
+
+ companion object Constant {
+ private const val TEST_RESPONSE_JSON = "{\"hi\":{\"there\":{\"lol\":\"Value\"}}}"
+ }
+
+ @Test
+ fun constructor_emptyJson_throwsIllegalArgumentException() {
+ Assert.assertThrows(
+ "Expected empty Json to throw error",
+ IllegalArgumentException::class.java
+ ) {
+ CreateRestoreCredentialResponse("")
+ }
+ }
+
+ @Test
+ fun constructor_invalidJson_throwsIllegalArgumentException() {
+ Assert.assertThrows(
+ "Expected empty Json to throw error",
+ IllegalArgumentException::class.java
+ ) {
+ CreateRestoreCredentialResponse("invalid")
+ }
+ }
+
+ @Test
+ fun constructor_success() {
+ CreateRestoreCredentialResponse(TEST_RESPONSE_JSON)
+ }
+
+ @Test
+ fun getter_registrationResponseJson_success() {
+ val testJsonExpected = "{\"input\":5}"
+ val createRestoreCredentialResponse = CreateRestoreCredentialResponse(testJsonExpected)
+ val testJsonActual = createRestoreCredentialResponse.responseJson
+ assertThat(testJsonActual).isEqualTo(testJsonExpected)
+ }
+
+ @Test
+ fun getter_frameworkProperties_success() {
+ val registrationResponseJsonExpected = "{\"input\":5}"
+ val expectedData = Bundle()
+ expectedData.putString(
+ CreateRestoreCredentialResponse.BUNDLE_KEY_CREATE_RESTORE_CREDENTIAL_RESPONSE,
+ registrationResponseJsonExpected
+ )
+
+ val response = CreateRestoreCredentialResponse(registrationResponseJsonExpected)
+
+ assertThat(response.type).isEqualTo(RestoreCredential.TYPE_RESTORE_CREDENTIAL)
+ assertThat(equals(response.data, expectedData)).isTrue()
+ }
+}
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/CredentialProviderFactoryTest.kt b/credentials/credentials/src/androidTest/java/androidx/credentials/CredentialProviderFactoryTest.kt
index beb4307..30a90a7 100644
--- a/credentials/credentials/src/androidTest/java/androidx/credentials/CredentialProviderFactoryTest.kt
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/CredentialProviderFactoryTest.kt
@@ -16,6 +16,7 @@
package androidx.credentials
import android.os.Build
+import androidx.credentials.ClearCredentialStateRequest.Companion.TYPE_CLEAR_RESTORE_CREDENTIAL
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SdkSuppress
import androidx.test.filters.SmallTest
@@ -29,6 +30,17 @@
@RunWith(AndroidJUnit4::class)
@SmallTest
class CredentialProviderFactoryTest {
+ companion object {
+ private const val TEST_USERNAME = "[email protected]"
+ private const val TEST_USER_DISPLAYNAME = "Test User"
+ private const val TEST_REQUEST_JSON =
+ "{\"rp\":{\"name\":true,\"id\":\"app-id\"}," +
+ "\"user\":{\"name\":\"$TEST_USERNAME\",\"id\":\"id-value\",\"displayName" +
+ "\":\"$TEST_USER_DISPLAYNAME\",\"icon\":true}, \"challenge\":true," +
+ "\"pubKeyCredParams\":true,\"excludeCredentials\":true," +
+ "\"attestation\":true}"
+ }
+
private val context = InstrumentationRegistry.getInstrumentation().context
private lateinit var credentialProviderFactory: CredentialProviderFactory
@@ -126,6 +138,35 @@
}
@Test
+ @SdkSuppress(minSdkVersion = 34)
+ fun getBestAvailableProvider_postU_restoreCredential_returnsPreU() {
+ if (Build.VERSION.SDK_INT <= 33) {
+ return
+ }
+ clearState()
+ val expectedProvider = FakeProvider(success = true)
+ credentialProviderFactory.testMode = true
+ credentialProviderFactory.testPreUProvider = expectedProvider
+
+ val credentialOptions = ArrayList<CredentialOption>()
+ credentialOptions.add(GetRestoreCredentialOption(TEST_REQUEST_JSON))
+ val request = GetCredentialRequest(credentialOptions)
+
+ assertThat(
+ credentialProviderFactory.getBestAvailableProvider(TYPE_CLEAR_RESTORE_CREDENTIAL)
+ )
+ .isEqualTo(expectedProvider)
+ assertThat(
+ credentialProviderFactory.getBestAvailableProvider(
+ CreateRestoreCredentialRequest(TEST_REQUEST_JSON)
+ )
+ )
+ .isEqualTo(expectedProvider)
+ assertThat(credentialProviderFactory.getBestAvailableProvider(request))
+ .isEqualTo(expectedProvider)
+ }
+
+ @Test
@SdkSuppress(maxSdkVersion = 33)
fun getBestAvailableProvider_preU_success() {
if (Build.VERSION.SDK_INT >= 34) {
@@ -169,4 +210,33 @@
assertNull(credentialProviderFactory.getBestAvailableProvider())
}
+
+ @Test
+ @SdkSuppress(maxSdkVersion = 33)
+ fun getBestAvailableProvider_preU_restoreCredential_returnsPreU() {
+ if (Build.VERSION.SDK_INT >= 34) {
+ return
+ }
+ clearState()
+ val expectedProvider = FakeProvider(success = true)
+ credentialProviderFactory.testMode = true
+ credentialProviderFactory.testPreUProvider = expectedProvider
+
+ val credentialOptions = ArrayList<CredentialOption>()
+ credentialOptions.add(GetRestoreCredentialOption(TEST_REQUEST_JSON))
+ val request = GetCredentialRequest(credentialOptions)
+
+ assertThat(
+ credentialProviderFactory.getBestAvailableProvider(TYPE_CLEAR_RESTORE_CREDENTIAL)
+ )
+ .isEqualTo(expectedProvider)
+ assertThat(
+ credentialProviderFactory.getBestAvailableProvider(
+ CreateRestoreCredentialRequest(TEST_REQUEST_JSON)
+ )
+ )
+ .isEqualTo(expectedProvider)
+ assertThat(credentialProviderFactory.getBestAvailableProvider(request))
+ .isEqualTo(expectedProvider)
+ }
}
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/GetCredentialRequestTest.kt b/credentials/credentials/src/androidTest/java/androidx/credentials/GetCredentialRequestTest.kt
index 65961ef..a1ab836 100644
--- a/credentials/credentials/src/androidTest/java/androidx/credentials/GetCredentialRequestTest.kt
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/GetCredentialRequestTest.kt
@@ -27,6 +27,7 @@
import org.junit.Test
import org.junit.runner.RunWith
+@OptIn(ExperimentalDigitalCredentialApi::class)
@RunWith(AndroidJUnit4::class)
@SmallTest
class GetCredentialRequestTest {
@@ -40,6 +41,41 @@
}
@Test
+ fun constructor_mixedUseOfGetRestoreCredentialOption_throws() {
+ assertThrows(IllegalArgumentException::class.java) {
+ val credentialOptions = ArrayList<CredentialOption>()
+ credentialOptions.add(GetRestoreCredentialOption(TEST_JSON))
+ credentialOptions.add(GetPasswordOption())
+ GetCredentialRequest(credentialOptions)
+ }
+ }
+
+ @Test
+ fun constructor_singleUseOfGetRestoreCredentialOption_doesNotThrow() {
+ val credentialOptions = ArrayList<CredentialOption>()
+ credentialOptions.add(GetRestoreCredentialOption(TEST_JSON))
+ GetCredentialRequest(credentialOptions)
+ }
+
+ @Test
+ fun constructor_mixedUseOfDigitalCredentialOption_throws() {
+ assertThrows(IllegalArgumentException::class.java) {
+ val credentialOptions = ArrayList<CredentialOption>()
+ credentialOptions.add(GetDigitalCredentialOption(TEST_JSON))
+ credentialOptions.add(GetPasswordOption())
+ GetCredentialRequest(credentialOptions)
+ }
+ }
+
+ @Test
+ fun constructor_singleUseOfDigitalCredentialOption_doesNotThrow() {
+ val credentialOptions = ArrayList<CredentialOption>()
+ credentialOptions.add(GetDigitalCredentialOption(TEST_JSON))
+ credentialOptions.add(GetDigitalCredentialOption(TEST_JSON))
+ GetCredentialRequest(credentialOptions)
+ }
+
+ @Test
fun constructor() {
val expectedCredentialOptions = ArrayList<CredentialOption>()
expectedCredentialOptions.add(GetPasswordOption())
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/GetRestoreCredentialOptionJavaTest.java b/credentials/credentials/src/androidTest/java/androidx/credentials/GetRestoreCredentialOptionJavaTest.java
new file mode 100644
index 0000000..9b6d76f
--- /dev/null
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/GetRestoreCredentialOptionJavaTest.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.credentials;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class GetRestoreCredentialOptionJavaTest {
+ private static final String TEST_REQUEST_JSON = "{\"hi\":{\"there\":{\"lol\":\"Value\"}}}";
+ private static final int EXPECTED_PASSKEY_PRIORITY =
+ CredentialOption.PRIORITY_PASSKEY_OR_SIMILAR;
+
+
+ @Test
+ public void constructor_emptyJson_throwsIllegalArgumentException() {
+ assertThrows("Expected empty Json to throw error",
+ IllegalArgumentException.class,
+ () -> new GetRestoreCredentialOption("")
+ );
+ }
+
+ @Test
+ public void constructor_nullJson_throwsNullPointerException() {
+ assertThrows("Expected null Json to throw NPE",
+ NullPointerException.class,
+ () -> new GetRestoreCredentialOption(null)
+ );
+ }
+
+ @Test
+ public void constructor_success() {
+ new GetRestoreCredentialOption(TEST_REQUEST_JSON);
+ }
+
+ @Test
+ public void getter_requestJson_success() {
+ String testJsonExpected = "{\"hi\":{\"there\":{\"lol\":\"Value\"}}}";
+ GetRestoreCredentialOption getRestoreCredentialOption =
+ new GetRestoreCredentialOption(testJsonExpected);
+ String testJsonActual = getRestoreCredentialOption.getRequestJson();
+ assertThat(testJsonActual).isEqualTo(testJsonExpected);
+ }
+}
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/GetRestoreCredentialOptionTest.kt b/credentials/credentials/src/androidTest/java/androidx/credentials/GetRestoreCredentialOptionTest.kt
new file mode 100644
index 0000000..626b125
--- /dev/null
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/GetRestoreCredentialOptionTest.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.credentials
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class GetRestoreCredentialOptionTest {
+
+ @Test
+ fun constructor_emptyJson_throwsIllegalArgumentException() {
+ Assert.assertThrows(
+ "Expected empty Json to throw error",
+ IllegalArgumentException::class.java
+ ) {
+ GetRestoreCredentialOption("")
+ }
+ }
+
+ @Test
+ fun constructor_success() {
+ GetRestoreCredentialOption(TEST_REQUEST_JSON)
+ }
+
+ @Test
+ fun getter_requestJson_success() {
+ val testJsonExpected = "{\"hi\":{\"there\":{\"lol\":\"Value\"}}}"
+ val getRestoreCredentialOption = GetRestoreCredentialOption(testJsonExpected)
+ val testJsonActual = getRestoreCredentialOption.requestJson
+ assertThat(testJsonActual).isEqualTo(testJsonExpected)
+ }
+
+ companion object Constant {
+ private const val TEST_REQUEST_JSON = "{\"hi\":{\"there\":{\"lol\":\"Value\"}}}"
+ }
+}
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/RestoreCredentialJavaTest.java b/credentials/credentials/src/androidTest/java/androidx/credentials/RestoreCredentialJavaTest.java
new file mode 100644
index 0000000..b7e7ae9
--- /dev/null
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/RestoreCredentialJavaTest.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.credentials;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.os.Bundle;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class RestoreCredentialJavaTest {
+ private static final String TEST_JSON = "{\"hi\":{\"there\":{\"lol\":\"Value\"}}}";
+
+ @Test
+ public void typeConstant() {
+ assertThat(RestoreCredential.TYPE_RESTORE_CREDENTIAL)
+ .isEqualTo("androidx.credentials.TYPE_RESTORE_CREDENTIAL");
+ }
+
+ @Test
+ public void constructor_emptyJson_throwsIllegalArgumentException() {
+ assertThrows("Expected empty Json to throw IllegalArgumentException",
+ IllegalArgumentException.class,
+ () -> {
+ Bundle bundle = new Bundle();
+ bundle.putString(
+ "androidx.credentials.BUNDLE_KEY_GET_RESTORE_CREDENTIAL_RESPONSE",
+ ""
+ );
+ Credential.createFrom(RestoreCredential.TYPE_RESTORE_CREDENTIAL, bundle);
+ }
+ );
+ }
+
+ @Test
+ public void constructor_success() {
+ Bundle bundle = new Bundle();
+ bundle.putString(
+ "androidx.credentials.BUNDLE_KEY_GET_RESTORE_CREDENTIAL_RESPONSE",
+ TEST_JSON
+ );
+ Credential.createFrom(RestoreCredential.TYPE_RESTORE_CREDENTIAL, bundle);
+ }
+
+ @Test
+ public void getter_authJson_success() {
+ String testJsonExpected = "{\"hi\":{\"there\":{\"lol\":\"Value\"}}}";
+ Bundle bundle = new Bundle();
+ bundle.putString(
+ "androidx.credentials.BUNDLE_KEY_GET_RESTORE_CREDENTIAL_RESPONSE",
+ testJsonExpected
+ );
+ RestoreCredential credential = (RestoreCredential) Credential
+ .createFrom(RestoreCredential.TYPE_RESTORE_CREDENTIAL, bundle);
+ String testJsonActual = credential.getAuthenticationResponseJson();
+ assertThat(testJsonActual).isEqualTo(testJsonExpected);
+ }
+}
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/RestoreCredentialTest.kt b/credentials/credentials/src/androidTest/java/androidx/credentials/RestoreCredentialTest.kt
new file mode 100644
index 0000000..b1fb4dd
--- /dev/null
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/RestoreCredentialTest.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.credentials
+
+import android.os.Bundle
+import androidx.credentials.Credential.Companion.createFrom
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class RestoreCredentialTest {
+
+ companion object Constant {
+ private const val TEST_JSON = "{\"hi\":{\"there\":{\"lol\":\"Value\"}}}"
+ }
+
+ @Test
+ fun typeConstant() {
+ assertThat(RestoreCredential.TYPE_RESTORE_CREDENTIAL)
+ .isEqualTo("androidx.credentials.TYPE_RESTORE_CREDENTIAL")
+ }
+
+ @Test
+ fun createFrom_emptyJson_throwsIllegalArgumentException() {
+ Assert.assertThrows(
+ "Expected empty Json to throw IllegalArgumentException",
+ IllegalArgumentException::class.java
+ ) {
+ val bundle = Bundle()
+ bundle.putString("androidx.credentials.BUNDLE_KEY_GET_RESTORE_CREDENTIAL_RESPONSE", "")
+ RestoreCredential.createFrom(bundle)
+ }
+ }
+
+ @Test
+ fun createFrom_invalidJson_throwsIllegalArgumentException() {
+ Assert.assertThrows(
+ "Expected invalid Json to throw IllegalArgumentException",
+ IllegalArgumentException::class.java
+ ) {
+ val bundle = Bundle()
+ bundle.putString(
+ "androidx.credentials.BUNDLE_KEY_GET_RESTORE_CREDENTIAL_RESPONSE",
+ "invalid"
+ )
+ RestoreCredential.createFrom(bundle)
+ }
+ }
+
+ @Test
+ fun createFrom_success() {
+ val bundle = Bundle()
+ bundle.putString(
+ "androidx.credentials.BUNDLE_KEY_GET_RESTORE_CREDENTIAL_RESPONSE",
+ TEST_JSON
+ )
+ RestoreCredential.createFrom(bundle)
+ }
+
+ @Test
+ fun getter_authJson_success() {
+ val testJsonExpected = "{\"hi\":{\"there\":{\"lol\":\"Value\"}}}"
+ val bundle = Bundle()
+ bundle.putString(
+ "androidx.credentials.BUNDLE_KEY_GET_RESTORE_CREDENTIAL_RESPONSE",
+ testJsonExpected
+ )
+ val restoreCredential = RestoreCredential.createFrom(bundle)
+ val testJsonActual = restoreCredential.authenticationResponseJson
+ assertThat(testJsonActual).isEqualTo(testJsonExpected)
+ }
+}
diff --git a/credentials/credentials/src/main/java/androidx/credentials/CreateRestoreCredentialResponse.kt b/credentials/credentials/src/main/java/androidx/credentials/CreateRestoreCredentialResponse.kt
index a2ed952..8d26574 100644
--- a/credentials/credentials/src/main/java/androidx/credentials/CreateRestoreCredentialResponse.kt
+++ b/credentials/credentials/src/main/java/androidx/credentials/CreateRestoreCredentialResponse.kt
@@ -19,6 +19,7 @@
import android.os.Bundle
import androidx.annotation.RestrictTo
import androidx.credentials.exceptions.CreateCredentialUnknownException
+import androidx.credentials.internal.RequestValidationHelper
/**
* A response of the [RestoreCredential] flow.
@@ -32,9 +33,19 @@
data: Bundle,
) : CreateCredentialResponse(RestoreCredential.TYPE_RESTORE_CREDENTIAL, data) {
- /** Constructs a [CreateRestoreCredentialResponse]. */
+ /**
+ * Constructs a [CreateRestoreCredentialResponse].
+ *
+ * @throws IllegalArgumentException If [responseJson] is empty, or an invalid JSON
+ */
constructor(responseJson: String) : this(responseJson, toBundle(responseJson))
+ init {
+ require(RequestValidationHelper.isValidJSON(responseJson)) {
+ "registrationResponseJson must not be empty, and must be a valid JSON"
+ }
+ }
+
companion object {
const val BUNDLE_KEY_CREATE_RESTORE_CREDENTIAL_RESPONSE =
"androidx.credentials.BUNDLE_KEY_CREATE_RESTORE_CREDENTIAL_RESPONSE"
diff --git a/credentials/credentials/src/main/java/androidx/credentials/CredentialProviderFactory.kt b/credentials/credentials/src/main/java/androidx/credentials/CredentialProviderFactory.kt
index bf885cb..bad4fbf 100644
--- a/credentials/credentials/src/main/java/androidx/credentials/CredentialProviderFactory.kt
+++ b/credentials/credentials/src/main/java/androidx/credentials/CredentialProviderFactory.kt
@@ -77,12 +77,6 @@
} else if (request is GetCredentialRequest) {
for (option in request.credentialOptions) {
if (option is GetRestoreCredentialOption || option is GetDigitalCredentialOption) {
- if (request.credentialOptions.any { it !is GetDigitalCredentialOption }) {
- throw IllegalArgumentException(
- "`GetDigitalCredentialOption` cannot be" +
- " combined with other option types in a single request"
- )
- }
return tryCreatePreUOemProvider()
}
}
diff --git a/credentials/credentials/src/main/java/androidx/credentials/GetCredentialRequest.kt b/credentials/credentials/src/main/java/androidx/credentials/GetCredentialRequest.kt
index ef20a75..0c0133a 100644
--- a/credentials/credentials/src/main/java/androidx/credentials/GetCredentialRequest.kt
+++ b/credentials/credentials/src/main/java/androidx/credentials/GetCredentialRequest.kt
@@ -62,6 +62,7 @@
* [GetRestoreCredentialOption] with another option (i.e. [GetPasswordOption] or
* [GetPublicKeyCredentialOption]).
*/
+@OptIn(ExperimentalDigitalCredentialApi::class)
class GetCredentialRequest
@JvmOverloads
constructor(
@@ -76,6 +77,16 @@
init {
require(credentialOptions.isNotEmpty()) { "credentialOptions should not be empty" }
if (credentialOptions.size > 1) {
+ val digitalCredentialOptionCount =
+ credentialOptions.count { it is GetDigitalCredentialOption }
+ if (
+ digitalCredentialOptionCount > 0 &&
+ digitalCredentialOptionCount != credentialOptions.size
+ ) {
+ throw IllegalArgumentException(
+ "Digital Credential Option cannot be used with other credential option."
+ )
+ }
for (option in credentialOptions) {
if (option is GetRestoreCredentialOption) {
throw IllegalArgumentException(
diff --git a/credentials/credentials/src/main/java/androidx/credentials/GetRestoreCredentialOption.kt b/credentials/credentials/src/main/java/androidx/credentials/GetRestoreCredentialOption.kt
index feabf25..bcf4a90 100644
--- a/credentials/credentials/src/main/java/androidx/credentials/GetRestoreCredentialOption.kt
+++ b/credentials/credentials/src/main/java/androidx/credentials/GetRestoreCredentialOption.kt
@@ -18,6 +18,7 @@
import android.os.Bundle
import androidx.credentials.exceptions.NoCredentialException
+import androidx.credentials.internal.RequestValidationHelper
/**
* A request to get the restore credential from the restore credential provider.
@@ -47,6 +48,13 @@
allowedProviders = emptySet(),
typePriorityHint = PRIORITY_DEFAULT,
) {
+
+ init {
+ require(RequestValidationHelper.isValidJSON(requestJson)) {
+ "requestJson must not be empty, and must be a valid JSON"
+ }
+ }
+
private companion object {
private const val BUNDLE_KEY_GET_RESTORE_CREDENTIAL_REQUEST =
"androidx.credentials.BUNDLE_KEY_GET_RESTORE_CREDENTIAL_REQUEST"
diff --git a/libraryversions.toml b/libraryversions.toml
index 8a6f498..a098b34 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -162,10 +162,10 @@
WEAR_INPUT_TESTING = "1.2.0-alpha03"
WEAR_ONGOING = "1.1.0-alpha02"
WEAR_PHONE_INTERACTIONS = "1.1.0-alpha04"
-WEAR_PROTOLAYOUT = "1.3.0-alpha01"
+WEAR_PROTOLAYOUT = "1.3.0-alpha02"
WEAR_PROTOLAYOUT_MATERIAL3 = "1.0.0-alpha01"
WEAR_REMOTE_INTERACTIONS = "1.1.0-rc01"
-WEAR_TILES = "1.5.0-alpha01"
+WEAR_TILES = "1.5.0-alpha02"
WEAR_TOOLING_PREVIEW = "1.0.0-rc01"
WEAR_WATCHFACE = "1.3.0-alpha04"
WEBKIT = "1.13.0-alpha01"
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/build.gradle b/lifecycle/lifecycle-viewmodel-savedstate/build.gradle
index b50d0a2..d98f5f0 100644
--- a/lifecycle/lifecycle-viewmodel-savedstate/build.gradle
+++ b/lifecycle/lifecycle-viewmodel-savedstate/build.gradle
@@ -178,4 +178,5 @@
inceptionYear = "2018"
description = "Android Lifecycle ViewModel"
legacyDisableKotlinStrictApiMode = true
+ metalavaK2UastEnabled = false // TODO(b/324624680)
}
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
index 63d2579..c09b58d 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
@@ -402,6 +402,26 @@
.onUpdateMemberRoutes(Collections.singletonList(route.getDescriptorId()));
}
+ /**
+ * Selects the given {@link MediaRouter.RouteInfo route}.
+ *
+ * <p>This method does two things:
+ *
+ * <ul>
+ * <li>Updates the currently selected route ({@link #mSelectedRoute}).
+ * <li>Notifies the {@link MediaRouteProvider} that published the selected route of said
+ * selection. An exception to this is when {@code syncMediaRoute1Provider} is false, and
+ * the provider of the route is {@link PlatformMediaRouter1RouteProvider}. This is to
+ * prevent calling {@link android.media.MediaRouter#selectRoute} as a result of a {@link
+ * android.media.MediaRouter} callback. See b/294968421#comment59 for details.
+ * </ul>
+ *
+ * @param route The {@link MediaRouter.RouteInfo} to select.
+ * @param unselectReason The reason associated with the route selection.
+ * @param syncMediaRoute1Provider Whether this selection should be passed through to {@link
+ * PlatformMediaRouter1RouteProvider}. Must only be true when called as a result of an
+ * explicit application route selection.
+ */
/* package */ void selectRoute(
@NonNull MediaRouter.RouteInfo route,
@MediaRouter.UnselectReason int unselectReason,
@@ -1004,9 +1024,10 @@
mSelectedRoute != null
? String.format(
Locale.US,
- "%s(BT=%b)",
+ "%s(BT=%b, syncMediaRoute1Provider=%b)",
mSelectedRoute.getName(),
- mSelectedRoute.isBluetooth())
+ mSelectedRoute.isBluetooth(),
+ syncMediaRoute1Provider)
: null;
Log.w(
TAG,
@@ -1217,7 +1238,7 @@
if (provider != null) {
MediaRouter.RouteInfo route = provider.findRouteByDescriptorId(id);
if (route != null) {
- route.select();
+ route.select(/* syncMediaRoute1Provider= */ false);
}
}
}
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouter.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouter.java
index 9765791..f4b39f0 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouter.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouter.java
@@ -1976,13 +1976,7 @@
select(/* syncMediaRoute1Provider= */ true);
}
- /**
- * Selects this media route.
- *
- * @param syncMediaRoute1Provider Whether this selection should be passed through to {@link
- * PlatformMediaRouter1RouteProvider}. Should be false when this call is the result of a
- * {@link MediaRouter.Callback#onRouteSelected} call.
- */
+ /** See {@link GlobalMediaRouter#selectRoute}. */
@RestrictTo(LIBRARY)
@MainThread
public void select(boolean syncMediaRoute1Provider) {
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/PlatformMediaRouter1RouteProvider.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/PlatformMediaRouter1RouteProvider.java
index 322240f..89d4178 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/PlatformMediaRouter1RouteProvider.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/PlatformMediaRouter1RouteProvider.java
@@ -382,7 +382,7 @@
if (index >= 0) {
SystemRouteRecord record = mSystemRouteRecords.get(index);
if (record.mRouteDescriptorId.equals(route.getDescriptorId())) {
- route.select();
+ route.select(/* syncMediaRoute1Provider= */ false);
}
}
}
diff --git a/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/MockPdfViewerFragment.kt b/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/MockPdfViewerFragment.kt
deleted file mode 100644
index 89a299d..0000000
--- a/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/MockPdfViewerFragment.kt
+++ /dev/null
@@ -1,43 +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.pdf
-
-import androidx.annotation.RestrictTo
-import androidx.pdf.idlingresource.PdfIdlingResource
-import androidx.pdf.viewer.fragment.PdfViewerFragment
-
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-internal class MockPdfViewerFragment : PdfViewerFragment() {
- val pdfLoadingIdlingResource = PdfIdlingResource(PDF_LOAD_RESOURCE_NAME)
-
- var documentLoaded = false
- var documentError: Throwable? = null
-
- override fun onLoadDocumentSuccess() {
- documentLoaded = true
- pdfLoadingIdlingResource.decrement()
- }
-
- override fun onLoadDocumentError(error: Throwable) {
- documentError = error
- pdfLoadingIdlingResource.decrement()
- }
-
- companion object {
- private const val PDF_LOAD_RESOURCE_NAME = "PdfLoad"
- }
-}
diff --git a/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt b/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt
index 1457b00..324d0cf 100644
--- a/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt
+++ b/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt
@@ -52,12 +52,12 @@
@RunWith(AndroidJUnit4::class)
class PdfViewerFragmentTestSuite {
- private lateinit var scenario: FragmentScenario<MockPdfViewerFragment>
+ private lateinit var scenario: FragmentScenario<TestPdfViewerFragment>
@Before
fun setup() {
scenario =
- launchFragmentInContainer<MockPdfViewerFragment>(
+ launchFragmentInContainer<TestPdfViewerFragment>(
themeResId =
com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar,
initialState = Lifecycle.State.INITIALIZED
@@ -83,7 +83,7 @@
filename: String,
nextState: Lifecycle.State,
orientation: Int
- ): FragmentScenario<MockPdfViewerFragment> {
+ ): FragmentScenario<TestPdfViewerFragment> {
val context = InstrumentationRegistry.getInstrumentation().context
val inputStream = context.assets.open(filename)
@@ -179,6 +179,62 @@
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
}
+ /**
+ * This test verifies the behavior of the Pdf viewer in immersive mode, specifically the
+ * visibility of the toolbox and the host app's search button.
+ */
+ @Test
+ fun testPdfViewerFragment_immersiveMode_toggleMenu() {
+ // Load a PDF document into the fragment
+ val scenario =
+ scenarioLoadDocument(
+ TEST_DOCUMENT_FILE,
+ Lifecycle.State.STARTED,
+ ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ )
+
+ // Check that the document is loaded successfully
+ onView(withId(R.id.loadingView))
+ .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
+ scenario.onFragment {
+ Preconditions.checkArgument(
+ it.documentLoaded,
+ "Unable to load document due to ${it.documentError?.message}"
+ )
+ }
+
+ // Show the toolbox and check visibility of buttons
+ scenario.onFragment { it.isToolboxVisible = true }
+ onView(withId(R.id.edit_fab)).check(matches(isDisplayed()))
+ onView(withId(androidx.pdf.testapp.R.id.host_Search)).check(matches(isDisplayed()))
+
+ // Hide the toolbox and check visibility of buttons
+ scenario.onFragment { it.isToolboxVisible = false }
+ onView(withId(R.id.edit_fab))
+ .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
+ onView(withId(androidx.pdf.testapp.R.id.host_Search)).check(matches(isDisplayed()))
+
+ // Enter immersive mode and check visibility of buttons
+ scenario.onFragment { it.onRequestImmersiveMode(true) }
+ onView(withId(R.id.edit_fab))
+ .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
+ onView(withId(androidx.pdf.testapp.R.id.host_Search))
+ .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
+
+ // Exit immersive mode and check visibility of buttons
+ scenario.onFragment { it.onRequestImmersiveMode(false) }
+ onView(withId(R.id.edit_fab)).check(matches(isDisplayed()))
+ onView(withId(androidx.pdf.testapp.R.id.host_Search)).check(matches(isDisplayed()))
+
+ // Click the host app search button and check visibility of elements
+ onView(withId(androidx.pdf.testapp.R.id.host_Search)).perform(click())
+ onView(withId(R.id.search_container)).check(matches(isDisplayed()))
+ onView(withId(R.id.edit_fab))
+ .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
+ onView(withId(androidx.pdf.testapp.R.id.host_Search))
+ .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
+ }
+
fun testPdfViewerFragment_setDocumentUri_passwordProtected_portrait() {
val scenario =
scenarioLoadDocument(
diff --git a/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/TestPdfViewerFragment.kt b/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/TestPdfViewerFragment.kt
new file mode 100644
index 0000000..1685383
--- /dev/null
+++ b/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/TestPdfViewerFragment.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.pdf
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.annotation.RestrictTo
+import androidx.pdf.idlingresource.PdfIdlingResource
+import androidx.pdf.testapp.R
+import androidx.pdf.viewer.fragment.PdfViewerFragment
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+internal class TestPdfViewerFragment : PdfViewerFragment() {
+ private var hostView: FrameLayout? = null
+ private var search: FloatingActionButton? = null
+ val pdfLoadingIdlingResource = PdfIdlingResource(PDF_LOAD_RESOURCE_NAME)
+
+ var documentLoaded = false
+ var documentError: Throwable? = null
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ val view = super.onCreateView(inflater, container, savedInstanceState) as FrameLayout
+
+ // Inflate the custom layout for this fragment
+ hostView = inflater.inflate(R.layout.fragment_host, container, false) as FrameLayout
+ search = hostView?.findViewById(R.id.host_Search)
+
+ // Add the default PDF viewer to the custom layout
+ hostView?.addView(view)
+
+ // Show/hide the search button based on initial toolbox visibility
+ if (isToolboxVisible) search?.show() else search?.hide()
+
+ // Set up search button click listener
+ search?.setOnClickListener { isTextSearchActive = true }
+ return hostView
+ }
+
+ override fun onRequestImmersiveMode(enterImmersive: Boolean) {
+ super.onRequestImmersiveMode(enterImmersive)
+ if (!enterImmersive) search?.show() else search?.hide()
+ }
+
+ override fun onLoadDocumentSuccess() {
+ documentLoaded = true
+ pdfLoadingIdlingResource.decrement()
+ }
+
+ override fun onLoadDocumentError(error: Throwable) {
+ documentError = error
+ pdfLoadingIdlingResource.decrement()
+ }
+
+ companion object {
+ private const val PDF_LOAD_RESOURCE_NAME = "PdfLoad"
+ }
+}
diff --git a/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/HostFragment.kt b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/HostFragment.kt
new file mode 100644
index 0000000..ead82cf2
--- /dev/null
+++ b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/HostFragment.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.pdf.testapp
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.pdf.viewer.fragment.PdfViewerFragment
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+
+/**
+ * This fragment extends PdfViewerFragment to provide a custom layout and handle immersive mode. It
+ * adds a FloatingActionButton for search functionality and manages its visibility based on the
+ * immersive mode state.
+ */
+class HostFragment : PdfViewerFragment() {
+ private var hostView: FrameLayout? = null
+ private var search: FloatingActionButton? = null
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ val view = super.onCreateView(inflater, container, savedInstanceState) as FrameLayout
+
+ // Inflate the custom layout for this fragment.
+ hostView = inflater.inflate(R.layout.fragment_host, container, false) as FrameLayout
+ search = hostView?.findViewById(R.id.host_Search)
+
+ // Add the default PDF viewer to the custom layout
+ hostView?.addView(view)
+
+ // Show/hide the search button based on initial toolbox visibility
+ if (isToolboxVisible) search?.show() else search?.hide()
+
+ // Set up search button click listener
+ search?.setOnClickListener { isTextSearchActive = true }
+ return hostView
+ }
+
+ override fun onRequestImmersiveMode(enterImmersive: Boolean) {
+ super.onRequestImmersiveMode(enterImmersive)
+ if (!enterImmersive) search?.show() else search?.hide()
+ }
+}
diff --git a/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/MainActivity.kt b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/MainActivity.kt
index becbc76..25f23c3 100644
--- a/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/MainActivity.kt
+++ b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/MainActivity.kt
@@ -29,14 +29,13 @@
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
-import androidx.pdf.viewer.fragment.PdfViewerFragment
import com.google.android.material.button.MaterialButton
@SuppressLint("RestrictedApiAndroidX")
@RestrictTo(RestrictTo.Scope.LIBRARY)
class MainActivity : AppCompatActivity() {
- private var pdfViewerFragment: PdfViewerFragment? = null
+ private var pdfViewerFragment: HostFragment? = null
private var isPdfViewInitialized = false
@VisibleForTesting
@@ -57,8 +56,7 @@
if (pdfViewerFragment == null) {
pdfViewerFragment =
- supportFragmentManager.findFragmentByTag(PDF_VIEWER_FRAGMENT_TAG)
- as PdfViewerFragment?
+ supportFragmentManager.findFragmentByTag(PDF_VIEWER_FRAGMENT_TAG) as HostFragment?
}
val getContentButton: MaterialButton = findViewById(R.id.launch_button)
@@ -74,7 +72,7 @@
val fragmentManager: FragmentManager = supportFragmentManager
// Fragment initialization
- pdfViewerFragment = PdfViewerFragment()
+ pdfViewerFragment = HostFragment()
val transaction: FragmentTransaction = fragmentManager.beginTransaction()
// Replace an existing fragment in a container with an instance of a new fragment
diff --git a/pdf/integration-tests/testapp/src/main/res/drawable/ic_action_search.xml b/pdf/integration-tests/testapp/src/main/res/drawable/ic_action_search.xml
new file mode 100644
index 0000000..876b264
--- /dev/null
+++ b/pdf/integration-tests/testapp/src/main/res/drawable/ic_action_search.xml
@@ -0,0 +1,11 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:autoMirrored="true"
+ android:tint="?attr/colorOnSecondaryContainer"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:pathData="M20.49,19l-5.73,-5.73C15.53,12.2 16,10.91 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.41,0 2.7,-0.47 3.77,-1.24L19,20.49 20.49,19zM5,9.5C5,7.01 7.01,5 9.5,5S14,7.01 14,9.5 11.99,14 9.5,14 5,11.99 5,9.5z"
+ android:fillColor="?attr/colorOnSecondaryContainer"/>
+</vector>
\ No newline at end of file
diff --git a/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml b/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml
new file mode 100644
index 0000000..7b7ffd0
--- /dev/null
+++ b/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright 2024 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ -->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ tools:context=".HostFragment">
+
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/host_Search"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="80dp"
+ android:layout_marginEnd="16dp"
+ android:contentDescription="@string/action_edit"
+ android:layout_gravity="bottom|end"
+ android:visibility="gone"
+ android:src="@drawable/ic_action_search"
+ app:backgroundTint="?attr/colorSecondaryContainer"
+ app:tint="?attr/colorOnSecondaryContainer" />
+
+</FrameLayout>
\ No newline at end of file
diff --git a/pdf/pdf-viewer-fragment/api/current.txt b/pdf/pdf-viewer-fragment/api/current.txt
index 53ee770..752ae07 100644
--- a/pdf/pdf-viewer-fragment/api/current.txt
+++ b/pdf/pdf-viewer-fragment/api/current.txt
@@ -5,12 +5,16 @@
ctor public PdfViewerFragment();
method public final android.net.Uri? getDocumentUri();
method public final boolean isTextSearchActive();
+ method public final boolean isToolboxVisible();
method public void onLoadDocumentError(Throwable error);
method public void onLoadDocumentSuccess();
+ method public void onRequestImmersiveMode(boolean enterImmersive);
method public final void setDocumentUri(android.net.Uri?);
method public final void setTextSearchActive(boolean);
+ method public final void setToolboxVisible(boolean);
property public final android.net.Uri? documentUri;
property public final boolean isTextSearchActive;
+ property public final boolean isToolboxVisible;
}
}
diff --git a/pdf/pdf-viewer-fragment/api/restricted_current.txt b/pdf/pdf-viewer-fragment/api/restricted_current.txt
index 53ee770..752ae07 100644
--- a/pdf/pdf-viewer-fragment/api/restricted_current.txt
+++ b/pdf/pdf-viewer-fragment/api/restricted_current.txt
@@ -5,12 +5,16 @@
ctor public PdfViewerFragment();
method public final android.net.Uri? getDocumentUri();
method public final boolean isTextSearchActive();
+ method public final boolean isToolboxVisible();
method public void onLoadDocumentError(Throwable error);
method public void onLoadDocumentSuccess();
+ method public void onRequestImmersiveMode(boolean enterImmersive);
method public final void setDocumentUri(android.net.Uri?);
method public final void setTextSearchActive(boolean);
+ method public final void setToolboxVisible(boolean);
property public final android.net.Uri? documentUri;
property public final boolean isTextSearchActive;
+ property public final boolean isToolboxVisible;
}
}
diff --git a/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt b/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt
index ac0ae4c..9fabf61 100644
--- a/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt
+++ b/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt
@@ -52,6 +52,7 @@
import androidx.pdf.util.Observables.ExposedValue
import androidx.pdf.util.Preconditions
import androidx.pdf.util.Uris
+import androidx.pdf.viewer.ImmersiveModeRequester
import androidx.pdf.viewer.LayoutHandler
import androidx.pdf.viewer.LoadingView
import androidx.pdf.viewer.PageSelectionValueObserver
@@ -165,6 +166,13 @@
private var mEventCallback: EventCallback? = null
+ private val mImmersiveModeRequester: ImmersiveModeRequester =
+ object : ImmersiveModeRequester {
+ override fun requestImmersiveModeChange(enterImmersive: Boolean) {
+ onRequestImmersiveMode(enterImmersive)
+ }
+ }
+
/**
* The URI of the PDF document to display defaulting to `null`.
*
@@ -227,6 +235,34 @@
}
/**
+ * Indicates whether the toolbox should be visible.
+ *
+ * The host app can control this property to show/hide the toolbox based on its state and the
+ * `onRequestImmersiveMode` callback. The setter updates the UI elements within the fragment
+ * accordingly.
+ */
+ public var isToolboxVisible: Boolean
+ get() = arguments?.getBoolean(KEY_TOOLBOX_VISIBILITY) ?: true
+ set(value) {
+ (arguments ?: Bundle()).apply { putBoolean(KEY_TOOLBOX_VISIBILITY, value) }
+ if (value) annotationButton?.show() else annotationButton?.hide()
+ }
+
+ /**
+ * Called when the PDF view wants to enter or exit immersive mode based on user's interaction
+ * with the content. Apps would typically hide their top bar or other navigational interface
+ * when in immersive mode. The default implementation keeps toolbox visibility in sync with the
+ * enterImmersive mode. It is recommended that apps keep this behaviour by calling
+ * super.onRequestImmersiveMode while overriding this method.
+ *
+ * @param enterImmersive true to enter immersive mode, false to exit.
+ */
+ public open fun onRequestImmersiveMode(enterImmersive: Boolean) {
+ // Update toolbox visibility
+ isToolboxVisible = !enterImmersive
+ }
+
+ /**
* Invoked when the document has been fully loaded, processed, and the initial pages are
* displayed within the viewing area. This callback signifies that the document is ready for
* user interaction.
@@ -262,6 +298,11 @@
): View? {
super.onCreateView(inflater, container, savedInstanceState)
this.container = container
+ if (!hasContents && delayedContentsAvailable == null) {
+ if (savedInstanceState != null) {
+ restoreContents(savedInstanceState)
+ }
+ }
pdfViewer = inflater.inflate(R.layout.pdf_viewer_container, container, false) as FrameLayout
pdfViewer?.isScrollContainer = true
@@ -308,9 +349,7 @@
// and no means to load it. The best way is to just kill the service which
// will restart on the next onStart.
pdfLoader?.disconnect()
- return@PdfLoaderCallbacksImpl true
}
- return@PdfLoaderCallbacksImpl false
},
onDocumentLoaded = {
documentLoaded = true
@@ -318,9 +357,9 @@
if (shouldRedrawOnDocumentLoaded) {
shouldRedrawOnDocumentLoaded = false
}
- annotationButton?.let { button ->
+ annotationButton?.let {
if ((savedInstanceState == null) && isAnnotationIntentResolvable) {
- button.show()
+ onRequestImmersiveMode(false)
}
}
},
@@ -351,12 +390,6 @@
}
}
- if (!hasContents && delayedContentsAvailable == null) {
- if (savedInstanceState != null) {
- restoreContents(savedInstanceState)
- }
- }
-
return pdfViewer
}
@@ -405,6 +438,7 @@
if (onScreen) {
onExit()
}
+ onScreen = false
started = false
super.onStop()
}
@@ -541,7 +575,7 @@
isAnnotationIntentResolvable &&
state.getBoolean(KEY_ANNOTATION_BUTTON_VISIBILITY)
) {
- annotationButton?.show()
+ onRequestImmersiveMode(false)
}
}
}
@@ -613,7 +647,8 @@
zoomView!!,
selectionModel,
paginationModel!!,
- layoutHandler!!
+ layoutHandler!!,
+ mImmersiveModeRequester
)
singleTapHandler!!.setAnnotationIntentResolvable(isAnnotationIntentResolvable)
@@ -655,7 +690,8 @@
findInFileView!!,
isAnnotationIntentResolvable,
selectionActionMode!!,
- viewState
+ viewState,
+ mImmersiveModeRequester
)
zoomView?.zoomScroll()?.addObserver(zoomScrollObserver)
@@ -669,12 +705,12 @@
)
findInFileView!!.searchModel.selectedMatch().addObserver(selectedMatchObserver)
- annotationButton?.let { findInFileView!!.setAnnotationButton(it) }
+ annotationButton?.let { findInFileView!!.setAnnotationButton(it, mImmersiveModeRequester) }
fastScrollView?.setOnFastScrollActiveListener {
annotationButton?.let { button ->
if (button.visibility == View.VISIBLE) {
- button.hide()
+ onRequestImmersiveMode(true)
}
}
}
@@ -690,7 +726,6 @@
fileData?.let {
localUri = it.uri
postContentsAvailable(it)
- postEnter()
}
} catch (e: Exception) {
// This can happen if the data is an instance of StreamOpenable, and the client
@@ -709,14 +744,14 @@
}
setAnnotationIntentResolvability()
if (!isAnnotationIntentResolvable && annotationButton?.visibility == View.VISIBLE) {
- annotationButton?.post { annotationButton?.hide() }
+ annotationButton?.post { onRequestImmersiveMode(true) }
}
if (
isAnnotationIntentResolvable &&
annotationButton?.visibility != View.VISIBLE &&
findInFileView?.visibility != View.VISIBLE
) {
- annotationButton?.post { annotationButton?.show() }
+ annotationButton?.post { onRequestImmersiveMode(false) }
}
}
@@ -738,7 +773,6 @@
pdfLoaderCallbacks?.searchModel = null
- pdfLoader?.disconnect()
pdfLoader = null
documentLoaded = false
}
@@ -850,7 +884,7 @@
}
}
if (localUri != null && localUri != fileUri) {
- annotationButton?.hide()
+ onRequestImmersiveMode(true)
}
localUri = fileUri
}
@@ -944,5 +978,6 @@
private const val KEY_DOCUMENT_URI: String = "documentUri"
private const val KEY_ANNOTATION_BUTTON_VISIBILITY = "isAnnotationVisible"
private const val KEY_PENDING_DOCUMENT_LOAD = "pendingDocumentLoad"
+ private const val KEY_TOOLBOX_VISIBILITY = "isToolboxVisible"
}
}
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/find/FindInFileView.java b/pdf/pdf-viewer/src/main/java/androidx/pdf/find/FindInFileView.java
index cddac28..05d927c 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/find/FindInFileView.java
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/find/FindInFileView.java
@@ -45,6 +45,7 @@
import androidx.pdf.util.CycleRange;
import androidx.pdf.util.ObservableValue;
import androidx.pdf.util.ObservableValue.ValueObserver;
+import androidx.pdf.viewer.ImmersiveModeRequester;
import androidx.pdf.viewer.PaginatedView;
import androidx.pdf.viewer.SearchModel;
import androidx.pdf.viewer.SelectedMatch;
@@ -75,6 +76,7 @@
private View mCloseButton;
private FloatingActionButton mAnnotationButton;
private PaginatedView mPaginatedView;
+ private ImmersiveModeRequester mImmersiveModeRequester;
private FindInFileListener mFindInFileListener;
private Runnable mOnClosedButtonCallback;
@@ -246,8 +248,10 @@
}
public void setAnnotationButton(
- @NonNull FloatingActionButton annotationButton) {
+ @NonNull FloatingActionButton annotationButton,
+ @NonNull ImmersiveModeRequester immersiveModeRequester) {
mAnnotationButton = annotationButton;
+ mImmersiveModeRequester = immersiveModeRequester;
}
public void setAnnotationIntentResolvable(
@@ -267,7 +271,7 @@
if (visibility) {
this.setVisibility(VISIBLE);
if (mAnnotationButton != null && mAnnotationButton.getVisibility() == VISIBLE) {
- mAnnotationButton.hide();
+ mImmersiveModeRequester.requestImmersiveModeChange(true);
}
setupFindInFileBtn();
WindowCompat.getInsetsController(((Activity) getContext()).getWindow(), this)
@@ -306,7 +310,7 @@
mCloseButton.setOnClickListener(view -> {
resetFindInFile();
if (mIsAnnotationIntentResolvable) {
- mAnnotationButton.show();
+ mImmersiveModeRequester.requestImmersiveModeChange(false);
}
});
}
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/ImmersiveModeRequester.kt b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/ImmersiveModeRequester.kt
new file mode 100644
index 0000000..f2f21e8
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/ImmersiveModeRequester.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.pdf.viewer
+
+import androidx.annotation.RestrictTo
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public interface ImmersiveModeRequester {
+ public fun requestImmersiveModeChange(enterImmersive: Boolean)
+}
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginatedView.java b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginatedView.java
index 027471d..95c6e4d 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginatedView.java
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginatedView.java
@@ -556,9 +556,9 @@
static class SavedState extends View.BaseSavedState {
final PaginationModel mModel;
- SavedState(Parcelable superState, PaginationModel model) {
- super(superState);
- mModel = model;
+ SavedState(Parcel source) {
+ super(source);
+ mModel = ParcelCompat.readParcelable(source, null, PaginationModel.class);
}
SavedState(Parcel source, ClassLoader loader) {
@@ -566,10 +566,34 @@
mModel = ParcelCompat.readParcelable(source, loader, PaginationModel.class);
}
+ SavedState(Parcelable superState, PaginationModel model) {
+ super(superState);
+ mModel = model;
+ }
+
@Override
public void writeToParcel(Parcel out, int flags) {
super.writeToParcel(out, flags);
out.writeParcelable(mModel, flags);
}
+
+ public static final ClassLoaderCreator<SavedState> CREATOR =
+ new ClassLoaderCreator<SavedState>() {
+
+ @Override
+ public SavedState createFromParcel(Parcel in) {
+ return new SavedState(in);
+ }
+
+ @Override
+ public SavedState createFromParcel(Parcel source, ClassLoader loader) {
+ return new SavedState(source, loader);
+ }
+
+ @Override
+ public SavedState[] newArray(int size) {
+ return new SavedState[size];
+ }
+ };
}
}
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PdfViewer.java b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PdfViewer.java
index bff9c8c..fac1129 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PdfViewer.java
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PdfViewer.java
@@ -182,6 +182,13 @@
private EventCallback mEventCallback;
+ private final ImmersiveModeRequester mImmersiveModeRequester = new ImmersiveModeRequester() {
+ @Override
+ public void requestImmersiveModeChange(boolean enterImmersive) {
+ //TODO: remove this class
+ }
+ };
+
public PdfViewer() {
super(SELF_MANAGED_CONTENTS);
}
@@ -284,7 +291,8 @@
mSearchModel.query().addObserver(mSearchQueryObserver);
mSingleTapHandler = new SingleTapHandler(getContext(), mAnnotationButton, mPaginatedView,
- mFindInFileView, mZoomView, mSelectionModel, mPaginationModel, mLayoutHandler);
+ mFindInFileView, mZoomView, mSelectionModel, mPaginationModel, mLayoutHandler,
+ mImmersiveModeRequester);
mPageViewFactory = new PageViewFactory(requireContext(), mPdfLoader,
mPaginatedView, mZoomView, mSingleTapHandler, mFindInFileView,
mEventCallback);
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/SingleTapHandler.java b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/SingleTapHandler.java
index 67cfb2d..2da4beb 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/SingleTapHandler.java
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/SingleTapHandler.java
@@ -44,6 +44,7 @@
private final PdfSelectionModel mPdfSelectionModel;
private final PaginationModel mPaginationModel;
private final LayoutHandler mLayoutHandler;
+ private final ImmersiveModeRequester mImmersiveModeRequester;
private boolean mIsAnnotationIntentResolvable;
public SingleTapHandler(@NonNull Context context,
@@ -53,7 +54,8 @@
@NonNull ZoomView zoomView,
@NonNull PdfSelectionModel pdfSelectionModel,
@NonNull PaginationModel paginationModel,
- @NonNull LayoutHandler layoutHandler) {
+ @NonNull LayoutHandler layoutHandler,
+ @NonNull ImmersiveModeRequester immersiveModeRequester) {
mContext = context;
mFloatingActionButton = floatingActionButton;
mPaginatedView = paginatedView;
@@ -62,6 +64,7 @@
mPdfSelectionModel = pdfSelectionModel;
mPaginationModel = paginationModel;
mLayoutHandler = layoutHandler;
+ mImmersiveModeRequester = immersiveModeRequester;
}
public void setAnnotationIntentResolvable(boolean annotationIntentResolvable) {
@@ -73,9 +76,9 @@
if (mIsAnnotationIntentResolvable) {
if (mFloatingActionButton.getVisibility() == View.GONE
&& mFindInFileView.getVisibility() == GONE) {
- mFloatingActionButton.show();
+ mImmersiveModeRequester.requestImmersiveModeChange(false);
} else {
- mFloatingActionButton.hide();
+ mImmersiveModeRequester.requestImmersiveModeChange(true);
}
}
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/ZoomScrollValueObserver.java b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/ZoomScrollValueObserver.java
index b266f91..c373b3c 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/ZoomScrollValueObserver.java
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/ZoomScrollValueObserver.java
@@ -43,6 +43,7 @@
private boolean mIsAnnotationIntentResolvable;
private final SelectionActionMode mSelectionActionMode;
private final ObservableValue<ViewState> mViewState;
+ private final ImmersiveModeRequester mImmersiveModeRequester;
private boolean mIsPageScrollingUp;
@@ -51,7 +52,8 @@
@NonNull LayoutHandler layoutHandler, @NonNull FloatingActionButton annotationButton,
@NonNull FindInFileView findInFileView, boolean isAnnotationIntentResolvable,
@NonNull SelectionActionMode selectionActionMode,
- @NonNull ObservableValue<ViewState> viewState) {
+ @NonNull ObservableValue<ViewState> viewState,
+ @NonNull ImmersiveModeRequester immersiveModeRequester) {
mZoomView = zoomView;
mPaginatedView = paginatedView;
mLayoutHandler = layoutHandler;
@@ -62,6 +64,7 @@
mViewState = viewState;
mAnnotationButtonHandler = new Handler(Looper.getMainLooper());
mIsPageScrollingUp = false;
+ mImmersiveModeRequester = immersiveModeRequester;
}
@Override
@@ -93,18 +96,15 @@
if (!isAnnotationButtonVisible() && position.scrollY == 0
&& mFindInFileView.getVisibility() == View.GONE) {
- mAnnotationButton.show();
+ mImmersiveModeRequester.requestImmersiveModeChange(false);
} else if (isAnnotationButtonVisible() && mIsPageScrollingUp) {
clearAnnotationHandler();
return;
}
if (position.scrollY == oldPosition.scrollY) {
- mAnnotationButtonHandler.post(new Runnable() {
- @Override
- public void run() {
- if (position.scrollY != 0) {
- mAnnotationButton.hide();
- }
+ mAnnotationButtonHandler.post(() -> {
+ if (position.scrollY != 0) {
+ mImmersiveModeRequester.requestImmersiveModeChange(true);
}
});
}
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfConnection.java b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfConnection.java
index a86c12b..df72681 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfConnection.java
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfConnection.java
@@ -74,11 +74,6 @@
this.mOnConnectFailure = onConnectFailure;
}
- /** Checks if Connection to PdfDocumentService is established */
- public boolean isConnected() {
- return mConnected;
- }
-
/**
* Returns a {@link PdfDocumentRemote} if the service is bound. It could be still initializing
* (see {@link #setDocumentLoaded}).
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoader.java b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoader.java
index 4506163..cf49ad7 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoader.java
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoader.java
@@ -139,26 +139,7 @@
/** Schedule task to load a PdfDocument. */
public void reloadDocument() {
- if (isConnected()) {
- mExecutor.schedule(new LoadDocumentTask(mLoadedPassword));
- } else {
- /*
- * For password protected files we kill the service if the app goes into
- * background before the document is loaded hence here we just register a
- * task which will be executed once the service is reconnected onStart
- */
- mConnection.setOnConnectInitializer(
- () -> mExecutor.schedule(new LoadDocumentTask(mLoadedPassword)));
- mConnection.setConnectionFailureHandler(
- () -> mCallbacks.documentNotLoaded(PdfStatus.NONE));
- }
- }
-
- /**
- * Check if PdfLoader is connected to PdfDocumentService
- */
- public boolean isConnected() {
- return mConnection.isConnected();
+ mExecutor.schedule(new LoadDocumentTask(mLoadedPassword));
}
/**
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoaderCallbacksImpl.kt b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoaderCallbacksImpl.kt
index 2f5210b..7b2d1b0 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoaderCallbacksImpl.kt
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoaderCallbacksImpl.kt
@@ -67,7 +67,7 @@
private var isTextSearchActive: Boolean,
private var viewState: ExposedValue<ViewState>,
private val fragmentContainerView: View?,
- private val onRequestPassword: (Boolean) -> Boolean,
+ private val onRequestPassword: (Boolean) -> Unit,
private val onDocumentLoaded: () -> Unit,
private val onDocumentLoadFailure: (Throwable) -> Unit,
private var eventCallback: EventCallback?,
@@ -148,39 +148,39 @@
override fun requestPassword(incorrect: Boolean) {
eventCallback?.onViewerReset()
- if (onRequestPassword(onScreen)) return
+ onRequestPassword(onScreen)
if (viewState.get() != ViewState.NO_VIEW) {
var passwordDialog = currentPasswordDialog(fragmentManager)
if (passwordDialog == null) {
passwordDialog = PdfPasswordDialog()
+ passwordDialog.setListener(
+ object : PdfPasswordDialog.PasswordDialogEventsListener {
+ override fun onPasswordTextChange(password: String) {
+ pdfLoader?.applyPassword(password)
+ }
+
+ override fun onDialogCancelled() {
+ val retryCallback = Runnable { requestPassword(false) }
+ val snackbar =
+ fragmentContainerView?.let {
+ Snackbar.make(
+ it,
+ R.string.password_not_entered,
+ Snackbar.LENGTH_INDEFINITE
+ )
+ }
+ val mResolveClickListener =
+ View.OnClickListener { _: View? -> retryCallback.run() }
+ snackbar?.setAction(R.string.retry_button_text, mResolveClickListener)
+ snackbar?.show()
+ }
+ }
+ )
+
passwordDialog.show(fragmentManager, PASSWORD_DIALOG_TAG)
}
- passwordDialog.setListener(
- object : PdfPasswordDialog.PasswordDialogEventsListener {
- override fun onPasswordTextChange(password: String) {
- pdfLoader?.applyPassword(password)
- }
-
- override fun onDialogCancelled() {
- val retryCallback = Runnable { requestPassword(false) }
- val snackbar =
- fragmentContainerView?.let {
- Snackbar.make(
- it,
- R.string.password_not_entered,
- Snackbar.LENGTH_INDEFINITE
- )
- }
- val mResolveClickListener =
- View.OnClickListener { _: View? -> retryCallback.run() }
- snackbar?.setAction(R.string.retry_button_text, mResolveClickListener)
- snackbar?.show()
- }
- }
- )
-
if (incorrect) {
passwordDialog.retry()
}
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/password/PasswordDialog.java b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/password/PasswordDialog.java
index d887f00..a994a5d 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/password/PasswordDialog.java
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/password/PasswordDialog.java
@@ -63,7 +63,6 @@
private int mBlueColor;
private int mTextErrorColor;
private AlertDialog mPasswordDialog;
- private static final String PASSWORD_INPUT = "password_input";
private boolean mIncorrect;
private boolean mFinishOnCancel;
@@ -93,11 +92,6 @@
final EditText passwordField = (EditText) view.findViewById(R.id.password);
setupPasswordField(passwordField);
- if (savedInstanceState != null) {
- String savedPassword = savedInstanceState.getString(PASSWORD_INPUT, "");
- passwordField.setText(savedPassword);
- }
-
// Hijack the positive button to NOT dismiss the dialog immediately.
dialog.setOnShowListener(
new OnShowListener() {
@@ -152,17 +146,6 @@
return dialog;
}
- @Override
- public void onSaveInstanceState(@NonNull Bundle outState) {
- super.onSaveInstanceState(outState);
- // Find the EditText by its ID
- EditText editTextPassword = (EditText) mPasswordDialog.findViewById(R.id.password);
- // Get the text from the EditText and store it in passwordInput
- String passwordInput = editTextPassword.getText().toString();
- // Save the input to the outState bundle
- outState.putString(PASSWORD_INPUT, passwordInput);
- }
-
private void setupPasswordField(final EditText passwordField) {
passwordField.setFocusable(true);
passwordField.requestFocus();
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/find/FindInFileViewTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/find/FindInFileViewTest.java
index e100e41..1135636 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/find/FindInFileViewTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/find/FindInFileViewTest.java
@@ -26,6 +26,7 @@
import android.os.Build;
+import androidx.pdf.viewer.ImmersiveModeRequester;
import androidx.pdf.viewer.PaginatedView;
import androidx.pdf.viewer.loader.PdfLoader;
import androidx.test.core.app.ApplicationProvider;
@@ -58,6 +59,8 @@
private PaginatedView mPaginatedView;
@Mock
private FloatingActionButton mAnnotationButton;
+ @Mock
+ private ImmersiveModeRequester mImmersiveModeRequester;
private FindInFileView mFindInFileView;
private AutoCloseable mOpenMocks;
@@ -67,7 +70,7 @@
mFindInFileView = new FindInFileView(ApplicationProvider.getApplicationContext());
mFindInFileView.setPdfLoader(mPdfLoader);
mFindInFileView.setPaginatedView(mPaginatedView);
- mFindInFileView.setAnnotationButton(mAnnotationButton);
+ mFindInFileView.setAnnotationButton(mAnnotationButton, mImmersiveModeRequester);
}
@After
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/ZoomScrollValueObserverTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/ZoomScrollValueObserverTest.java
index 412b9c8..e9b5a39f 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/ZoomScrollValueObserverTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/ZoomScrollValueObserverTest.java
@@ -26,13 +26,16 @@
import android.os.Looper;
import android.view.View;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.pdf.ViewState;
import androidx.pdf.data.Range;
import androidx.pdf.find.FindInFileView;
+import androidx.pdf.models.PageSelection;
+import androidx.pdf.models.SelectionBoundary;
import androidx.pdf.select.SelectionActionMode;
import androidx.pdf.util.ObservableValue;
import androidx.pdf.util.Observables;
-import androidx.pdf.widget.FastScrollView;
import androidx.pdf.widget.ZoomView;
import androidx.test.filters.SmallTest;
@@ -65,14 +68,14 @@
private final LayoutHandler mMockLayoutHandler = mock(LayoutHandler.class);
private final FloatingActionButton mMockAnnotationButton = mock(FloatingActionButton.class);
private final FindInFileView mMockFindInFileView = mock(FindInFileView.class);
- private final FastScrollView mMockFastScrollView = mock(FastScrollView.class);
private final PageRangeHandler mPageRangeHandler = mock(PageRangeHandler.class);
private final SelectionActionMode mMockSelectionActionMode = mock(SelectionActionMode.class);
-
+ private final PdfSelectionModel mMockSelectionModel = mock(PdfSelectionModel.class);
+ private final PageSelection mMockPageSelection = mock(PageSelection.class);
+ private final ImmersiveModeRequester mMockImmersiveModeRequester = mock(
+ ImmersiveModeRequester.class);
private boolean mIsAnnotationIntentResolvable;
private ZoomView.ZoomScroll mNewPosition;
- private ZoomView.ZoomScroll mOldPosition;
-
@Before
public void setUp() {
@@ -82,52 +85,54 @@
when(mMockPaginatedView.getPageRangeHandler()).thenReturn(mPageRangeHandler);
when(mMockPaginatedView.getModel()).thenReturn(mMockPaginationModel);
when(mMockPaginationModel.isInitialized()).thenReturn(true);
- when(mMockZoomView.getHeight()).thenReturn(100);
when(mPageRangeHandler.computeVisibleRange(0, 1.0f, 100, false)).thenReturn(PAGE_RANGE);
- when(mMockZoomView.getStableZoom()).thenReturn(1.0f);
- when(mMockZoomView.getVisibleAreaInContentCoords()).thenReturn(RECT);
when(mMockPaginatedView.createPageViewsForVisiblePageRange()).thenReturn(false);
when(mPageRangeHandler.getVisiblePages()).thenReturn(PAGE_RANGE);
when(mMockPaginationModel.isInitialized()).thenReturn(true);
when(mMockPaginationModel.getSize()).thenReturn(1);
+ when(mMockPaginatedView.getSelectionModel()).thenReturn(mMockSelectionModel);
+ when(mMockSelectionModel.selection()).thenReturn(new ObservableValue<PageSelection>() {
+ @Nullable
+ @Override
+ public PageSelection get() {
+ return mMockPageSelection;
+ }
+
+ @NonNull
+ @Override
+ public Object addObserver(ValueObserver<PageSelection> observer) {
+ return 1;
+ }
+
+ @Override
+ public void removeObserver(@NonNull Object observerKey) {
+
+ }
+ });
+ when(mMockPageSelection.getStart()).thenReturn(new SelectionBoundary(0, 0, 0, false));
+ when(mMockPageSelection.getStop()).thenReturn(new SelectionBoundary(0, 100, 100, false));
+ when(mMockPaginatedView.getViewArea()).thenReturn(RECT);
+ when(mMockPaginationModel.getLookAtX(0, 0)).thenReturn(1);
+ when(mMockPaginationModel.getLookAtX(0, 100)).thenReturn(50);
+ when(mMockPaginationModel.getLookAtY(0, 0)).thenReturn(1);
+ when(mMockPaginationModel.getLookAtY(0, 100)).thenReturn(50);
}
@Test
- public void onChange_loadPageAssets_stablePosition() {
+ public void onChange_stablePosition() {
mNewPosition = new ZoomView.ZoomScroll(1.0f, 0, 0, true);
ZoomScrollValueObserver zoomScrollValueObserver = new ZoomScrollValueObserver(mMockZoomView,
mMockPaginatedView, mMockLayoutHandler, mMockAnnotationButton,
mMockFindInFileView, mIsAnnotationIntentResolvable, mMockSelectionActionMode,
- VIEW_STATE_EXPOSED_VALUE);
+ VIEW_STATE_EXPOSED_VALUE, mMockImmersiveModeRequester);
zoomScrollValueObserver.onChange(OLD_POSITION, mNewPosition);
- verify(mMockPaginatedView).setViewArea(RECT);
- verify(mMockPaginatedView).refreshPageRangeInVisibleArea(mNewPosition, 100);
- verify(mMockPaginatedView).handleGonePages(false);
- verify(mMockPaginatedView).loadInvisibleNearPageRange(1.0f);
- verify(mMockPaginatedView).refreshVisiblePages(false,
- ViewState.NO_VIEW, 1.0f);
- verify(mMockPaginatedView).handleGonePages(true);
- verify(mMockLayoutHandler).maybeLayoutPages(100);
+ verify(mMockSelectionActionMode).resume();
}
@Test
- public void onChange_loadPageAssets_stableZoom() {
- mNewPosition = new ZoomView.ZoomScroll(2.0f, 0, 0, false);
- when(mMockZoomView.getStableZoom()).thenReturn(2.0f);
-
- ZoomScrollValueObserver zoomScrollValueObserver = new ZoomScrollValueObserver(mMockZoomView,
- mMockPaginatedView, mMockLayoutHandler, mMockAnnotationButton, mMockFindInFileView,
- mIsAnnotationIntentResolvable, mMockSelectionActionMode,
- VIEW_STATE_EXPOSED_VALUE);
- zoomScrollValueObserver.onChange(OLD_POSITION, mNewPosition);
-
- verify(mMockPaginatedView).refreshVisibleTiles(false, ViewState.NO_VIEW);
- }
-
- @Test
- public void onChange_showAnnotationButton() {
+ public void onChange_exitImmersiveMode() {
mIsAnnotationIntentResolvable = true;
when(mMockAnnotationButton.getVisibility()).thenReturn(View.GONE);
when(mMockFindInFileView.getVisibility()).thenReturn(View.GONE);
@@ -136,29 +141,29 @@
mMockPaginatedView, mMockLayoutHandler, mMockAnnotationButton,
mMockFindInFileView,
mIsAnnotationIntentResolvable, mMockSelectionActionMode,
- VIEW_STATE_EXPOSED_VALUE);
+ VIEW_STATE_EXPOSED_VALUE, mMockImmersiveModeRequester);
zoomScrollValueObserver.onChange(OLD_POSITION, mNewPosition);
- verify(mMockAnnotationButton).setVisibility(View.VISIBLE);
+ verify(mMockImmersiveModeRequester).requestImmersiveModeChange(false);
}
@Test
- public void onChange_hideAnnotationButton() {
+ public void onChange_enterImmersiveMode() {
mIsAnnotationIntentResolvable = true;
mNewPosition = new ZoomView.ZoomScroll(1.0f, 0, 10, false);
- mOldPosition = new ZoomView.ZoomScroll(1.0f, 0, 10, false);
+ ZoomView.ZoomScroll oldPosition = new ZoomView.ZoomScroll(1.0f, 0, 10, false);
when(mMockAnnotationButton.getVisibility()).thenReturn(View.VISIBLE);
ZoomScrollValueObserver zoomScrollValueObserver = new ZoomScrollValueObserver(mMockZoomView,
mMockPaginatedView, mMockLayoutHandler, mMockAnnotationButton,
mMockFindInFileView,
mIsAnnotationIntentResolvable, mMockSelectionActionMode,
- VIEW_STATE_EXPOSED_VALUE);
- zoomScrollValueObserver.onChange(mOldPosition, mNewPosition);
+ VIEW_STATE_EXPOSED_VALUE, mMockImmersiveModeRequester);
+ zoomScrollValueObserver.onChange(oldPosition, mNewPosition);
// TODO: Remove this hardcode dependency.
- new Handler(Looper.getMainLooper()).postDelayed(() -> {
- verify(mMockAnnotationButton).setVisibility(View.GONE);
- }, ANIMATION_DELAY_MILLIS);
+ new Handler(Looper.getMainLooper()).postDelayed(
+ () -> verify(mMockImmersiveModeRequester).requestImmersiveModeChange(true),
+ ANIMATION_DELAY_MILLIS);
}
}
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainFragment.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainFragment.kt
deleted file mode 100644
index 8b13789..0000000
--- a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainFragment.kt
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/ISandboxedUiAdapter.aidl b/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/ISandboxedUiAdapter.aidl
index 47e58ed..63aa0ac 100644
--- a/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/ISandboxedUiAdapter.aidl
+++ b/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/ISandboxedUiAdapter.aidl
@@ -21,6 +21,7 @@
@JavaPassthrough(annotation="@androidx.annotation.RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY)")
oneway interface ISandboxedUiAdapter {
+ @JavaPassthrough(annotation="@androidx.annotation.RequiresApi(34)")
void openRemoteSession(
IBinder hostToken, int displayId, int initialWidth, int initialHeight, boolean isZOrderOnTop,
IRemoteSessionClient remoteSessionClient);
diff --git a/privacysandbox/ui/ui-provider/build.gradle b/privacysandbox/ui/ui-provider/build.gradle
index 73065aa..6e49780 100644
--- a/privacysandbox/ui/ui-provider/build.gradle
+++ b/privacysandbox/ui/ui-provider/build.gradle
@@ -47,6 +47,7 @@
androidTestImplementation(libs.espressoCore)
androidTestImplementation(libs.testUiautomator)
androidTestImplementation(project(":internal-testutils-runtime"))
+ androidTestImplementation(project(":internal-testutils-truth"))
}
android {
diff --git a/privacysandbox/ui/ui-provider/src/androidTest/kotlin/androidx/privacysandbox/ui/provider/impl/DeferredObjectHolderTest.kt b/privacysandbox/ui/ui-provider/src/androidTest/kotlin/androidx/privacysandbox/ui/provider/impl/DeferredObjectHolderTest.kt
new file mode 100644
index 0000000..43bd0a2
--- /dev/null
+++ b/privacysandbox/ui/ui-provider/src/androidTest/kotlin/androidx/privacysandbox/ui/provider/impl/DeferredObjectHolderTest.kt
@@ -0,0 +1,443 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.privacysandbox.ui.provider.impl
+
+import android.os.MessageQueue
+import androidx.core.util.Consumer
+import androidx.core.util.Supplier
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import java.util.concurrent.atomic.AtomicInteger
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+@SdkSuppress(minSdkVersion = 23)
+class DeferredObjectHolderTest {
+
+ private lateinit var messageQueue: StubMessageQueue
+
+ private lateinit var errorObject: StubObject
+ private lateinit var errorHandler: StubErrorHandler
+
+ @Before
+ fun setUp() {
+ messageQueue = StubMessageQueue()
+ errorObject = StubObject().also(StubObject::initialize)
+ errorHandler = StubErrorHandler()
+ }
+
+ @After
+ fun tearDown() {
+ errorHandler.assertNoError()
+ }
+
+ @Test
+ fun demandObject_beforePreloadObject_createAndInitObject_success() {
+ val stubObject = StubObject()
+ val deferredObjectHolder = createDeferredObjectHolder(stubObject)
+
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(stubObject)
+ // Check that creation / initialization logic triggered only once
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(stubObject)
+
+ // No handler registered as object already created
+ deferredObjectHolder.preloadObject(messageQueue)
+ assertThat(messageQueue.handlerRegistered).isFalse()
+ }
+
+ @Test
+ fun demandObject_beforePreloadObject_createAndInitObject_createFail() {
+ val exceptionOnCreate = RuntimeException("Error during creation")
+ demandObject_beforePreloadObject_createAndInitObject_fail(
+ createDeferredObjectHolder(exceptionOnCreate = exceptionOnCreate),
+ exceptionOnCreate
+ )
+ }
+
+ @Test
+ fun demandObject_beforePreloadObject_createAndInitObject_initFail() {
+ val exceptionOnInit = RuntimeException("Error during initialization")
+ demandObject_beforePreloadObject_createAndInitObject_fail(
+ createDeferredObjectHolder(exceptionOnInit = exceptionOnInit),
+ exceptionOnInit
+ )
+ }
+
+ private fun demandObject_beforePreloadObject_createAndInitObject_fail(
+ deferredObjectHolder: DeferredObjectHolder<StubObject, StubObject>,
+ expectedError: Throwable
+ ) {
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(errorObject)
+ errorHandler.assertErrorAndReset(expectedError)
+
+ // Check that creation / initialization logic triggered only once
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(errorObject)
+ errorHandler.assertNoError()
+
+ // No handler registered as already failed
+ deferredObjectHolder.preloadObject(messageQueue)
+ assertThat(messageQueue.handlerRegistered).isFalse()
+ }
+
+ @Test
+ fun demandObject_beforeIdle_createAndInitObject_success() {
+ val stubObject = StubObject()
+ val deferredObjectHolder = createDeferredObjectHolder(stubObject)
+ deferredObjectHolder.preloadObject(messageQueue)
+ assertThat(messageQueue.handlerRegistered).isTrue()
+
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(stubObject)
+
+ // Handler unregistered during processIdle() as object already created and initialized
+ messageQueue.processIdle()
+ assertThat(messageQueue.handlerRegistered).isFalse()
+
+ // Check that creation / initialization logic triggered only once
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(stubObject)
+ }
+
+ @Test
+ fun demandObject_beforeIdle_createAndInitObject_createFail() {
+ val exceptionOnCreate = RuntimeException("Error during creation")
+ demandObject_beforeIdle_createAndInitObject_fail(
+ createDeferredObjectHolder(exceptionOnCreate = exceptionOnCreate),
+ exceptionOnCreate
+ )
+ }
+
+ @Test
+ fun demandObject_beforeIdle_createAndInitObject_initFail() {
+ val exceptionOnInit = RuntimeException("Error during initialization")
+ demandObject_beforeIdle_createAndInitObject_fail(
+ createDeferredObjectHolder(exceptionOnInit = exceptionOnInit),
+ exceptionOnInit
+ )
+ }
+
+ private fun demandObject_beforeIdle_createAndInitObject_fail(
+ deferredObjectHolder: DeferredObjectHolder<StubObject, StubObject>,
+ expectedError: Throwable
+ ) {
+ deferredObjectHolder.preloadObject(messageQueue)
+ assertThat(messageQueue.handlerRegistered).isTrue()
+
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(errorObject)
+ errorHandler.assertErrorAndReset(expectedError)
+
+ // Handler unregistered during processIdle() as already failed
+ messageQueue.processIdle()
+ assertThat(messageQueue.handlerRegistered).isFalse()
+
+ // Check that creation / initialization logic triggered only once
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(errorObject)
+ errorHandler.assertNoError()
+ }
+
+ @Test
+ fun demandObject_afterIdleCreate_initObject_success() {
+ val stubObject = StubObject()
+ val deferredObjectHolder = createDeferredObjectHolder(stubObject)
+ deferredObjectHolder.preloadObject(messageQueue)
+ assertThat(messageQueue.handlerRegistered).isTrue()
+
+ // Created in first idle, but not initialized
+ messageQueue.processIdle()
+ assertThat(stubObject.isInitCalled()).isFalse()
+ assertThat(messageQueue.handlerRegistered).isTrue()
+
+ // Initialize during demandObject()
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(stubObject)
+ assertThat(stubObject.isInitCalled()).isTrue()
+
+ // Handler unregistered during processIdle() as object already created and initialized
+ messageQueue.processIdle()
+ assertThat(messageQueue.handlerRegistered).isFalse()
+
+ // Check that creation / initialization logic triggered only once
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(stubObject)
+ }
+
+ @Test
+ fun demandObject_afterIdleCreate_initObject_fail() {
+ val exceptionOnInit = RuntimeException("Error during initialization")
+ val deferredObjectHolder = createDeferredObjectHolder(exceptionOnInit = exceptionOnInit)
+ deferredObjectHolder.preloadObject(messageQueue)
+ assertThat(messageQueue.handlerRegistered).isTrue()
+
+ // Created in first idle, but not initialized
+ messageQueue.processIdle()
+ assertThat(messageQueue.handlerRegistered).isTrue()
+
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(errorObject)
+ errorHandler.assertErrorAndReset(exceptionOnInit)
+
+ // Handler unregistered during processIdle() as already failed
+ messageQueue.processIdle()
+ assertThat(messageQueue.handlerRegistered).isFalse()
+
+ // Check that creation / initialization logic triggered only once
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(errorObject)
+ errorHandler.assertNoError()
+ }
+
+ @Test
+ fun demandObject_afterIdleInit_differentCreateAndInitIdles() {
+ val stubObject = StubObject()
+ val deferredObjectHolder = createDeferredObjectHolder(stubObject)
+ deferredObjectHolder.preloadObject(messageQueue)
+ assertThat(messageQueue.handlerRegistered).isTrue()
+
+ // Create in first idle, but not initialize
+ messageQueue.processIdle(hasMessagesDue = true)
+ assertThat(stubObject.isInitCalled()).isFalse()
+ assertThat(messageQueue.handlerRegistered).isTrue()
+
+ // Init in idle when no messages due while processing
+ messageQueue.processIdle(hasMessagesDue = false)
+ assertThat(stubObject.isInitCalled()).isTrue()
+ assertThat(messageQueue.handlerRegistered).isFalse()
+
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(stubObject)
+
+ // Check that creation / initialization logic triggered only once
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(stubObject)
+ }
+
+ @Test
+ fun demandObject_afterIdleInit_singleCreateAndInitIdle() {
+ val stubObject = StubObject()
+ val deferredObjectHolder = createDeferredObjectHolder(stubObject)
+ deferredObjectHolder.preloadObject(messageQueue)
+ assertThat(messageQueue.handlerRegistered).isTrue()
+
+ // Create and init in first idle (no messages due while processing)
+ messageQueue.processIdle(hasMessagesDue = false)
+ assertThat(messageQueue.handlerRegistered).isFalse()
+
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(stubObject)
+
+ // Check that creation / initialization logic triggered only once
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(stubObject)
+ }
+
+ @Test
+ fun demandObject_afterIdleCreateFail_differentCreateAndInitIdles() {
+ demandObject_afterIdleCreateFail(hasMessagesDueWhileProcessIdle = true)
+ }
+
+ @Test
+ fun demandObject_afterIdleCreateFail_singleCreateAndInitIdle() {
+ demandObject_afterIdleCreateFail(hasMessagesDueWhileProcessIdle = false)
+ }
+
+ private fun demandObject_afterIdleCreateFail(hasMessagesDueWhileProcessIdle: Boolean) {
+ val exceptionOnCreate = RuntimeException("Error during creation")
+ val deferredObjectHolder = createDeferredObjectHolder(exceptionOnCreate = exceptionOnCreate)
+
+ deferredObjectHolder.preloadObject(messageQueue)
+ assertThat(messageQueue.handlerRegistered).isTrue()
+
+ // Fail object creation in first idle
+ messageQueue.processIdle(hasMessagesDue = hasMessagesDueWhileProcessIdle)
+ errorHandler.assertErrorAndReset(exceptionOnCreate)
+ assertThat(messageQueue.handlerRegistered).isFalse()
+
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(errorObject)
+
+ // Check that creation / initialization logic triggered only once
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(errorObject)
+ errorHandler.assertNoError()
+ }
+
+ @Test
+ fun demandObject_afterIdleInitFail_differentCreateAndInitIdles() {
+ val exceptionOnInit = RuntimeException("Error during initialization")
+ val deferredObjectHolder = createDeferredObjectHolder(exceptionOnInit = exceptionOnInit)
+
+ deferredObjectHolder.preloadObject(messageQueue)
+ assertThat(messageQueue.handlerRegistered).isTrue()
+
+ // Create in first idle
+ messageQueue.processIdle(hasMessagesDue = true)
+ errorHandler.assertNoError()
+ assertThat(messageQueue.handlerRegistered).isTrue()
+
+ // Init fail in idle when no messages due while processing
+ messageQueue.processIdle(hasMessagesDue = false)
+ errorHandler.assertErrorAndReset(exceptionOnInit)
+ assertThat(messageQueue.handlerRegistered).isFalse()
+
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(errorObject)
+
+ // Check that creation / initialization logic triggered only once
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(errorObject)
+ errorHandler.assertNoError()
+ }
+
+ @Test
+ fun demandObject_afterIdleInitFail_singleCreateAndInitIdle() {
+ val exceptionOnInit = RuntimeException("Error during initialization")
+ val deferredObjectHolder = createDeferredObjectHolder(exceptionOnInit = exceptionOnInit)
+
+ deferredObjectHolder.preloadObject(messageQueue)
+ assertThat(messageQueue.handlerRegistered).isTrue()
+
+ // Successfully create object, but fail init in first idle
+ messageQueue.processIdle(hasMessagesDue = false)
+ errorHandler.assertErrorAndReset(exceptionOnInit)
+ assertThat(messageQueue.handlerRegistered).isFalse()
+
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(errorObject)
+
+ // Check that creation / initialization logic triggered only once
+ deferredObjectHolder.demandObjectAndAssertThatSameInstanceAs(errorObject)
+ errorHandler.assertNoError()
+ }
+
+ private fun createDeferredObjectHolder(
+ stubObject: StubObject
+ ): DeferredObjectHolder<StubObject, StubObject> =
+ createDeferredObjectHolder(StubObjectFactory(stubObject = stubObject))
+
+ private fun createDeferredObjectHolder(
+ exceptionOnCreate: Throwable? = null,
+ exceptionOnInit: Throwable? = null
+ ): DeferredObjectHolder<StubObject, StubObject> =
+ createDeferredObjectHolder(
+ StubObjectFactory(
+ exceptionOnCreate = exceptionOnCreate,
+ stubObject = StubObject(exceptionOnInit = exceptionOnInit)
+ )
+ )
+
+ private fun createDeferredObjectHolder(
+ objectFactory: Supplier<StubObject>
+ ): DeferredObjectHolder<StubObject, StubObject> {
+ return DeferredObjectHolder(
+ objectFactory = objectFactory,
+ objectInit = StubObject::initialize,
+ errorHandler = errorHandler,
+ errorObject = errorObject
+ )
+ }
+
+ private class StubObjectFactory(
+ private val exceptionOnCreate: Throwable? = null,
+ private val stubObject: StubObject = StubObject(),
+ ) : Supplier<StubObject> {
+
+ val createMethodCallCounter = AtomicInteger(0)
+
+ override fun get(): StubObject {
+ // Check that called only once.
+ assertThat(createMethodCallCounter.incrementAndGet()).isEqualTo(1)
+ if (exceptionOnCreate != null) {
+ throw exceptionOnCreate
+ }
+ return stubObject
+ }
+ }
+
+ private class StubObject(private val exceptionOnInit: Throwable? = null) {
+ private val initMethodCallCounter = AtomicInteger(0)
+
+ fun initialize() {
+ // Check that called only once.
+ assertThat(initMethodCallCounter.incrementAndGet()).isEqualTo(1)
+ if (exceptionOnInit != null) {
+ throw exceptionOnInit
+ }
+ }
+
+ fun isInitCalled(): Boolean {
+ return initMethodCallCounter.get() != 0
+ }
+ }
+
+ private class StubErrorHandler : Consumer<Throwable> {
+ private var error: Throwable? = null
+
+ fun assertErrorAndReset(expected: Throwable) {
+ assertThat(error).isEqualTo(expected)
+ error = null
+ }
+
+ fun assertNoError() {
+ assertWithMessage("Unexpected error").that(error).isNull()
+ }
+
+ override fun accept(value: Throwable) {
+ // Check that error set only once before assertErrorAndReset() call.
+ assertThat(this.error).isNull()
+ this.error = value
+ }
+ }
+
+ private class StubMessageQueue : DeferredObjectHolder.MessageQueue {
+
+ val handlerRegistered: Boolean
+ get() = handlers.isNotEmpty()
+
+ private var idle: Boolean = false
+ private var handlers: MutableList<MessageQueue.IdleHandler> = mutableListOf()
+
+ /**
+ * Emulate Idle state - run registered IdleHandlers.
+ *
+ * If handler calls [isIdle] during processing it will receive ![hasMessagesDue] as result.
+ *
+ * In real world [hasMessagesDue] will be "true" more often that "false":
+ * 1) It will be used during intensive loading process - high chance of new messages arrive.
+ * 2) Logic inside IdleHandler takes time - increasing chance of new messages arrive.
+ * 3) Other IdleHandlers takes time / etc.
+ */
+ fun processIdle(hasMessagesDue: Boolean = true) {
+ idle = !hasMessagesDue
+ val it = handlers.iterator()
+ while (it.hasNext()) {
+ val handler = it.next()
+ val keep = handler.queueIdle()
+ if (!keep) {
+ it.remove()
+ }
+ }
+ idle = false
+ }
+
+ override fun addIdleHandler(handler: MessageQueue.IdleHandler) {
+ handlers.add(handler)
+ }
+
+ override fun isIdle(): Boolean {
+ return idle
+ }
+ }
+
+ private fun DeferredObjectHolder<StubObject, StubObject>
+ .demandObjectAndAssertThatSameInstanceAs(expected: StubObject) {
+ val result = demandObject()
+ assertThat(result.isInitCalled()).isTrue()
+ assertThat(result).isSameInstanceAs(expected)
+ }
+}
diff --git a/privacysandbox/ui/ui-provider/src/androidTest/kotlin/androidx/privacysandbox/ui/provider/impl/DeferredSessionClientTest.kt b/privacysandbox/ui/ui-provider/src/androidTest/kotlin/androidx/privacysandbox/ui/provider/impl/DeferredSessionClientTest.kt
new file mode 100644
index 0000000..2634162
--- /dev/null
+++ b/privacysandbox/ui/ui-provider/src/androidTest/kotlin/androidx/privacysandbox/ui/provider/impl/DeferredSessionClientTest.kt
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.privacysandbox.ui.provider.impl
+
+import android.content.res.Configuration
+import android.os.Bundle
+import android.view.View
+import androidx.core.util.Consumer
+import androidx.privacysandbox.ui.core.SandboxedUiAdapter
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import java.util.concurrent.atomic.AtomicReference
+import org.junit.Assert
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+@SdkSuppress(minSdkVersion = 23)
+class DeferredSessionClientTest {
+
+ @Test
+ fun onSessionOpened_whenDemandObjectSuccess_delegates() {
+ val stubClient = StubClient()
+ val deferredClient = createDeferredClient(stubClient)
+
+ val session = StubSession()
+ deferredClient.onSessionOpened(session)
+
+ assertThat(stubClient.sessions).containsExactly(session)
+ assertThat(session.closed).isFalse()
+ }
+
+ @Test
+ fun onSessionOpened_whenDemandObjectFailed_callErrorHandlerAndCloseSession() {
+ val exception = RuntimeException("Something went wrong")
+ val stubClient = StubClient(exceptionOnInit = exception)
+ val fail = AtomicReference<Throwable>()
+ val deferredClient = createDeferredClient(stubClient, errorHandler = fail::set)
+
+ val session = StubSession()
+ deferredClient.onSessionOpened(session)
+
+ assertThat(fail.get()).isEqualTo(exception)
+ assertThat(stubClient.sessions).isEmpty()
+ assertThat(session.closed).isTrue()
+ }
+
+ @Test
+ fun onSessionError_whenDemandObjectSuccess_delegates() {
+ val stubClient = StubClient()
+ val deferredClient = createDeferredClient(stubClient)
+
+ val sessionError = RuntimeException("Session opening error")
+ deferredClient.onSessionError(sessionError)
+
+ assertThat(stubClient.errors).containsExactly(sessionError)
+ }
+
+ @Test
+ fun onSessionError_whenDemandObjectFailed_callErrorHandler() {
+ val exception = RuntimeException("Something went wrong")
+ val stubClient = StubClient(exceptionOnInit = exception)
+ val fail = AtomicReference<Throwable>()
+ val deferredClient = createDeferredClient(stubClient, errorHandler = fail::set)
+
+ val sessionError = RuntimeException("Session opening error")
+ deferredClient.onSessionError(sessionError)
+
+ assertThat(fail.get()).isEqualTo(exception)
+ assertThat(stubClient.errors).isEmpty()
+ }
+
+ @Test
+ fun onResizeRequested_whenDemandObjectSuccess_delegates() {
+ val stubClient = StubClient()
+ val deferredClient = createDeferredClient(stubClient)
+
+ deferredClient.onResizeRequested(1, 2)
+
+ assertThat(stubClient.resizes).containsExactly(Pair(1, 2))
+ }
+
+ @Test
+ fun onResizeRequested_whenDemandObjectFailed_callErrorHandler() {
+ val exception = RuntimeException("Something went wrong")
+ val stubClient = StubClient(exceptionOnInit = exception)
+ val fail = AtomicReference<Throwable>()
+ val deferredClient = createDeferredClient(stubClient, errorHandler = fail::set)
+
+ deferredClient.onResizeRequested(1, 2)
+
+ assertThat(fail.get()).isEqualTo(exception)
+ assertThat(stubClient.resizes).isEmpty()
+ }
+
+ private fun createDeferredClient(
+ stubClient: StubClient,
+ errorHandler: Consumer<Throwable> = Consumer {
+ Assert.fail("Unexpected fail " + it.message)
+ }
+ ): DeferredSessionClient {
+ return DeferredSessionClient.create(
+ clientFactory = { stubClient },
+ clientInit = StubClient::initialize,
+ errorHandler = errorHandler
+ )
+ }
+
+ private class StubClient(val exceptionOnInit: Throwable? = null) :
+ SandboxedUiAdapter.SessionClient {
+
+ val sessions: MutableList<SandboxedUiAdapter.Session> = mutableListOf()
+ val errors: MutableList<Throwable> = mutableListOf()
+ val resizes: MutableList<Pair<Int, Int>> = mutableListOf()
+
+ fun initialize() {
+ if (exceptionOnInit != null) {
+ throw exceptionOnInit
+ }
+ }
+
+ override fun onSessionOpened(session: SandboxedUiAdapter.Session) {
+ sessions.add(session)
+ }
+
+ override fun onSessionError(throwable: Throwable) {
+ errors.add(throwable)
+ }
+
+ override fun onResizeRequested(width: Int, height: Int) {
+ resizes.add(Pair(width, height))
+ }
+ }
+
+ private class StubSession : SandboxedUiAdapter.Session {
+
+ var closed: Boolean = false
+
+ override val view: View
+ get() = throw UnsupportedOperationException("Not implemented")
+
+ override val signalOptions: Set<String>
+ get() = throw UnsupportedOperationException("Not implemented")
+
+ override fun notifyResized(width: Int, height: Int) {}
+
+ override fun notifyZOrderChanged(isZOrderOnTop: Boolean) {}
+
+ override fun notifyConfigurationChanged(configuration: Configuration) {}
+
+ override fun notifyUiChanged(uiContainerInfo: Bundle) {}
+
+ override fun close() {
+ closed = true
+ }
+ }
+}
diff --git a/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/BinderAdapterDelegate.kt b/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/BinderAdapterDelegate.kt
index ecb4e64..fff6cad6 100644
--- a/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/BinderAdapterDelegate.kt
+++ b/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/BinderAdapterDelegate.kt
@@ -38,6 +38,7 @@
import androidx.privacysandbox.ui.core.SessionObserver
import androidx.privacysandbox.ui.core.SessionObserverContext
import androidx.privacysandbox.ui.core.SessionObserverFactory
+import androidx.privacysandbox.ui.provider.impl.DeferredSessionClient
import java.util.concurrent.Executor
/**
@@ -87,7 +88,6 @@
override fun removeObserverFactory(sessionObserverFactory: SessionObserverFactory) {}
- @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
override fun openRemoteSession(
windowInputToken: IBinder,
displayId: Int,
@@ -96,60 +96,85 @@
isZOrderOnTop: Boolean,
remoteSessionClient: IRemoteSessionClient
) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ remoteSessionClient.onRemoteSessionError("openRemoteSession() requires API34+")
+ return
+ }
+
val mHandler = Handler(Looper.getMainLooper())
mHandler.post {
try {
- val mDisplayManager: DisplayManager =
+ val displayManager =
sandboxContext.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
- val windowContext =
- sandboxContext.createDisplayContext(mDisplayManager.getDisplay(displayId))
- val surfaceControlViewHost =
- CompatImpl.createSurfaceControlViewHost(
- windowContext,
- mDisplayManager.getDisplay(displayId),
- windowInputToken
+ val display = displayManager.getDisplay(displayId)
+ val displayContext = sandboxContext.createDisplayContext(display)
+
+ val deferredClient =
+ DeferredSessionClient.create(
+ clientFactory = {
+ Api34PlusImpl.createSessionClientProxy(
+ displayContext,
+ display,
+ windowInputToken,
+ isZOrderOnTop,
+ remoteSessionClient
+ )
+ },
+ clientInit = { it.initialize(initialWidth, initialHeight) },
+ errorHandler = { remoteSessionClient.onRemoteSessionError(it.message) }
)
- checkNotNull(surfaceControlViewHost) {
- "SurfaceControlViewHost must be available when provider is remote"
- }
- val sessionClient =
- SessionClientProxy(
- surfaceControlViewHost,
- initialWidth,
- initialHeight,
- isZOrderOnTop,
- remoteSessionClient
- )
+
openSession(
- windowContext,
+ displayContext,
windowInputToken,
initialWidth,
initialHeight,
isZOrderOnTop,
- Runnable::run,
- sessionClient
+ MainThreadExecutor(mHandler),
+ deferredClient
)
+
+ deferredClient.preloadClient()
} catch (exception: Throwable) {
remoteSessionClient.onRemoteSessionError(exception.message)
}
}
}
+ /** Avoiding all potential concurrency issues by executing callback only on main thread. */
+ private class MainThreadExecutor(private val mainHandler: Handler) : Executor {
+ override fun execute(command: Runnable) {
+ if (Looper.getMainLooper() == Looper.myLooper()) {
+ command.run()
+ } else {
+ mainHandler.post(command)
+ }
+ }
+ }
+
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
- private inner class SessionClientProxy(
+ private class SessionClientProxy(
+ private val touchTransferringView: TouchFocusTransferringView,
private val surfaceControlViewHost: SurfaceControlViewHost,
- private val initialWidth: Int,
- private val initialHeight: Int,
private val isZOrderOnTop: Boolean,
private val remoteSessionClient: IRemoteSessionClient
) : SandboxedUiAdapter.SessionClient {
+ /**
+ * Split SurfaceControlViewHost creation and calling setView() into 2 steps to minimize each
+ * step duration and interference with actual openSession() logic (reduce potential delays).
+ */
+ fun initialize(initialWidth: Int, initialHeight: Int) {
+ surfaceControlViewHost.setView(touchTransferringView, initialWidth, initialHeight)
+ }
+
override fun onSessionOpened(session: SandboxedUiAdapter.Session) {
val view = session.view
- val touchTransferringView =
- TouchFocusTransferringView(sandboxContext, surfaceControlViewHost)
+
+ if (touchTransferringView.childCount > 0) {
+ touchTransferringView.removeAllViews()
+ }
touchTransferringView.addView(view)
- surfaceControlViewHost.setView(touchTransferringView, initialWidth, initialHeight)
// This var is not locked as it will be set to false by the first event that can trigger
// sending the remote session opened callback.
@@ -294,37 +319,25 @@
}
}
- /**
- * Provides backward compat support for APIs.
- *
- * If the API is available, it's called from a version-specific static inner class gated with
- * version check, otherwise a fallback action is taken depending on the situation.
- */
- private object CompatImpl {
-
- fun createSurfaceControlViewHost(
- context: Context,
+ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+ private object Api34PlusImpl {
+ fun createSessionClientProxy(
+ displayContext: Context,
display: Display,
- hostToken: IBinder
- ): SurfaceControlViewHost? {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
- return Api34PlusImpl.createSurfaceControlViewHost(context, display, hostToken)
- } else {
- null
- }
- }
-
- @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
- private object Api34PlusImpl {
-
- @JvmStatic
- fun createSurfaceControlViewHost(
- context: Context,
- display: Display,
- hostToken: IBinder
- ): SurfaceControlViewHost {
- return SurfaceControlViewHost(context, display, hostToken)
- }
+ windowInputToken: IBinder,
+ isZOrderOnTop: Boolean,
+ remoteSessionClient: IRemoteSessionClient
+ ): SessionClientProxy {
+ val surfaceControlViewHost =
+ SurfaceControlViewHost(displayContext, display, windowInputToken)
+ val touchTransferringView =
+ TouchFocusTransferringView(displayContext, surfaceControlViewHost)
+ return SessionClientProxy(
+ touchTransferringView,
+ surfaceControlViewHost,
+ isZOrderOnTop,
+ remoteSessionClient
+ )
}
}
}
diff --git a/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/impl/DeferredObjectHolder.kt b/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/impl/DeferredObjectHolder.kt
new file mode 100644
index 0000000..25fabfa
--- /dev/null
+++ b/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/impl/DeferredObjectHolder.kt
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.privacysandbox.ui.provider.impl
+
+import android.os.Build
+import android.os.Looper
+import android.os.MessageQueue.IdleHandler
+import androidx.annotation.RequiresApi
+import androidx.core.util.Consumer
+import androidx.core.util.Supplier
+import org.jetbrains.annotations.TestOnly
+
+/**
+ * Tries to postpone object creation/initialization until Idle state.
+ *
+ * Uses [objectFactory] to create object and [objectInit] to initialize it later. In case of
+ * creation/initialization failure reports error to [errorHandler] and uses [errorObject] as return
+ * value for [demandObject]
+ *
+ * @param objectFactory Creates uninitialized object. Called only once.
+ * @param objectInit Initialize object created by [objectFactory]. Called only once.
+ * @param errorHandler Handler for error during creation/initialization. Called only once.
+ * @param errorObject Return value for [demandObject] in case of creation/initialization errors.
+ */
+@RequiresApi(Build.VERSION_CODES.M)
+internal class DeferredObjectHolder<BaseClass : Any, ImplementationClass : BaseClass>(
+ private val objectFactory: Supplier<ImplementationClass>,
+ private val objectInit: Consumer<ImplementationClass>,
+ private val errorHandler: Consumer<Throwable>,
+ private val errorObject: BaseClass
+) : ObjectHolder<BaseClass> {
+
+ private lateinit var impl: ImplementationClass
+ private var state: ObjectState = ObjectState.NONE
+
+ override fun demandObject(): BaseClass {
+ createObjectIfNeeded()
+ initializeObjectIfNeeded()
+ return if (state == ObjectState.INITIALIZED) {
+ impl
+ } else {
+ errorObject
+ }
+ }
+
+ /**
+ * Schedule object preloading in upcoming Idles.
+ *
+ * Creates object in a first Idle and tries to initialize it in one of subsequent Idles
+ * (depending on how busy/utilized main thread).
+ *
+ * This combination provides good balance between Idle time utilization and interference with
+ * other tasks scheduled for main thread.
+ *
+ * Although checking (or not) Idle each time could give better utilization for some use cases,
+ * using combination of 2 different approaches should give better average results for more
+ * scenarios by covering weak points of single approaches.
+ */
+ override fun preloadObject() {
+ preloadObject(AndroidMessageQueue)
+ }
+
+ @TestOnly
+ fun preloadObject(messageQueue: MessageQueue) {
+ if (state.isFinalState()) {
+ // Already initialized or failed - no further steps required.
+ return
+ }
+ messageQueue.addIdleHandler {
+ createObjectIfNeeded()
+
+ if (messageQueue.isIdle()) {
+ initializeObjectIfNeeded()
+ }
+
+ return@addIdleHandler !state.isFinalState()
+ }
+ }
+
+ private fun createObjectIfNeeded() = tryWithErrorHandling {
+ if (state == ObjectState.NONE) {
+ impl = objectFactory.get()
+ state = ObjectState.CREATED
+ }
+ }
+
+ private fun initializeObjectIfNeeded() = tryWithErrorHandling {
+ if (state == ObjectState.CREATED) {
+ objectInit.accept(impl)
+ state = ObjectState.INITIALIZED
+ }
+ }
+
+ private inline fun tryWithErrorHandling(function: () -> Unit) {
+ try {
+ function()
+ } catch (exception: Throwable) {
+ state = ObjectState.FAILED
+ errorHandler.accept(exception)
+ }
+ }
+
+ private enum class ObjectState {
+ NONE,
+ CREATED,
+ INITIALIZED,
+ FAILED;
+
+ fun isFinalState(): Boolean {
+ return this == INITIALIZED || this == FAILED
+ }
+ }
+
+ interface MessageQueue {
+ fun addIdleHandler(handler: IdleHandler)
+
+ fun isIdle(): Boolean
+ }
+
+ private object AndroidMessageQueue : MessageQueue {
+ override fun addIdleHandler(handler: IdleHandler) = Looper.myQueue().addIdleHandler(handler)
+
+ override fun isIdle() = Looper.myQueue().isIdle
+ }
+}
diff --git a/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/impl/DeferredSessionClient.kt b/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/impl/DeferredSessionClient.kt
new file mode 100644
index 0000000..2a65bb9
--- /dev/null
+++ b/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/impl/DeferredSessionClient.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.privacysandbox.ui.provider.impl
+
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.util.Consumer
+import androidx.core.util.Supplier
+import androidx.privacysandbox.ui.core.SandboxedUiAdapter
+
+/**
+ * Placeholder client passed to [SandboxedUiAdapter.openSession] before actual client will be
+ * created.
+ *
+ * If adapter uses background threads, calling openSession() earlier could improve latency by
+ * scheduling provider tasks earlier and creating actual client while waiting for results.
+ *
+ * Using [DeferredObjectHolder] to create actual client in background.
+ */
+@RequiresApi(Build.VERSION_CODES.M)
+internal class DeferredSessionClient(
+ private val objectHolder: ObjectHolder<SandboxedUiAdapter.SessionClient>
+) : SandboxedUiAdapter.SessionClient {
+
+ override fun onSessionOpened(session: SandboxedUiAdapter.Session) {
+ objectHolder.demandObject().onSessionOpened(session)
+ }
+
+ override fun onSessionError(throwable: Throwable) {
+ objectHolder.demandObject().onSessionError(throwable)
+ }
+
+ override fun onResizeRequested(width: Int, height: Int) {
+ objectHolder.demandObject().onResizeRequested(width, height)
+ }
+
+ fun preloadClient() = objectHolder.preloadObject()
+
+ companion object {
+ private const val TAG = "DeferredSessionClient"
+
+ fun <T : SandboxedUiAdapter.SessionClient> create(
+ clientFactory: Supplier<T>,
+ clientInit: Consumer<T>,
+ errorHandler: Consumer<Throwable>
+ ): DeferredSessionClient {
+ return DeferredSessionClient(
+ DeferredObjectHolder(
+ objectFactory = clientFactory,
+ objectInit = clientInit,
+ errorHandler = {
+ Log.e(TAG, "Exception during actual client initialization", it)
+ errorHandler.accept(it)
+ },
+ FailClient
+ )
+ )
+ }
+
+ private object FailClient : SandboxedUiAdapter.SessionClient {
+ override fun onSessionOpened(session: SandboxedUiAdapter.Session) {
+ Log.w(TAG, "Auto-closing session on actual client initialization error")
+ session.close()
+ }
+
+ override fun onSessionError(throwable: Throwable) {}
+
+ override fun onResizeRequested(width: Int, height: Int) {}
+ }
+ }
+}
diff --git a/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/impl/ObjectHolder.kt b/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/impl/ObjectHolder.kt
new file mode 100644
index 0000000..436ea5b
--- /dev/null
+++ b/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/impl/ObjectHolder.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.privacysandbox.ui.provider.impl
+
+/** Interface for object holder that could create object on demand. */
+internal interface ObjectHolder<T> {
+ fun demandObject(): T
+
+ fun preloadObject()
+}
diff --git a/privacysandbox/ui/ui-tests/src/androidTest/java/androidx/privacysandbox/ui/tests/endtoend/IntegrationTests.kt b/privacysandbox/ui/ui-tests/src/androidTest/java/androidx/privacysandbox/ui/tests/endtoend/IntegrationTests.kt
index 7e17123..921c73c 100644
--- a/privacysandbox/ui/ui-tests/src/androidTest/java/androidx/privacysandbox/ui/tests/endtoend/IntegrationTests.kt
+++ b/privacysandbox/ui/ui-tests/src/androidTest/java/androidx/privacysandbox/ui/tests/endtoend/IntegrationTests.kt
@@ -265,8 +265,8 @@
activityScenarioRule.withActivity {
testSession.sessionClient.onResizeRequested(newWidth, newHeight)
}
- assertWithMessage("Resized height").that(testSession.resizedWidth).isEqualTo(newWidth)
- assertWithMessage("Resized width").that(testSession.resizedHeight).isEqualTo(newHeight)
+ assertWithMessage("Resized width").that(testSession.resizedWidth).isEqualTo(newWidth)
+ assertWithMessage("Resized height").that(testSession.resizedHeight).isEqualTo(newHeight)
testSession.assertResizeOccurred(
/* expectedWidth=*/ newWidth,
/* expectedHeight=*/ newHeight
diff --git a/wear/compose/compose-foundation/api/current.txt b/wear/compose/compose-foundation/api/current.txt
index 5cc082e..85f9661 100644
--- a/wear/compose/compose-foundation/api/current.txt
+++ b/wear/compose/compose-foundation/api/current.txt
@@ -604,8 +604,8 @@
}
public final class PagerKt {
- method @androidx.compose.runtime.Composable public static void HorizontalPager(androidx.wear.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional int beyondViewportPageCount, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, optional @FloatRange(from=0.0, to=1.0) float swipeToDismissEdgeZoneFraction, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
- method @androidx.compose.runtime.Composable public static void VerticalPager(androidx.wear.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional int beyondViewportPageCount, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void HorizontalPager(androidx.wear.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional int beyondViewportPageCount, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, optional @FloatRange(from=0.0, to=1.0) float swipeToDismissEdgeZoneFraction, optional androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior? rotaryScrollableBehavior, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void VerticalPager(androidx.wear.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional int beyondViewportPageCount, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, optional androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior? rotaryScrollableBehavior, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
}
public abstract class PagerState extends androidx.compose.foundation.pager.PagerState {
@@ -630,6 +630,7 @@
method @androidx.compose.runtime.Composable public androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior behavior(androidx.compose.foundation.gestures.ScrollableState scrollableState, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional boolean hapticFeedbackEnabled);
method @androidx.compose.runtime.Composable public androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior snapBehavior(androidx.compose.foundation.gestures.ScrollableState scrollableState, androidx.wear.compose.foundation.rotary.RotarySnapLayoutInfoProvider layoutInfoProvider, optional float snapOffset, optional boolean hapticFeedbackEnabled);
method @androidx.compose.runtime.Composable public androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior snapBehavior(androidx.wear.compose.foundation.lazy.ScalingLazyListState scrollableState, optional float snapOffset, optional boolean hapticFeedbackEnabled);
+ method @androidx.compose.runtime.Composable public androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior snapBehavior(androidx.wear.compose.foundation.pager.PagerState pagerState, optional float snapOffset, optional boolean hapticFeedbackEnabled);
field public static final androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults INSTANCE;
}
diff --git a/wear/compose/compose-foundation/api/restricted_current.txt b/wear/compose/compose-foundation/api/restricted_current.txt
index 5cc082e..85f9661 100644
--- a/wear/compose/compose-foundation/api/restricted_current.txt
+++ b/wear/compose/compose-foundation/api/restricted_current.txt
@@ -604,8 +604,8 @@
}
public final class PagerKt {
- method @androidx.compose.runtime.Composable public static void HorizontalPager(androidx.wear.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional int beyondViewportPageCount, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, optional @FloatRange(from=0.0, to=1.0) float swipeToDismissEdgeZoneFraction, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
- method @androidx.compose.runtime.Composable public static void VerticalPager(androidx.wear.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional int beyondViewportPageCount, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void HorizontalPager(androidx.wear.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional int beyondViewportPageCount, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, optional @FloatRange(from=0.0, to=1.0) float swipeToDismissEdgeZoneFraction, optional androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior? rotaryScrollableBehavior, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void VerticalPager(androidx.wear.compose.foundation.pager.PagerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional int beyondViewportPageCount, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional boolean userScrollEnabled, optional boolean reverseLayout, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,?>? key, optional androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior? rotaryScrollableBehavior, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
}
public abstract class PagerState extends androidx.compose.foundation.pager.PagerState {
@@ -630,6 +630,7 @@
method @androidx.compose.runtime.Composable public androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior behavior(androidx.compose.foundation.gestures.ScrollableState scrollableState, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional boolean hapticFeedbackEnabled);
method @androidx.compose.runtime.Composable public androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior snapBehavior(androidx.compose.foundation.gestures.ScrollableState scrollableState, androidx.wear.compose.foundation.rotary.RotarySnapLayoutInfoProvider layoutInfoProvider, optional float snapOffset, optional boolean hapticFeedbackEnabled);
method @androidx.compose.runtime.Composable public androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior snapBehavior(androidx.wear.compose.foundation.lazy.ScalingLazyListState scrollableState, optional float snapOffset, optional boolean hapticFeedbackEnabled);
+ method @androidx.compose.runtime.Composable public androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior snapBehavior(androidx.wear.compose.foundation.pager.PagerState pagerState, optional float snapOffset, optional boolean hapticFeedbackEnabled);
field public static final androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults INSTANCE;
}
diff --git a/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/PagerTest.kt b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/PagerTest.kt
index 4446e58..6e76ce7 100644
--- a/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/PagerTest.kt
+++ b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/PagerTest.kt
@@ -17,27 +17,44 @@
package androidx.wear.compose.foundation
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.RotaryInjectionScope
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performRotaryScrollInput
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.test.swipeRight
import androidx.compose.ui.test.swipeUp
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.pager.HorizontalPager
import androidx.wear.compose.foundation.pager.PagerState
import androidx.wear.compose.foundation.pager.VerticalPager
import androidx.wear.compose.foundation.pager.rememberPagerState
+import androidx.wear.compose.foundation.rotary.MockRotaryResolution
+import androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior
+import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults
+import androidx.wear.compose.foundation.rotary.RotarySnapSensitivity
+import androidx.wear.compose.foundation.rotary.rotaryScrollable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.junit.Assert
+import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -45,8 +62,14 @@
@get:Rule val rule = createComposeRule()
private val pagerTestTag = "Pager"
+ private var lcItemSizePx: Float = 20f
+ private var lcItemSizeDp: Dp = Dp.Infinity
- @OptIn(ExperimentalWearFoundationApi::class)
+ @Before
+ fun before() {
+ with(rule.density) { lcItemSizeDp = lcItemSizePx.toDp() }
+ }
+
@Test
fun horizontal_pager_with_nested_scaling_lazy_column_swipes_along_one_page_at_a_time() {
val pageCount = 5
@@ -238,4 +261,357 @@
verifyScrollsToEachPage(pageCount, pagerState, scrollScope)
}
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun vertical_pager_scrolled_by_2_pages_with_rotary_high_res() {
+ verticalPagerRotaryScrolledBy(
+ lowRes = false,
+ userScrollEnabled = true,
+ rotaryScrollableBehavior = { RotaryScrollableDefaults.snapBehavior(it) },
+ rotaryScrollInput = { pagerState ->
+ for (i in 0..1) {
+ rotateToScrollVertically(
+ pagerState.layoutInfo.pageSize.toFloat() /
+ RotarySnapSensitivity.HIGH.minThresholdDivider + 1
+ )
+ advanceEventTime(100)
+ }
+ },
+ expectedPageTarget = 2
+ )
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun vertical_pager_scrolled_by_2_pages_with_rotary_lowRes() {
+ verticalPagerRotaryScrolledBy(
+ lowRes = true,
+ userScrollEnabled = true,
+ rotaryScrollableBehavior = { RotaryScrollableDefaults.snapBehavior(it) },
+ rotaryScrollInput = {
+ for (i in 0..1) {
+ rotateToScrollVertically(100f)
+ advanceEventTime(100)
+ }
+ },
+ expectedPageTarget = 2
+ )
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun vertical_pager_not_rotary_scrolled_with_disabled_userScrolledEnabled() {
+ verticalPagerRotaryScrolledBy(
+ lowRes = false,
+ userScrollEnabled = false,
+ rotaryScrollableBehavior = { RotaryScrollableDefaults.snapBehavior(it) },
+ rotaryScrollInput = { pagerState ->
+ rotateToScrollVertically(
+ pagerState.layoutInfo.pageSize.toFloat() /
+ RotarySnapSensitivity.HIGH.minThresholdDivider + 1
+ )
+ },
+ expectedPageTarget = 0
+ )
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun vertical_pager_not_rotary_scrolled_without_rotaryScrollableBehavior() {
+ verticalPagerRotaryScrolledBy(
+ lowRes = false,
+ userScrollEnabled = true,
+ rotaryScrollableBehavior = { null },
+ rotaryScrollInput = { pagerState ->
+ rotateToScrollVertically(
+ pagerState.layoutInfo.pageSize.toFloat() /
+ RotarySnapSensitivity.HIGH.minThresholdDivider + 1
+ )
+ },
+ expectedPageTarget = 0
+ )
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun horizontal_pager_scrolled_by_2_pages_with_rotary_high_res() {
+ horizontalPagerRotaryScrolledBy(
+ lowRes = false,
+ userScrollEnabled = true,
+ rotaryScrollableBehavior = { RotaryScrollableDefaults.snapBehavior(it) },
+ rotaryScrollInput = { pagerState ->
+ for (i in 0..1) {
+ rotateToScrollVertically(
+ pagerState.layoutInfo.pageSize.toFloat() /
+ RotarySnapSensitivity.HIGH.minThresholdDivider + 1
+ )
+ advanceEventTime(100)
+ }
+ },
+ expectedPageTarget = 2
+ )
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun horizontal_pager_scrolled_by_2_pages_with_rotary_lowRes() {
+ horizontalPagerRotaryScrolledBy(
+ lowRes = true,
+ userScrollEnabled = true,
+ rotaryScrollableBehavior = { RotaryScrollableDefaults.snapBehavior(it) },
+ rotaryScrollInput = {
+ for (i in 0..1) {
+ rotateToScrollVertically(100f)
+ advanceEventTime(100)
+ }
+ },
+ expectedPageTarget = 2
+ )
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun horizontal_pager_not_rotary_scrolled_with_disabled_userScrolledEnabled() {
+ horizontalPagerRotaryScrolledBy(
+ lowRes = false,
+ userScrollEnabled = false,
+ rotaryScrollableBehavior = { RotaryScrollableDefaults.snapBehavior(it) },
+ rotaryScrollInput = { pagerState ->
+ rotateToScrollVertically(
+ pagerState.layoutInfo.pageSize.toFloat() /
+ RotarySnapSensitivity.HIGH.minThresholdDivider + 1
+ )
+ },
+ expectedPageTarget = 0
+ )
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun horizontal_pager_not_rotary_scrolled_without_rotaryScrollableBehavior() {
+ horizontalPagerRotaryScrolledBy(
+ lowRes = false,
+ userScrollEnabled = true,
+ rotaryScrollableBehavior = { null },
+ rotaryScrollInput = { pagerState ->
+ rotateToScrollVertically(
+ pagerState.layoutInfo.pageSize.toFloat() /
+ RotarySnapSensitivity.HIGH.minThresholdDivider + 1
+ )
+ },
+ expectedPageTarget = 0
+ )
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun content_in_horizontalPager_rotary_scrolled_without_rotaryScrollableBehavior() {
+ lateinit var pagerState: PagerState
+ val pageCount = 5
+ lateinit var lcStates: MutableList<LazyListState>
+
+ rule.setContent {
+ pagerState = rememberPagerState { pageCount }
+ lcStates = MutableList(pageCount) { rememberLazyListState() }
+ MockRotaryResolution(lowRes = false) {
+ HorizontalPager(
+ modifier = Modifier.testTag(pagerTestTag).size(100.dp),
+ state = pagerState,
+ ) { page ->
+ DefaultLazyColumn(lcStates[page])
+ }
+ }
+ }
+
+ rule.onNodeWithTag(pagerTestTag).performRotaryScrollInput {
+ rotateToScrollVertically(lcItemSizePx * 5)
+ }
+
+ // We expect HorizontalPager not to be scrolled and remain on the 0th page.
+ // At the same time a LazyColumn on this page should be scrolled.
+ rule.runOnIdle { Assert.assertEquals(0, pagerState.currentPage) }
+ rule.runOnIdle { Assert.assertEquals(5, lcStates[0].firstVisibleItemIndex) }
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun content_in_horizontalPager_not_rotary_scrolled_with_rotaryScrollableBehavior() {
+ lateinit var pagerState: PagerState
+ val pageCount = 5
+ lateinit var lcStates: MutableList<LazyListState>
+
+ rule.setContent {
+ pagerState = rememberPagerState { pageCount }
+ lcStates = MutableList(pageCount) { rememberLazyListState() }
+ MockRotaryResolution(lowRes = false) {
+ HorizontalPager(
+ modifier = Modifier.testTag(pagerTestTag).size(100.dp),
+ state = pagerState,
+ rotaryScrollableBehavior = RotaryScrollableDefaults.snapBehavior(pagerState)
+ ) { page ->
+ DefaultLazyColumn(lcStates[page])
+ }
+ }
+ }
+
+ rule.onNodeWithTag(pagerTestTag).performRotaryScrollInput {
+ rotateToScrollVertically(
+ pagerState.layoutInfo.pageSize.toFloat() /
+ RotarySnapSensitivity.HIGH.minThresholdDivider + 1
+ )
+ }
+
+ // We expect HorizontalPager to be scrolled by 1 page.
+ rule.runOnIdle { Assert.assertEquals(1, pagerState.currentPage) }
+ // At the same time LazyColumns shouldn't be scrolled.
+ for (lcState in lcStates) {
+ rule.runOnIdle { Assert.assertEquals(0, lcState.firstVisibleItemIndex) }
+ }
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun content_in_verticalPager_rotary_scrolled_without_rotaryScrollableBehavior() {
+ lateinit var pagerState: PagerState
+ val pageCount = 5
+ lateinit var lcStates: MutableList<LazyListState>
+
+ rule.setContent {
+ pagerState = rememberPagerState { pageCount }
+ lcStates = MutableList(pageCount) { rememberLazyListState() }
+ MockRotaryResolution(lowRes = false) {
+ VerticalPager(
+ modifier = Modifier.testTag(pagerTestTag).size(100.dp),
+ state = pagerState,
+ rotaryScrollableBehavior = null
+ ) { page ->
+ DefaultLazyColumn(lcStates[page])
+ }
+ }
+ }
+
+ rule.onNodeWithTag(pagerTestTag).performRotaryScrollInput {
+ rotateToScrollVertically(lcItemSizePx * 5)
+ }
+
+ // We expect VerticalPager not to be scrolled and remain on the 0th page.
+ // At the same time a LazyColumn on this page should be scrolled.
+ rule.runOnIdle { Assert.assertEquals(0, pagerState.currentPage) }
+ rule.runOnIdle { Assert.assertEquals(5, lcStates[0].firstVisibleItemIndex) }
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun content_in_verticalPager_not_rotary_scrolled_with_rotaryScrollableBehavior() {
+ lateinit var pagerState: PagerState
+ val pageCount = 5
+ lateinit var lcStates: MutableList<LazyListState>
+
+ rule.setContent {
+ pagerState = rememberPagerState { pageCount }
+ lcStates = MutableList(pageCount) { rememberLazyListState() }
+ MockRotaryResolution(lowRes = false) {
+ HorizontalPager(
+ modifier = Modifier.testTag(pagerTestTag).size(100.dp),
+ state = pagerState,
+ rotaryScrollableBehavior = RotaryScrollableDefaults.snapBehavior(pagerState)
+ ) { page ->
+ DefaultLazyColumn(lcStates[page])
+ }
+ }
+ }
+
+ rule.onNodeWithTag(pagerTestTag).performRotaryScrollInput {
+ rotateToScrollVertically(
+ pagerState.layoutInfo.pageSize.toFloat() /
+ RotarySnapSensitivity.HIGH.minThresholdDivider + 1
+ )
+ }
+
+ // We expect VerticalPager to be scrolled by 1 page.
+ rule.runOnIdle { Assert.assertEquals(1, pagerState.currentPage) }
+ // At the same time LazyColumns shouldn't be scrolled.
+ for (lcState in lcStates) {
+ rule.runOnIdle { Assert.assertEquals(0, lcState.firstVisibleItemIndex) }
+ }
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ private fun verticalPagerRotaryScrolledBy(
+ expectedPageTarget: Int,
+ lowRes: Boolean,
+ userScrollEnabled: Boolean,
+ rotaryScrollableBehavior: @Composable (pagerState: PagerState) -> RotaryScrollableBehavior?,
+ rotaryScrollInput: RotaryInjectionScope.(pagerState: PagerState) -> Unit
+ ) {
+ lateinit var pagerState: PagerState
+ val pageCount = 5
+
+ rule.setContent {
+ pagerState = rememberPagerState { pageCount }
+
+ MockRotaryResolution(lowRes = lowRes) {
+ VerticalPager(
+ modifier = Modifier.testTag(pagerTestTag),
+ state = pagerState,
+ userScrollEnabled = userScrollEnabled,
+ rotaryScrollableBehavior = rotaryScrollableBehavior(pagerState)
+ ) { page ->
+ BasicText(text = "Page $page")
+ }
+ }
+ }
+
+ rule.onNodeWithTag(pagerTestTag).performRotaryScrollInput { rotaryScrollInput(pagerState) }
+
+ rule.runOnIdle { Assert.assertEquals(expectedPageTarget, pagerState.currentPage) }
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ private fun horizontalPagerRotaryScrolledBy(
+ expectedPageTarget: Int,
+ lowRes: Boolean,
+ userScrollEnabled: Boolean,
+ rotaryScrollableBehavior: @Composable (pagerState: PagerState) -> RotaryScrollableBehavior?,
+ rotaryScrollInput: RotaryInjectionScope.(pagerState: PagerState) -> Unit
+ ) {
+ lateinit var pagerState: PagerState
+ val pageCount = 5
+
+ rule.setContent {
+ pagerState = rememberPagerState { pageCount }
+
+ MockRotaryResolution(lowRes = lowRes) {
+ HorizontalPager(
+ modifier = Modifier.testTag(pagerTestTag),
+ state = pagerState,
+ userScrollEnabled = userScrollEnabled,
+ rotaryScrollableBehavior = rotaryScrollableBehavior(pagerState)
+ ) { page ->
+ BasicText(text = "Page $page")
+ }
+ }
+ }
+
+ rule.onNodeWithTag(pagerTestTag).performRotaryScrollInput { rotaryScrollInput(pagerState) }
+
+ rule.runOnIdle { Assert.assertEquals(expectedPageTarget, pagerState.currentPage) }
+ }
+
+ @Composable
+ fun DefaultLazyColumn(state: LazyListState) {
+ LazyColumn(
+ state = state,
+ modifier =
+ Modifier.rotaryScrollable(
+ RotaryScrollableDefaults.behavior(state),
+ rememberActiveFocusRequester()
+ )
+ ) {
+ for (i in 0..20) {
+ item { BasicText(modifier = Modifier.height(lcItemSizeDp), text = "Page content") }
+ }
+ }
+ }
}
diff --git a/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/rotary/RotaryTest.kt b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/rotary/RotaryTest.kt
index 2ade292..42350ba 100644
--- a/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/rotary/RotaryTest.kt
+++ b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/rotary/RotaryTest.kt
@@ -257,20 +257,7 @@
rule.setContent {
state = rememberLazyListState()
- val context = LocalContext.current
-
- // Mocking low-res flag
- val mockContext = spy(context)
- val mockPackageManager = spy(context.packageManager)
- `when`(mockPackageManager.hasSystemFeature("android.hardware.rotaryencoder.lowres"))
- .thenReturn(lowRes)
-
- doReturn(mockPackageManager).`when`(mockContext).packageManager
-
- CompositionLocalProvider(
- LocalContext provides mockContext,
- LocalOverscrollConfiguration provides null
- ) {
+ MockRotaryResolution(lowRes = lowRes) {
DefaultLazyColumnItemsWithRotary(
itemSize = itemSizeDp,
scrollableState = state,
@@ -304,3 +291,24 @@
const val TEST_TAG = "test-tag"
}
}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+internal fun MockRotaryResolution(lowRes: Boolean = false, content: @Composable () -> Unit) {
+ val context = LocalContext.current
+
+ // Mocking low-res flag
+ val mockContext = spy(context)
+ val mockPackageManager = spy(context.packageManager)
+ `when`(mockPackageManager.hasSystemFeature("android.hardware.rotaryencoder.lowres"))
+ .thenReturn(lowRes)
+
+ doReturn(mockPackageManager).`when`(mockContext).packageManager
+
+ CompositionLocalProvider(
+ LocalContext provides mockContext,
+ LocalOverscrollConfiguration provides null
+ ) {
+ content()
+ }
+}
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/pager/Pager.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/pager/Pager.kt
index 680f343..2ab41d2 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/pager/Pager.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/pager/Pager.kt
@@ -52,6 +52,10 @@
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.HierarchicalFocusCoordinator
+import androidx.wear.compose.foundation.rememberActiveFocusRequester
+import androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior
+import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults
+import androidx.wear.compose.foundation.rotary.rotaryScrollable
import kotlinx.coroutines.coroutineScope
/**
@@ -88,6 +92,9 @@
* the leftmost 25% of the screen will trigger the gesture. Even when RTL mode is enabled, this
* parameter only ever applies to the left edge of the screen. Setting this to 0 will disable the
* gesture.
+ * @param rotaryScrollableBehavior Parameter for changing rotary behavior. By default rotary support
+ * is disabled for [HorizontalPager]. It can be enabled by passing
+ * [RotaryScrollableDefaults.snapBehavior] with pagerState parameter.
* @param content A composable function that defines the content of each page displayed by the
* Pager. This is where the UI elements that should appear within each page should be placed.
*/
@@ -103,6 +110,7 @@
key: ((index: Int) -> Any)? = null,
@FloatRange(from = 0.0, to = 1.0)
swipeToDismissEdgeZoneFraction: Float = PagerDefaults.SwipeToDismissEdgeZoneFraction,
+ rotaryScrollableBehavior: RotaryScrollableBehavior? = null,
content: @Composable PagerScope.(page: Int) -> Unit
) {
val swipeToDismissEnabled = swipeToDismissEdgeZoneFraction != 0f
@@ -117,6 +125,15 @@
if (swipeToDismissEnabled) originalTouchSlop * CustomTouchSlopMultiplier
else originalTouchSlop
) {
+ val rotaryModifier =
+ if (rotaryScrollableBehavior != null && userScrollEnabled)
+ Modifier.rotaryScrollable(
+ behavior = rotaryScrollableBehavior,
+ focusRequester = rememberActiveFocusRequester(),
+ reverseDirection = reverseLayout
+ )
+ else Modifier
+
HorizontalPager(
state = state,
modifier =
@@ -143,7 +160,8 @@
// signals system swipe to dismiss that it can take over
ScrollAxisRange(value = { 0f }, maxValue = { 0f })
}
- },
+ }
+ .then(rotaryModifier),
contentPadding = contentPadding,
pageSize = PageSize.Fill,
beyondViewportPageCount = beyondViewportPageCount,
@@ -156,7 +174,12 @@
snapPosition = SnapPosition.Start,
) { page ->
CustomTouchSlopProvider(newTouchSlop = originalTouchSlop) {
- FocusedPageContent(page = page, pagerState = state, content = { content(page) })
+ HierarchicalFocusCoordinator(
+ requiresFocus = {
+ rotaryScrollableBehavior == null && state.currentPage == page
+ },
+ content = { content(page) }
+ )
}
}
}
@@ -167,6 +190,10 @@
* standard Compose Foundation [VerticalPager] and provides Wear-specific enhancements to improve
* performance, usability, and adherence to Wear OS design guidelines.
*
+ * [VerticalPager] supports rotary input by default. Rotary input allows users to scroll through the
+ * pager's content - by using a crown or a rotating bezel on their Wear OS device. It can be
+ * modified or turned off using the [rotaryScrollableBehavior] parameter.
+ *
* Please refer to the sample to learn how to use this API.
*
* @sample androidx.wear.compose.foundation.samples.SimpleVerticalPagerSample
@@ -190,6 +217,9 @@
* position will be maintained based on the key, which means if you add/remove items before the
* current visible item the item with the given key will be kept as the first visible one. If null
* is passed the position in the list will represent the key.
+ * @param rotaryScrollableBehavior Parameter for changing rotary behavior. We recommend to use
+ * [RotaryScrollableDefaults.snapBehavior] with pagerState parameter. Passing null turns off the
+ * rotary handling if it is not required.
* @param content A composable function that defines the content of each page displayed by the
* Pager. This is where the UI elements that should appear within each page should be placed.
*/
@@ -203,11 +233,22 @@
userScrollEnabled: Boolean = true,
reverseLayout: Boolean = false,
key: ((index: Int) -> Any)? = null,
+ rotaryScrollableBehavior: RotaryScrollableBehavior? =
+ RotaryScrollableDefaults.snapBehavior(state),
content: @Composable PagerScope.(page: Int) -> Unit
) {
+ val rotaryModifier =
+ if (rotaryScrollableBehavior != null && userScrollEnabled)
+ Modifier.rotaryScrollable(
+ behavior = rotaryScrollableBehavior,
+ focusRequester = rememberActiveFocusRequester(),
+ reverseDirection = reverseLayout
+ )
+ else Modifier
+
VerticalPager(
state = state,
- modifier = modifier.fillMaxSize(),
+ modifier = modifier.fillMaxSize().then(rotaryModifier),
contentPadding = contentPadding,
pageSize = PageSize.Fill,
beyondViewportPageCount = beyondViewportPageCount,
@@ -219,7 +260,10 @@
key = key,
snapPosition = SnapPosition.Start,
) { page ->
- FocusedPageContent(page = page, pagerState = state, content = { content(page) })
+ HierarchicalFocusCoordinator(
+ requiresFocus = { rotaryScrollableBehavior == null && state.currentPage == page },
+ content = { content(page) }
+ )
}
}
@@ -282,18 +326,6 @@
}
@Composable
-internal fun FocusedPageContent(
- page: Int,
- pagerState: PagerState,
- content: @Composable () -> Unit
-) {
- HierarchicalFocusCoordinator(
- requiresFocus = { pagerState.currentPage == page },
- content = content
- )
-}
-
-@Composable
private fun CustomTouchSlopProvider(newTouchSlop: Float, content: @Composable () -> Unit) {
CompositionLocalProvider(
value =
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/RotaryScrollable.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/RotaryScrollable.kt
index 98e459b..b2fcee3 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/RotaryScrollable.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/RotaryScrollable.kt
@@ -50,6 +50,9 @@
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.ScalingLazyListState
import androidx.wear.compose.foundation.lazy.inverseLerp
+import androidx.wear.compose.foundation.pager.HorizontalPager
+import androidx.wear.compose.foundation.pager.PagerState
+import androidx.wear.compose.foundation.pager.VerticalPager
import androidx.wear.compose.foundation.rememberActiveFocusRequester
import kotlin.math.abs
import kotlin.math.absoluteValue
@@ -216,24 +219,14 @@
layoutInfoProvider: RotarySnapLayoutInfoProvider,
snapOffset: Dp = 0.dp,
hapticFeedbackEnabled: Boolean = true
- ): RotaryScrollableBehavior {
- val isLowRes = isLowResInput()
- val snapOffsetPx = with(LocalDensity.current) { snapOffset.roundToPx() }
- val rotaryHaptics: RotaryHapticHandler =
- rememberRotaryHapticHandler(scrollableState, hapticFeedbackEnabled)
-
- return remember(scrollableState, layoutInfoProvider, rotaryHaptics, snapOffset, isLowRes) {
- snapBehavior(
- scrollableState,
- layoutInfoProvider,
- rotaryHaptics,
- snapOffsetPx,
- ThresholdDivider,
- ResistanceFactor,
- isLowRes
- )
- }
- }
+ ): RotaryScrollableBehavior =
+ snapBehavior(
+ scrollableState = scrollableState,
+ layoutInfoProvider = layoutInfoProvider,
+ snapSensitivity = RotarySnapSensitivity.DEFAULT,
+ snapOffset = snapOffset,
+ hapticFeedbackEnabled = hapticFeedbackEnabled
+ )
/**
* Implementation of [RotaryScrollableBehavior] to define scrolling behaviour with snap for
@@ -259,9 +252,62 @@
ScalingLazyColumnRotarySnapLayoutInfoProvider(scrollableState)
},
snapOffset = snapOffset,
+ snapSensitivity = RotarySnapSensitivity.DEFAULT,
hapticFeedbackEnabled = hapticFeedbackEnabled
)
+ /**
+ * Implementation of [RotaryScrollableBehavior] to define scrolling behaviour with snap for
+ * [HorizontalPager] and [VerticalPager].
+ *
+ * @param pagerState [PagerState] to which rotary scroll will be connected.
+ * @param snapOffset An optional offset to be applied when snapping the item. Defines the
+ * distance from the center of the scrollable to the center of the snapped item.
+ * @param hapticFeedbackEnabled Controls whether haptic feedback is given during rotary
+ * scrolling (true by default). It's recommended to keep the default value of true for premium
+ * scrolling experience.
+ */
+ @Composable
+ fun snapBehavior(
+ pagerState: PagerState,
+ snapOffset: Dp = 0.dp,
+ hapticFeedbackEnabled: Boolean = true
+ ): RotaryScrollableBehavior {
+ return snapBehavior(
+ scrollableState = pagerState,
+ layoutInfoProvider =
+ remember(pagerState) { PagerRotarySnapLayoutInfoProvider(pagerState) },
+ snapSensitivity = RotarySnapSensitivity.HIGH,
+ snapOffset = snapOffset,
+ hapticFeedbackEnabled = hapticFeedbackEnabled
+ )
+ }
+
+ @Composable
+ private fun snapBehavior(
+ scrollableState: ScrollableState,
+ layoutInfoProvider: RotarySnapLayoutInfoProvider,
+ snapSensitivity: RotarySnapSensitivity,
+ snapOffset: Dp,
+ hapticFeedbackEnabled: Boolean
+ ): RotaryScrollableBehavior {
+ val isLowRes = isLowResInput()
+ val snapOffsetPx = with(LocalDensity.current) { snapOffset.roundToPx() }
+ val rotaryHaptics: RotaryHapticHandler =
+ rememberRotaryHapticHandler(scrollableState, hapticFeedbackEnabled)
+
+ return remember(scrollableState, layoutInfoProvider, rotaryHaptics, snapOffset, isLowRes) {
+ snapBehavior(
+ scrollableState,
+ layoutInfoProvider,
+ rotaryHaptics,
+ snapSensitivity,
+ snapOffsetPx,
+ isLowRes
+ )
+ }
+ }
+
/** Returns whether the input is Low-res (a bezel) or high-res (a crown/rsb). */
@Composable
private fun isLowResInput(): Boolean =
@@ -269,9 +315,6 @@
"android.hardware.rotaryencoder.lowres"
)
- private const val ThresholdDivider: Float = 1.5f
- private const val ResistanceFactor: Float = 3f
-
// These values represent the timeframe for a fling event. A bigger value is assigned
// to low-res input due to the lower frequency of low-res rotary events.
internal const val LowResFlingTimeframe: Long = 100L
@@ -303,6 +346,27 @@
get() = scrollableState.layoutInfo.totalItemsCount
}
+/** An implementation of rotary scroll adapter for Pager */
+internal class PagerRotarySnapLayoutInfoProvider(private val pagerState: PagerState) :
+ RotarySnapLayoutInfoProvider {
+
+ /** Calculates the average item height by just taking the pageSize. */
+ override val averageItemSize: Float
+ get() = pagerState.layoutInfo.pageSize.toFloat()
+
+ /** Current page */
+ override val currentItemIndex: Int
+ get() = pagerState.currentPage
+
+ /** The offset from the page center. */
+ override val currentItemOffset: Float
+ get() = pagerState.currentPageOffsetFraction * averageItemSize
+
+ /** The total count of items in Pager */
+ override val totalItemCount: Int
+ get() = pagerState.pageCount
+}
+
/**
* Handles scroll with fling.
*
@@ -353,9 +417,7 @@
* usage
* @param snapOffset An offset to be applied when snapping the item. After the snap the snapped
* items offset will be [snapOffset]. In pixels.
- * @param maxThresholdDivider Factor to divide item size when calculating threshold.
- * @param scrollDistanceDivider A value which is used to slow down or speed up the scroll before
- * snap happens. The higher the value the slower the scroll.
+ * @param snapSensitivity Sensitivity of the rotary snap.
* @param isLowRes Whether the input is Low-res (a bezel) or high-res(a crown/rsb)
* @return A snap implementation of [RotaryScrollableBehavior] which is either suitable for low-res
* or high-res input (see [Modifier.rotaryScrollable] for descriptions of low-res and high-res
@@ -365,9 +427,8 @@
scrollableState: ScrollableState,
layoutInfoProvider: RotarySnapLayoutInfoProvider,
rotaryHaptics: RotaryHapticHandler,
+ snapSensitivity: RotarySnapSensitivity,
snapOffset: Int,
- maxThresholdDivider: Float,
- scrollDistanceDivider: Float,
isLowRes: Boolean
): RotaryScrollableBehavior {
return if (isLowRes) {
@@ -384,10 +445,11 @@
} else {
HighResSnapRotaryScrollableBehavior(
rotaryHaptics = rotaryHaptics,
- scrollDistanceDivider = scrollDistanceDivider,
+ scrollDistanceDivider = snapSensitivity.resistanceFactor,
thresholdHandlerFactory = {
ThresholdHandler(
- maxThresholdDivider,
+ minThresholdDivider = snapSensitivity.minThresholdDivider,
+ maxThresholdDivider = snapSensitivity.maxThresholdDivider,
averageItemSize = { layoutInfoProvider.averageItemSize }
)
},
@@ -1061,7 +1123,9 @@
*/
internal class ThresholdHandler(
// Factor to divide item size when calculating threshold.
- // Depending on the speed, it dynamically varies in range 1..maxThresholdDivider
+ // Threshold is divided by a linear interpolation value between minThresholdDivider and
+ // maxThresholdDivider, based on the scrolling speed.
+ private val minThresholdDivider: Float,
private val maxThresholdDivider: Float,
// Min velocity for threshold calculation
private val minVelocity: Float = 300f,
@@ -1105,7 +1169,8 @@
)
// Calculate the final threshold size by dividing the average item size by a dynamically
// adjusted threshold divider.
- return averageItemSize() / lerp(1f, maxThresholdDivider, thresholdDividerFraction)
+ return averageItemSize() /
+ lerp(minThresholdDivider, maxThresholdDivider, thresholdDividerFraction)
}
/**
@@ -1213,6 +1278,28 @@
}
}
+/**
+ * Enum class representing the sensitivity of the rotary scroll.
+ *
+ * It defines two types of parameters that influence scroll behavior:
+ * - min/max thresholdDivider : these parameters reduce the scroll threshold based on the speed of
+ * rotary input, making the UI more responsive to both slow, deliberate rotations and fast flicks
+ * of the rotary.
+ * - resistanceFactor : Used to dampen the visual scroll effect. This allows the UI to scroll less
+ * than the actual input from the rotary device, providing a more controlled scrolling experience.
+ */
+internal enum class RotarySnapSensitivity(
+ val minThresholdDivider: Float,
+ val maxThresholdDivider: Float,
+ val resistanceFactor: Float,
+) {
+ // Default sensitivity
+ DEFAULT(1f, 1.5f, 3f),
+
+ // Used for full-screen pagers
+ HIGH(5f, 7.5f, 5f),
+}
+
/** Debug logging that can be enabled. */
private const val DEBUG = false
diff --git a/wear/compose/compose-foundation/src/test/kotlin/androidx/wear/compose/foundation/rotary/RotaryTest.kt b/wear/compose/compose-foundation/src/test/kotlin/androidx/wear/compose/foundation/rotary/RotaryTest.kt
index 666080e..cb5d03c 100644
--- a/wear/compose/compose-foundation/src/test/kotlin/androidx/wear/compose/foundation/rotary/RotaryTest.kt
+++ b/wear/compose/compose-foundation/src/test/kotlin/androidx/wear/compose/foundation/rotary/RotaryTest.kt
@@ -27,29 +27,42 @@
@Test
fun testMinVelocityThreshold() {
val itemHeight = 100f
- val thresholdHandler = ThresholdHandler(2.0f, averageItemSize = { itemHeight })
+ val minThresholdDivider = 1f
+ val maxThresholdDivider = 2f
+ val thresholdHandler =
+ ThresholdHandler(
+ minThresholdDivider = minThresholdDivider,
+ maxThresholdDivider = maxThresholdDivider,
+ averageItemSize = { itemHeight },
+ )
thresholdHandler.startThresholdTracking(0L)
// Simulate very slow scroll
thresholdHandler.updateTracking(100L, 1f)
val result = thresholdHandler.calculateSnapThreshold()
- // Threshold should be equal to the height of an item
- assertEquals(itemHeight, result, 0.01f)
+ // Threshold should be equal to the height of an item divided by minThresholdDivider
+ assertEquals(itemHeight / minThresholdDivider, result, 0.01f)
}
@Test
fun testMaxVelocityThreshold() {
val itemHeight = 100f
- val thresholdDivider = 2.0f
- val thresholdHandler = ThresholdHandler(thresholdDivider, averageItemSize = { itemHeight })
+ val minThresholdDivider = 1f
+ val maxThresholdDivider = 2f
+ val thresholdHandler =
+ ThresholdHandler(
+ minThresholdDivider = minThresholdDivider,
+ maxThresholdDivider = maxThresholdDivider,
+ averageItemSize = { itemHeight },
+ )
thresholdHandler.startThresholdTracking(0L)
// Simulate very fast scroll
thresholdHandler.updateTracking(1L, 100f)
val result = thresholdHandler.calculateSnapThreshold()
- // Threshold should be equal to the height of an item divided by threshold
- assertEquals(itemHeight / thresholdDivider, result, 0.01f)
+ // Threshold should be equal to the height of an item divided by maxThresholdDivider
+ assertEquals(itemHeight / maxThresholdDivider, result, 0.01f)
}
}
diff --git a/wear/compose/compose-material-core/src/main/java/androidx/wear/compose/materialcore/Icon.kt b/wear/compose/compose-material-core/src/main/java/androidx/wear/compose/materialcore/Icon.kt
index a52d381..b8e2346 100644
--- a/wear/compose/compose-material-core/src/main/java/androidx/wear/compose/materialcore/Icon.kt
+++ b/wear/compose/compose-material-core/src/main/java/androidx/wear/compose/materialcore/Icon.kt
@@ -46,9 +46,8 @@
* @param modifier Optional [Modifier] for this Icon
* @param tint Tint to be applied to [painter]. If [Color.Unspecified] is provided, then no tint is
* applied
- *
- * TODO:// Add link to Chip for a clickable icon
*/
+// TODO: Add link to Chip for a clickable icon
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@Composable
fun Icon(painter: Painter, contentDescription: String?, modifier: Modifier, tint: Color) {
diff --git a/wear/compose/compose-material3/api/current.txt b/wear/compose/compose-material3/api/current.txt
index 17e1f37..0ee8e16 100644
--- a/wear/compose/compose-material3/api/current.txt
+++ b/wear/compose/compose-material3/api/current.txt
@@ -2,12 +2,13 @@
package androidx.wear.compose.material3 {
public final class AlertDialogDefaults {
- method @androidx.compose.runtime.Composable public void BottomButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public void ConfirmButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public void DismissButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public void EdgeButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public void GroupSeparator();
method @androidx.compose.runtime.Composable public androidx.compose.foundation.layout.PaddingValues confirmDismissContentPadding();
- method @androidx.compose.runtime.Composable public androidx.compose.foundation.layout.PaddingValues contentPadding(boolean hasBottomButton);
+ method @androidx.compose.runtime.Composable public androidx.compose.foundation.layout.PaddingValues contentPadding();
+ method @androidx.compose.runtime.Composable public androidx.compose.foundation.layout.PaddingValues contentPaddingWithEdgeButton(optional androidx.wear.compose.material3.EdgeButtonSize edgeButtonSize);
method public kotlin.jvm.functions.Function1<androidx.compose.foundation.layout.RowScope,kotlin.Unit> getConfirmIcon();
method public kotlin.jvm.functions.Function1<androidx.compose.foundation.layout.RowScope,kotlin.Unit> getDismissIcon();
method public androidx.compose.foundation.layout.Arrangement.Vertical getVerticalArrangement();
@@ -18,7 +19,8 @@
}
public final class AlertDialogKt {
- method @androidx.compose.runtime.Composable public static void AlertDialog(boolean show, kotlin.jvm.functions.Function0<kotlin.Unit> onDismissRequest, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? bottomButton, kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? icon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? text, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.window.DialogProperties properties, optional kotlin.jvm.functions.Function1<? super androidx.wear.compose.foundation.lazy.ScalingLazyListScope,kotlin.Unit>? content);
+ method @androidx.compose.runtime.Composable public static void AlertDialog(boolean show, kotlin.jvm.functions.Function0<kotlin.Unit> onDismissRequest, kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? icon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? text, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.window.DialogProperties properties, optional kotlin.jvm.functions.Function1<? super androidx.wear.compose.foundation.lazy.ScalingLazyListScope,kotlin.Unit>? content);
+ method @androidx.compose.runtime.Composable public static void AlertDialog(boolean show, kotlin.jvm.functions.Function0<kotlin.Unit> onDismissRequest, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> edgeButton, kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? icon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? text, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.window.DialogProperties properties, optional kotlin.jvm.functions.Function1<? super androidx.wear.compose.foundation.lazy.ScalingLazyListScope,kotlin.Unit>? content);
method @androidx.compose.runtime.Composable public static void AlertDialog(boolean show, kotlin.jvm.functions.Function0<kotlin.Unit> onDismissRequest, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> confirmButton, kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> dismissButton, optional kotlin.jvm.functions.Function0<kotlin.Unit>? icon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? text, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.window.DialogProperties properties, optional kotlin.jvm.functions.Function1<? super androidx.wear.compose.foundation.lazy.ScalingLazyListScope,kotlin.Unit>? content);
}
@@ -83,10 +85,6 @@
method public androidx.compose.foundation.layout.PaddingValues getCompactButtonTapTargetPadding();
method public float getCompactButtonVerticalPadding();
method public androidx.compose.foundation.layout.PaddingValues getContentPadding();
- method public float getEdgeButtonHeightExtraSmall();
- method public float getEdgeButtonHeightLarge();
- method public float getEdgeButtonHeightMedium();
- method public float getEdgeButtonHeightSmall();
method public float getExtraLargeIconSize();
method public float getHeight();
method public float getIconSize();
@@ -109,10 +107,6 @@
property public final androidx.compose.foundation.layout.PaddingValues CompactButtonTapTargetPadding;
property public final float CompactButtonVerticalPadding;
property public final androidx.compose.foundation.layout.PaddingValues ContentPadding;
- property public final float EdgeButtonHeightExtraSmall;
- property public final float EdgeButtonHeightLarge;
- property public final float EdgeButtonHeightMedium;
- property public final float EdgeButtonHeightSmall;
property public final float ExtraLargeIconSize;
property public final float Height;
property public final float IconSize;
@@ -457,7 +451,22 @@
}
public final class EdgeButtonKt {
- method @androidx.compose.runtime.Composable public static void EdgeButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional float preferredHeight, optional boolean enabled, optional androidx.wear.compose.material3.ButtonColors colors, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void EdgeButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.material3.EdgeButtonSize buttonSize, optional boolean enabled, optional androidx.wear.compose.material3.ButtonColors colors, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ }
+
+ public final class EdgeButtonSize {
+ field public static final androidx.wear.compose.material3.EdgeButtonSize.Companion Companion;
+ }
+
+ public static final class EdgeButtonSize.Companion {
+ method public androidx.wear.compose.material3.EdgeButtonSize getExtraSmall();
+ method public androidx.wear.compose.material3.EdgeButtonSize getLarge();
+ method public androidx.wear.compose.material3.EdgeButtonSize getMedium();
+ method public androidx.wear.compose.material3.EdgeButtonSize getSmall();
+ property public final androidx.wear.compose.material3.EdgeButtonSize ExtraSmall;
+ property public final androidx.wear.compose.material3.EdgeButtonSize Large;
+ property public final androidx.wear.compose.material3.EdgeButtonSize Medium;
+ property public final androidx.wear.compose.material3.EdgeButtonSize Small;
}
@SuppressCompatibility @kotlin.RequiresOptIn(message="This Wear Material3 API is experimental and is likely to change or to be removed in" + " the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalWearMaterial3Api {
@@ -725,8 +734,8 @@
}
public final class PagerScaffoldKt {
- method @androidx.compose.runtime.Composable public static void HorizontalPagerScaffold(androidx.wear.compose.foundation.pager.PagerState pagerState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? pageIndicator, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float>? pageIndicatorAnimationSpec, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
- method @androidx.compose.runtime.Composable public static void VerticalPagerScaffold(androidx.wear.compose.foundation.pager.PagerState pagerState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? pageIndicator, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float>? pageIndicatorAnimationSpec, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void HorizontalPagerScaffold(androidx.wear.compose.foundation.pager.PagerState pagerState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? pageIndicator, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float>? pageIndicatorAnimationSpec, optional androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior? rotaryScrollableBehavior, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void VerticalPagerScaffold(androidx.wear.compose.foundation.pager.PagerState pagerState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? pageIndicator, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float>? pageIndicatorAnimationSpec, optional androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior? rotaryScrollableBehavior, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
}
public final class PickerDefaults {
@@ -901,6 +910,11 @@
method @androidx.compose.runtime.Stable public static androidx.compose.foundation.IndicationNodeFactory ripple(optional boolean bounded, optional float radius, optional long color);
}
+ public final class ScreenScaffoldDefaults {
+ method public androidx.compose.foundation.layout.PaddingValues contentPaddingWithEdgeButton(androidx.wear.compose.material3.EdgeButtonSize edgeButtonSize, optional float start, optional float top, optional float end, optional float extraBottom);
+ field public static final androidx.wear.compose.material3.ScreenScaffoldDefaults INSTANCE;
+ }
+
public final class ScreenScaffoldKt {
method @androidx.compose.runtime.Composable public static void ScreenScaffold(androidx.compose.foundation.lazy.LazyListState scrollState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? timeText, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? scrollIndicator, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? bottomButton, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public static void ScreenScaffold(androidx.compose.foundation.ScrollState scrollState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? timeText, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? scrollIndicator, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> content);
diff --git a/wear/compose/compose-material3/api/restricted_current.txt b/wear/compose/compose-material3/api/restricted_current.txt
index 17e1f37..0ee8e16 100644
--- a/wear/compose/compose-material3/api/restricted_current.txt
+++ b/wear/compose/compose-material3/api/restricted_current.txt
@@ -2,12 +2,13 @@
package androidx.wear.compose.material3 {
public final class AlertDialogDefaults {
- method @androidx.compose.runtime.Composable public void BottomButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public void ConfirmButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public void DismissButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public void EdgeButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public void GroupSeparator();
method @androidx.compose.runtime.Composable public androidx.compose.foundation.layout.PaddingValues confirmDismissContentPadding();
- method @androidx.compose.runtime.Composable public androidx.compose.foundation.layout.PaddingValues contentPadding(boolean hasBottomButton);
+ method @androidx.compose.runtime.Composable public androidx.compose.foundation.layout.PaddingValues contentPadding();
+ method @androidx.compose.runtime.Composable public androidx.compose.foundation.layout.PaddingValues contentPaddingWithEdgeButton(optional androidx.wear.compose.material3.EdgeButtonSize edgeButtonSize);
method public kotlin.jvm.functions.Function1<androidx.compose.foundation.layout.RowScope,kotlin.Unit> getConfirmIcon();
method public kotlin.jvm.functions.Function1<androidx.compose.foundation.layout.RowScope,kotlin.Unit> getDismissIcon();
method public androidx.compose.foundation.layout.Arrangement.Vertical getVerticalArrangement();
@@ -18,7 +19,8 @@
}
public final class AlertDialogKt {
- method @androidx.compose.runtime.Composable public static void AlertDialog(boolean show, kotlin.jvm.functions.Function0<kotlin.Unit> onDismissRequest, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? bottomButton, kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? icon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? text, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.window.DialogProperties properties, optional kotlin.jvm.functions.Function1<? super androidx.wear.compose.foundation.lazy.ScalingLazyListScope,kotlin.Unit>? content);
+ method @androidx.compose.runtime.Composable public static void AlertDialog(boolean show, kotlin.jvm.functions.Function0<kotlin.Unit> onDismissRequest, kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? icon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? text, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.window.DialogProperties properties, optional kotlin.jvm.functions.Function1<? super androidx.wear.compose.foundation.lazy.ScalingLazyListScope,kotlin.Unit>? content);
+ method @androidx.compose.runtime.Composable public static void AlertDialog(boolean show, kotlin.jvm.functions.Function0<kotlin.Unit> onDismissRequest, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> edgeButton, kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? icon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? text, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.window.DialogProperties properties, optional kotlin.jvm.functions.Function1<? super androidx.wear.compose.foundation.lazy.ScalingLazyListScope,kotlin.Unit>? content);
method @androidx.compose.runtime.Composable public static void AlertDialog(boolean show, kotlin.jvm.functions.Function0<kotlin.Unit> onDismissRequest, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> confirmButton, kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> dismissButton, optional kotlin.jvm.functions.Function0<kotlin.Unit>? icon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? text, optional androidx.compose.foundation.layout.Arrangement.Vertical verticalArrangement, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.window.DialogProperties properties, optional kotlin.jvm.functions.Function1<? super androidx.wear.compose.foundation.lazy.ScalingLazyListScope,kotlin.Unit>? content);
}
@@ -83,10 +85,6 @@
method public androidx.compose.foundation.layout.PaddingValues getCompactButtonTapTargetPadding();
method public float getCompactButtonVerticalPadding();
method public androidx.compose.foundation.layout.PaddingValues getContentPadding();
- method public float getEdgeButtonHeightExtraSmall();
- method public float getEdgeButtonHeightLarge();
- method public float getEdgeButtonHeightMedium();
- method public float getEdgeButtonHeightSmall();
method public float getExtraLargeIconSize();
method public float getHeight();
method public float getIconSize();
@@ -109,10 +107,6 @@
property public final androidx.compose.foundation.layout.PaddingValues CompactButtonTapTargetPadding;
property public final float CompactButtonVerticalPadding;
property public final androidx.compose.foundation.layout.PaddingValues ContentPadding;
- property public final float EdgeButtonHeightExtraSmall;
- property public final float EdgeButtonHeightLarge;
- property public final float EdgeButtonHeightMedium;
- property public final float EdgeButtonHeightSmall;
property public final float ExtraLargeIconSize;
property public final float Height;
property public final float IconSize;
@@ -457,7 +451,22 @@
}
public final class EdgeButtonKt {
- method @androidx.compose.runtime.Composable public static void EdgeButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional float preferredHeight, optional boolean enabled, optional androidx.wear.compose.material3.ButtonColors colors, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void EdgeButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.material3.EdgeButtonSize buttonSize, optional boolean enabled, optional androidx.wear.compose.material3.ButtonColors colors, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ }
+
+ public final class EdgeButtonSize {
+ field public static final androidx.wear.compose.material3.EdgeButtonSize.Companion Companion;
+ }
+
+ public static final class EdgeButtonSize.Companion {
+ method public androidx.wear.compose.material3.EdgeButtonSize getExtraSmall();
+ method public androidx.wear.compose.material3.EdgeButtonSize getLarge();
+ method public androidx.wear.compose.material3.EdgeButtonSize getMedium();
+ method public androidx.wear.compose.material3.EdgeButtonSize getSmall();
+ property public final androidx.wear.compose.material3.EdgeButtonSize ExtraSmall;
+ property public final androidx.wear.compose.material3.EdgeButtonSize Large;
+ property public final androidx.wear.compose.material3.EdgeButtonSize Medium;
+ property public final androidx.wear.compose.material3.EdgeButtonSize Small;
}
@SuppressCompatibility @kotlin.RequiresOptIn(message="This Wear Material3 API is experimental and is likely to change or to be removed in" + " the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalWearMaterial3Api {
@@ -725,8 +734,8 @@
}
public final class PagerScaffoldKt {
- method @androidx.compose.runtime.Composable public static void HorizontalPagerScaffold(androidx.wear.compose.foundation.pager.PagerState pagerState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? pageIndicator, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float>? pageIndicatorAnimationSpec, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
- method @androidx.compose.runtime.Composable public static void VerticalPagerScaffold(androidx.wear.compose.foundation.pager.PagerState pagerState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? pageIndicator, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float>? pageIndicatorAnimationSpec, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void HorizontalPagerScaffold(androidx.wear.compose.foundation.pager.PagerState pagerState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? pageIndicator, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float>? pageIndicatorAnimationSpec, optional androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior? rotaryScrollableBehavior, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void VerticalPagerScaffold(androidx.wear.compose.foundation.pager.PagerState pagerState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? pageIndicator, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float>? pageIndicatorAnimationSpec, optional androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior? rotaryScrollableBehavior, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.pager.PagerScope,? super java.lang.Integer,kotlin.Unit> content);
}
public final class PickerDefaults {
@@ -901,6 +910,11 @@
method @androidx.compose.runtime.Stable public static androidx.compose.foundation.IndicationNodeFactory ripple(optional boolean bounded, optional float radius, optional long color);
}
+ public final class ScreenScaffoldDefaults {
+ method public androidx.compose.foundation.layout.PaddingValues contentPaddingWithEdgeButton(androidx.wear.compose.material3.EdgeButtonSize edgeButtonSize, optional float start, optional float top, optional float end, optional float extraBottom);
+ field public static final androidx.wear.compose.material3.ScreenScaffoldDefaults INSTANCE;
+ }
+
public final class ScreenScaffoldKt {
method @androidx.compose.runtime.Composable public static void ScreenScaffold(androidx.compose.foundation.lazy.LazyListState scrollState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? timeText, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? scrollIndicator, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? bottomButton, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> content);
method @androidx.compose.runtime.Composable public static void ScreenScaffold(androidx.compose.foundation.ScrollState scrollState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? timeText, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit>? scrollIndicator, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> content);
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/AlertDialogs.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/AlertDialogs.kt
index c8618c6..59d5d83 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/AlertDialogs.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/AlertDialogs.kt
@@ -52,13 +52,13 @@
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.SwitchButton
import androidx.wear.compose.material3.Text
-import androidx.wear.compose.material3.samples.AlertDialogWithBottomButtonSample
import androidx.wear.compose.material3.samples.AlertDialogWithConfirmAndDismissSample
import androidx.wear.compose.material3.samples.AlertDialogWithContentGroupsSample
+import androidx.wear.compose.material3.samples.AlertDialogWithEdgeButtonSample
val AlertDialogs =
listOf(
- ComposableDemo("Bottom button") { AlertDialogWithBottomButtonSample() },
+ ComposableDemo("Edge button") { AlertDialogWithEdgeButtonSample() },
ComposableDemo("Confirm and Dismiss") { AlertDialogWithConfirmAndDismissSample() },
ComposableDemo("Content groups") { AlertDialogWithContentGroupsSample() },
ComposableDemo("Button stack") { AlertDialogWithButtonStack() },
@@ -73,7 +73,7 @@
var showMessage by remember { mutableStateOf(false) }
var showSecondaryButton by remember { mutableStateOf(false) }
var showCaption by remember { mutableStateOf(false) }
- var buttonsType by remember { mutableStateOf(AlertButtonsType.BOTTOM_BUTTON) }
+ var buttonsType by remember { mutableStateOf(AlertButtonsType.EDGE_BUTTON) }
var showDialog by remember { mutableStateOf(false) }
@@ -127,9 +127,9 @@
item {
RadioButton(
modifier = Modifier.fillMaxWidth(),
- selected = buttonsType == AlertButtonsType.BOTTOM_BUTTON,
- onSelect = { buttonsType = AlertButtonsType.BOTTOM_BUTTON },
- label = { Text("Single Bottom button") },
+ selected = buttonsType == AlertButtonsType.EDGE_BUTTON,
+ onSelect = { buttonsType = AlertButtonsType.EDGE_BUTTON },
+ label = { Text("Single EdgeButton") },
)
}
item {
@@ -145,7 +145,7 @@
modifier = Modifier.fillMaxWidth(),
selected = buttonsType == AlertButtonsType.NO_BUTTONS,
onSelect = { buttonsType = AlertButtonsType.NO_BUTTONS },
- label = { Text("No bottom button") },
+ label = { Text("No EdgeButton") },
)
}
item { Button(onClick = { showDialog = true }, label = { Text("Show dialog") }) }
@@ -190,8 +190,7 @@
)
},
title = { Text("Allow access to your photos?") },
- text = { Text("Lerp ipsum dolor sit amet.") },
- bottomButton = null,
+ text = { Text("Lerp ipsum dolor sit amet.") }
) {
item {
Button(
@@ -258,8 +257,8 @@
if (buttonsType == AlertButtonsType.CONFIRM_DISMISS) {
{ /* dismiss action */ }
} else null,
- onBottomButton =
- if (buttonsType == AlertButtonsType.BOTTOM_BUTTON) {
+ onEdgeButton =
+ if (buttonsType == AlertButtonsType.EDGE_BUTTON) {
onConfirmButton
} else null,
content =
@@ -270,7 +269,7 @@
}
if (showCaption) {
item { Caption(captionHorizontalPadding) }
- if (buttonsType == AlertButtonsType.BOTTOM_BUTTON) {
+ if (buttonsType == AlertButtonsType.EDGE_BUTTON) {
item { AlertDialogDefaults.GroupSeparator() }
}
}
@@ -331,7 +330,7 @@
message: @Composable (() -> Unit)?,
onDismissButton: (() -> Unit)?,
onConfirmButton: (() -> Unit)?,
- onBottomButton: (() -> Unit)?,
+ onEdgeButton: (() -> Unit)?,
content: (ScalingLazyListScope.() -> Unit)?
) {
if (onConfirmButton != null && onDismissButton != null) {
@@ -347,7 +346,7 @@
dismissButton = { AlertDialogDefaults.DismissButton(onDismissButton) },
content = content
)
- } else
+ } else if (onEdgeButton != null) {
AlertDialog(
show = show,
onDismissRequest = onDismissRequest,
@@ -356,16 +355,25 @@
title = title,
icon = icon,
text = message,
- bottomButton =
- if (onBottomButton != null) {
- { AlertDialogDefaults.BottomButton(onBottomButton) }
- } else null,
+ edgeButton = { AlertDialogDefaults.EdgeButton(onEdgeButton) },
content = content
)
+ } else {
+ AlertDialog(
+ show = show,
+ onDismissRequest = onDismissRequest,
+ modifier = modifier,
+ properties = properties,
+ title = title,
+ icon = icon,
+ text = message,
+ content = content
+ )
+ }
}
private enum class AlertButtonsType {
NO_BUTTONS,
- BOTTOM_BUTTON,
+ EDGE_BUTTON,
CONFIRM_DISMISS
}
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/EdgeButtonDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/EdgeButtonDemo.kt
index ba4a754..b12498f 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/EdgeButtonDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/EdgeButtonDemo.kt
@@ -18,7 +18,6 @@
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -45,8 +44,10 @@
import androidx.wear.compose.material3.ButtonDefaults
import androidx.wear.compose.material3.Card
import androidx.wear.compose.material3.EdgeButton
+import androidx.wear.compose.material3.EdgeButtonSize
import androidx.wear.compose.material3.RadioButton
import androidx.wear.compose.material3.ScreenScaffold
+import androidx.wear.compose.material3.ScreenScaffoldDefaults
import androidx.wear.compose.material3.Text
import androidx.wear.compose.material3.TextButton
import androidx.wear.compose.material3.TextButtonDefaults
@@ -72,7 +73,7 @@
bottomButton = {
EdgeButton(
onClick = {},
- preferredHeight = ButtonDefaults.EdgeButtonHeightLarge,
+ buttonSize = EdgeButtonSize.Large,
colors = ButtonDefaults.buttonColors(containerColor = Color.DarkGray)
) {
Text(labels[selectedLabel.intValue], color = Color.White)
@@ -83,7 +84,14 @@
state = state,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp),
- contentPadding = PaddingValues(10.dp, 20.dp, 10.dp, 80.dp),
+ contentPadding =
+ ScreenScaffoldDefaults.contentPaddingWithEdgeButton(
+ edgeButtonSize = EdgeButtonSize.Medium,
+ 10.dp,
+ 20.dp,
+ 10.dp,
+ 10.dp
+ ),
horizontalAlignment = Alignment.CenterHorizontally
) {
items(labels.size) {
@@ -120,7 +128,7 @@
bottomButton = {
EdgeButton(
onClick = {},
- preferredHeight = ButtonDefaults.EdgeButtonHeightLarge,
+ buttonSize = EdgeButtonSize.Large,
colors = ButtonDefaults.buttonColors(containerColor = Color.DarkGray)
) {
Text(labels[selectedLabel.intValue], color = Color.White)
@@ -131,7 +139,14 @@
state = state,
modifier = Modifier.fillMaxSize(),
autoCentering = null,
- contentPadding = PaddingValues(10.dp, 20.dp, 10.dp, 100.dp),
+ contentPadding =
+ ScreenScaffoldDefaults.contentPaddingWithEdgeButton(
+ edgeButtonSize = EdgeButtonSize.Medium,
+ 10.dp,
+ 20.dp,
+ 10.dp,
+ 10.dp
+ ),
horizontalAlignment = Alignment.CenterHorizontally
) {
items(labels.size) {
@@ -152,10 +167,10 @@
fun EdgeButtonMultiDemo() {
val sizes =
listOf(
- ButtonDefaults.EdgeButtonHeightExtraSmall,
- ButtonDefaults.EdgeButtonHeightSmall,
- ButtonDefaults.EdgeButtonHeightMedium,
- ButtonDefaults.EdgeButtonHeightLarge
+ EdgeButtonSize.ExtraSmall,
+ EdgeButtonSize.Small,
+ EdgeButtonSize.Medium,
+ EdgeButtonSize.Large
)
val sizeNames = listOf("XS", "S", "M", "L")
var size by remember { mutableIntStateOf(0) }
@@ -215,7 +230,7 @@
EdgeButton(
onClick = {},
enabled = colorNames[color] != "D",
- preferredHeight = sizes[size],
+ buttonSize = sizes[size],
colors = colors[color],
border =
if (colorNames[color] == "O")
@@ -232,10 +247,10 @@
fun EdgeButtonConfigurableDemo() {
val sizes =
listOf(
- "Extra Small" to ButtonDefaults.EdgeButtonHeightExtraSmall,
- "Small" to ButtonDefaults.EdgeButtonHeightSmall,
- "Medium" to ButtonDefaults.EdgeButtonHeightMedium,
- "Large" to ButtonDefaults.EdgeButtonHeightLarge
+ "Extra Small" to EdgeButtonSize.ExtraSmall,
+ "Small" to EdgeButtonSize.Small,
+ "Medium" to EdgeButtonSize.Medium,
+ "Large" to EdgeButtonSize.Large,
)
var selectedSize by remember { mutableIntStateOf(0) }
val colors =
@@ -257,7 +272,7 @@
bottomButton = {
EdgeButton(
onClick = {},
- preferredHeight = sizes[selectedSize].second,
+ buttonSize = sizes[selectedSize].second,
colors = colors[selectedColor].second,
border =
if (colors[selectedColor].first == "Outlined")
@@ -278,7 +293,12 @@
modifier = Modifier.fillMaxSize(),
autoCentering = null,
contentPadding =
- PaddingValues(10.dp, 20.dp, 10.dp, sizes[selectedSize].second + 6.dp),
+ ScreenScaffoldDefaults.contentPaddingWithEdgeButton(
+ sizes[selectedSize].second,
+ 10.dp,
+ 20.dp,
+ 10.dp
+ ),
horizontalAlignment = Alignment.CenterHorizontally
) {
selection(
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt
index dae4eef..6308c19 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt
@@ -84,7 +84,7 @@
@Sampled
@Composable
-fun AlertDialogWithBottomButtonSample() {
+fun AlertDialogWithEdgeButtonSample() {
var showDialog by remember { mutableStateOf(false) }
Box(Modifier.fillMaxSize()) {
@@ -107,8 +107,8 @@
)
},
title = { Text("Mobile network is not currently available") },
- bottomButton = {
- AlertDialogDefaults.BottomButton(
+ edgeButton = {
+ AlertDialogDefaults.EdgeButton(
onClick = {
// Perform confirm action here
showDialog = false
@@ -135,8 +135,8 @@
onDismissRequest = { showDialog = false },
title = { Text("Share your location") },
text = { Text(" The following apps have asked you to share your location") },
- bottomButton = {
- AlertDialogDefaults.BottomButton(
+ edgeButton = {
+ AlertDialogDefaults.EdgeButton(
onClick = {
// Perform confirm action here
showDialog = false
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/EdgeButtonSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/EdgeButtonSample.kt
index 2aa006b..4bcebfb 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/EdgeButtonSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/EdgeButtonSample.kt
@@ -36,6 +36,7 @@
import androidx.wear.compose.material3.ButtonDefaults.buttonColors
import androidx.wear.compose.material3.Card
import androidx.wear.compose.material3.EdgeButton
+import androidx.wear.compose.material3.EdgeButtonSize
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
@@ -47,7 +48,7 @@
Text("Confirm", Modifier.align(Alignment.Center))
EdgeButton(
onClick = { /* Do something */ },
- preferredHeight = ButtonDefaults.EdgeButtonHeightMedium,
+ buttonSize = EdgeButtonSize.Medium,
modifier = Modifier.align(Alignment.BottomEnd)
) {
Icon(
@@ -68,7 +69,7 @@
bottomButton = {
EdgeButton(
onClick = {},
- preferredHeight = ButtonDefaults.EdgeButtonHeightLarge,
+ buttonSize = EdgeButtonSize.Large,
colors = buttonColors(containerColor = Color.DarkGray)
) {
Text("Ok", textAlign = TextAlign.Center)
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/AlertDialogScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/AlertDialogScreenshotTest.kt
index b593267..a02f665 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/AlertDialogScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/AlertDialogScreenshotTest.kt
@@ -307,7 +307,7 @@
title = title,
icon = icon,
text = text,
- bottomButton = { AlertDialogDefaults.BottomButton({}) },
+ edgeButton = { AlertDialogDefaults.EdgeButton({}) },
content = content
)
}
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/AlertDialogTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/AlertDialogTest.kt
index a987c30..bac176a 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/AlertDialogTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/AlertDialogTest.kt
@@ -58,7 +58,7 @@
AlertDialog(
modifier = Modifier.testTag(TEST_TAG),
title = {},
- bottomButton = {},
+ edgeButton = {},
onDismissRequest = {},
show = true
)
@@ -87,7 +87,7 @@
AlertDialog(
icon = { TestImage(TEST_TAG) },
title = {},
- bottomButton = {},
+ edgeButton = {},
onDismissRequest = {},
show = true
)
@@ -115,7 +115,7 @@
rule.setContentWithTheme {
AlertDialog(
title = { Text("Text", modifier = Modifier.testTag(TEST_TAG)) },
- bottomButton = {},
+ edgeButton = {},
onDismissRequest = {},
show = true
)
@@ -143,7 +143,7 @@
AlertDialog(
title = {},
text = { Text("Text", modifier = Modifier.testTag(TEST_TAG)) },
- bottomButton = {},
+ edgeButton = {},
onDismissRequest = {},
show = true
)
@@ -169,7 +169,7 @@
@Test
fun displays_content_with_bottomButton() {
rule.setContentWithTheme {
- AlertDialog(title = {}, bottomButton = {}, onDismissRequest = {}, show = true) {
+ AlertDialog(title = {}, edgeButton = {}, onDismissRequest = {}, show = true) {
item { Text("Text", modifier = Modifier.testTag(TEST_TAG)) }
}
}
@@ -225,7 +225,7 @@
rule.setContentWithTheme {
AlertDialog(
title = {},
- bottomButton = { Button(onClick = {}, modifier = Modifier.testTag(TEST_TAG)) {} },
+ edgeButton = { Button(onClick = {}, modifier = Modifier.testTag(TEST_TAG)) {} },
onDismissRequest = {},
show = true
)
@@ -264,7 +264,7 @@
AlertDialog(
modifier = Modifier.testTag(TEST_TAG),
title = {},
- bottomButton = {},
+ edgeButton = {},
onDismissRequest = {
showDialog = false
dismissCounter++
@@ -287,7 +287,7 @@
AlertDialog(
modifier = Modifier.testTag(TEST_TAG),
title = {},
- bottomButton = {},
+ edgeButton = {},
onDismissRequest = { dismissCounter++ },
show = show.value
)
@@ -304,7 +304,7 @@
AlertDialog(
modifier = Modifier.testTag(TEST_TAG),
title = {},
- bottomButton = {},
+ edgeButton = {},
onDismissRequest = {},
show = false
)
@@ -338,7 +338,7 @@
actualTextAlign = LocalTextConfiguration.current.textAlign
actualTextMaxLines = LocalTextConfiguration.current.maxLines
},
- bottomButton = {},
+ edgeButton = {},
onDismissRequest = {},
show = true
)
@@ -373,7 +373,7 @@
actualTextStyle = LocalTextStyle.current
actualTextAlign = LocalTextConfiguration.current.textAlign
},
- bottomButton = {},
+ edgeButton = {},
onDismissRequest = {},
show = true
)
@@ -394,7 +394,7 @@
AlertDialog(
modifier = Modifier.testTag(TEST_TAG),
title = { Text("Title", color = expectedContentColor) },
- bottomButton = {},
+ edgeButton = {},
onDismissRequest = {},
show = true
)
@@ -413,7 +413,7 @@
modifier = Modifier.testTag(TEST_TAG),
title = {},
text = { Text("Text", color = expectedContentColor) },
- bottomButton = {},
+ edgeButton = {},
onDismissRequest = {},
show = true
)
@@ -431,7 +431,7 @@
AlertDialog(
modifier = Modifier.testTag(TEST_TAG).background(expectedBackgroundColor),
title = {},
- bottomButton = {},
+ edgeButton = {},
onDismissRequest = {},
show = true
)
@@ -474,7 +474,6 @@
show = true,
title = { Text("Title") },
onDismissRequest = {},
- bottomButton = null,
verticalArrangement =
Arrangement.spacedBy(space = 0.dp, alignment = Alignment.CenterVertically),
modifier = Modifier.size(SmallScreenSize.dp).testTag(TEST_TAG),
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/EdgeButtonScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/EdgeButtonScreenshotTest.kt
index 7eada56..9409890 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/EdgeButtonScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/EdgeButtonScreenshotTest.kt
@@ -64,50 +64,39 @@
@Test
fun edge_button_xsmall() = verifyScreenshot {
- BasicEdgeButton(buttonHeight = ButtonDefaults.EdgeButtonHeightExtraSmall)
+ BasicEdgeButton(buttonSize = EdgeButtonSize.ExtraSmall)
}
@Test
fun edge_button_small() =
- verifyScreenshot() { BasicEdgeButton(buttonHeight = ButtonDefaults.EdgeButtonHeightSmall) }
+ verifyScreenshot() { BasicEdgeButton(buttonSize = EdgeButtonSize.Small) }
@Test
fun edge_button_medium() =
- verifyScreenshot() { BasicEdgeButton(buttonHeight = ButtonDefaults.EdgeButtonHeightMedium) }
+ verifyScreenshot() { BasicEdgeButton(buttonSize = EdgeButtonSize.Medium) }
@Test
fun edge_button_large() =
- verifyScreenshot() { BasicEdgeButton(buttonHeight = ButtonDefaults.EdgeButtonHeightLarge) }
+ verifyScreenshot() { BasicEdgeButton(buttonSize = EdgeButtonSize.Large) }
@Test
fun edge_button_disabled() =
- verifyScreenshot() {
- BasicEdgeButton(buttonHeight = ButtonDefaults.EdgeButtonHeightMedium, enabled = false)
- }
+ verifyScreenshot() { BasicEdgeButton(buttonSize = EdgeButtonSize.Medium, enabled = false) }
@Test
fun edge_button_small_space_very_limited() =
verifyScreenshot() {
- BasicEdgeButton(
- buttonHeight = ButtonDefaults.EdgeButtonHeightSmall,
- constrainedHeight = 10.dp
- )
+ BasicEdgeButton(buttonSize = EdgeButtonSize.Small, constrainedHeight = 10.dp)
}
@Test
fun edge_button_small_space_limited() = verifyScreenshot {
- BasicEdgeButton(
- buttonHeight = ButtonDefaults.EdgeButtonHeightSmall,
- constrainedHeight = 30.dp
- )
+ BasicEdgeButton(buttonSize = EdgeButtonSize.Small, constrainedHeight = 30.dp)
}
@Test
fun edge_button_small_slightly_limited() = verifyScreenshot {
- BasicEdgeButton(
- buttonHeight = ButtonDefaults.EdgeButtonHeightSmall,
- constrainedHeight = 40.dp
- )
+ BasicEdgeButton(buttonSize = EdgeButtonSize.Small, constrainedHeight = 40.dp)
}
private val LONG_TEXT =
@@ -116,17 +105,17 @@
@Test
fun edge_button_xsmall_long_text() = verifyScreenshot {
- BasicEdgeButton(buttonHeight = ButtonDefaults.EdgeButtonHeightExtraSmall, text = LONG_TEXT)
+ BasicEdgeButton(buttonSize = EdgeButtonSize.ExtraSmall, text = LONG_TEXT)
}
@Test
fun edge_button_large_long_text() = verifyScreenshot {
- BasicEdgeButton(buttonHeight = ButtonDefaults.EdgeButtonHeightLarge, text = LONG_TEXT)
+ BasicEdgeButton(buttonSize = EdgeButtonSize.Large, text = LONG_TEXT)
}
@Composable
private fun BasicEdgeButton(
- buttonHeight: Dp,
+ buttonSize: EdgeButtonSize,
constrainedHeight: Dp? = null,
enabled: Boolean = true,
text: String = "Text"
@@ -135,7 +124,7 @@
EdgeButton(
onClick = { /* Do something */ },
enabled = enabled,
- preferredHeight = buttonHeight,
+ buttonSize = buttonSize,
modifier =
Modifier.align(Alignment.BottomEnd)
.testTag(TEST_TAG)
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/AlertDialog.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/AlertDialog.kt
index 5fd2645..10cfeea 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/AlertDialog.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/AlertDialog.kt
@@ -46,6 +46,7 @@
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.ScalingLazyListScope
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
+import androidx.wear.compose.material3.AlertDialogDefaults.edgeButtonExtraTopPadding
import androidx.wear.compose.material3.PaddingDefaults.horizontalContentPadding
import androidx.wear.compose.material3.PaddingDefaults.verticalContentPadding
import androidx.wear.compose.materialcore.isSmallScreen
@@ -124,23 +125,9 @@
* information, or help users accomplish a task. The dialog is scrollable by default if the content
* exceeds the viewport height.
*
- * This overload has a single slot for a confirm [EdgeButton] at the bottom of the dialog. It should
- * be used when the user will be presented with either a single acknowledgement, or a stack of
- * options.
- *
- * Example of an [AlertDialog] with an icon, title, text and bottom [EdgeButton]:
- *
- * @sample androidx.wear.compose.material3.samples.AlertDialogWithBottomButtonSample
- *
- * Example of an [AlertDialog] with content groups and a bottom [EdgeButton]:
- *
- * @sample androidx.wear.compose.material3.samples.AlertDialogWithContentGroupsSample
* @param show A boolean indicating whether the dialog should be displayed.
* @param onDismissRequest A lambda function to be called when the dialog is dismissed by swiping to
* the right or by other dismiss action.
- * @param bottomButton Optional slot for a [EdgeButton] indicating positive sentiment. Clicking the
- * button must remove the dialog from the composition hierarchy e.g. by setting [show] to false.
- * It's recommended to use [AlertDialogDefaults.BottomButton] in this slot with onClick callback.
* @param title A slot for displaying the title of the dialog. Title should contain a summary of the
* dialog's purpose or content and should not exceed 3 lines of text.
* @param modifier Modifier to be applied to the dialog content.
@@ -159,13 +146,12 @@
fun AlertDialog(
show: Boolean,
onDismissRequest: () -> Unit,
- bottomButton: (@Composable BoxScope.() -> Unit)?,
title: @Composable () -> Unit,
modifier: Modifier = Modifier,
icon: @Composable (() -> Unit)? = null,
text: @Composable (() -> Unit)? = null,
verticalArrangement: Arrangement.Vertical = AlertDialogDefaults.VerticalArrangement,
- contentPadding: PaddingValues = AlertDialogDefaults.contentPadding(bottomButton != null),
+ contentPadding: PaddingValues = AlertDialogDefaults.contentPadding(),
properties: DialogProperties = DialogProperties(),
content: (ScalingLazyListScope.() -> Unit)? = null
) {
@@ -179,29 +165,100 @@
title = title,
icon = icon,
text = text,
- alertButtonsParams =
- if (bottomButton != null) AlertButtonsParams.BottomButton(bottomButton)
- else AlertButtonsParams.NoButtons,
+ alertButtonsParams = AlertButtonsParams.NoButtons,
+ content = content
+ )
+}
+
+/**
+ * Dialogs provide important prompts in a user flow. They can require an action, communicate
+ * information, or help users accomplish a task. The dialog is scrollable by default if the content
+ * exceeds the viewport height.
+ *
+ * This overload has a single slot for a confirm [EdgeButton] at the bottom of the dialog. It should
+ * be used when the user will be presented with a single acknowledgement.
+ *
+ * Example of an [AlertDialog] with an icon, title, text and bottom [EdgeButton]:
+ *
+ * @sample androidx.wear.compose.material3.samples.AlertDialogWithEdgeButtonSample
+ *
+ * Example of an [AlertDialog] with content groups and a bottom [EdgeButton]:
+ *
+ * @sample androidx.wear.compose.material3.samples.AlertDialogWithContentGroupsSample
+ * @param show A boolean indicating whether the dialog should be displayed.
+ * @param onDismissRequest A lambda function to be called when the dialog is dismissed by swiping to
+ * the right or by other dismiss action.
+ * @param edgeButton Slot for a [EdgeButton] indicating positive sentiment. Clicking the button must
+ * remove the dialog from the composition hierarchy e.g. by setting [show] to false. It's
+ * recommended to use [AlertDialogDefaults.EdgeButton] in this slot with onClick callback. Note
+ * that when using a [EdgeButton] which is not Medium size, the contentPadding parameters should
+ * be specified.
+ * @param title A slot for displaying the title of the dialog. Title should contain a summary of the
+ * dialog's purpose or content and should not exceed 3 lines of text.
+ * @param modifier Modifier to be applied to the dialog content.
+ * @param icon Optional slot for an icon to be shown at the top of the dialog.
+ * @param text Optional slot for displaying the message of the dialog below the title. Should
+ * contain additional text that presents further details about the dialog's purpose if the title
+ * is insufficient.
+ * @param verticalArrangement The vertical arrangement of the dialog's children. There is a default
+ * padding between icon, title, and text, which will be added to the spacing specified in this
+ * [verticalArrangement] parameter.
+ * @param contentPadding The padding to apply around the entire dialog's contents. Ensure there is
+ * enough space for the [EdgeButton], for example, using
+ * [AlertDialogDefaults.contentPaddingWithEdgeButton]
+ * @param properties An optional [DialogProperties] object for configuring the dialog's behavior.
+ * @param content A slot for additional content, displayed within a scrollable [ScalingLazyColumn].
+ */
+@Composable
+fun AlertDialog(
+ show: Boolean,
+ onDismissRequest: () -> Unit,
+ edgeButton: (@Composable BoxScope.() -> Unit),
+ title: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ icon: @Composable (() -> Unit)? = null,
+ text: @Composable (() -> Unit)? = null,
+ verticalArrangement: Arrangement.Vertical = AlertDialogDefaults.VerticalArrangement,
+ contentPadding: PaddingValues = AlertDialogDefaults.contentPaddingWithEdgeButton(),
+ properties: DialogProperties = DialogProperties(),
+ content: (ScalingLazyListScope.() -> Unit)? = null
+) {
+ AlertDialogImpl(
+ show = show,
+ onDismissRequest = onDismissRequest,
+ modifier = modifier,
+ properties = properties,
+ verticalArrangement = verticalArrangement,
+ contentPadding = contentPadding,
+ title = title,
+ icon = icon,
+ text = text,
+ alertButtonsParams = AlertButtonsParams.EdgeButton(edgeButton),
content = content
)
}
/** Contains the default values used by [AlertDialog] */
object AlertDialogDefaults {
-
/**
- * Default composable for the bottom button in an [AlertDialog]. Should be used with
- * [AlertDialog] overload which contains a single bottomButton slot.
+ * Default composable for the edge button in an [AlertDialog]. This is a medium sized
+ * [EdgeButton]. Should be used with [AlertDialog] overload which contains a single edgeButton
+ * slot.
*
* @param onClick The callback to be invoked when the button is clicked.
+ * @param modifier The [Modifier] to be applied to the button.
* @param content The composable content of the button. Defaults to [ConfirmIcon].
*/
@Composable
- fun BottomButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit = ConfirmIcon) {
+ fun EdgeButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable RowScope.() -> Unit = ConfirmIcon
+ ) {
EdgeButton(
- modifier = Modifier.padding(top = edgeButtonExtraTopPadding),
+ modifier = modifier,
onClick = onClick,
- preferredHeight = ButtonDefaults.EdgeButtonHeightMedium,
+ buttonSize = EdgeButtonSize.Medium,
content = content
)
}
@@ -267,19 +324,36 @@
}
/**
- * The padding to apply around the content for the [AlertDialog] variation with a stack of
- * options and optional bottom button.
+ * The padding to apply around the content for the [AlertDialog] variation with a bottom button.
+ * If you need to configure custom paddings, consider using
+ * [ScreenScaffoldDefaults.contentPaddingWithEdgeButton]
*/
@Composable
- fun contentPadding(hasBottomButton: Boolean): PaddingValues {
+ fun contentPaddingWithEdgeButton(
+ edgeButtonSize: EdgeButtonSize = EdgeButtonSize.Medium
+ ): PaddingValues {
val topPadding = verticalContentPadding()
val horizontalPadding = horizontalContentPadding()
- val bottomPadding =
- if (hasBottomButton) edgeButtonHeightWithPadding
- else screenHeightDp().dp * noEdgeButtonBottomPaddingFraction
+ return ScreenScaffoldDefaults.contentPaddingWithEdgeButton(
+ edgeButtonSize,
+ top = topPadding,
+ start = horizontalPadding,
+ end = horizontalPadding,
+ extraBottom = edgeButtonExtraTopPadding
+ )
+ }
+
+ /**
+ * The padding to apply around the content for the [AlertDialog] variation with a stack of
+ * options and no buttons at the end.
+ */
+ @Composable
+ fun contentPadding(): PaddingValues {
+ val topPadding = verticalContentPadding()
+ val horizontalPadding = horizontalContentPadding()
return PaddingValues(
top = topPadding,
- bottom = bottomPadding,
+ bottom = screenHeightDp().dp * noEdgeButtonBottomPaddingFraction,
start = horizontalPadding,
end = horizontalPadding,
)
@@ -317,8 +391,7 @@
}
/** The extra top padding to apply to the edge button. */
- private val edgeButtonExtraTopPadding = 1.dp
- private val edgeButtonHeightWithPadding = ButtonDefaults.EdgeButtonHeightMedium + 7.dp
+ internal val edgeButtonExtraTopPadding = 1.dp
internal val noEdgeButtonBottomPaddingFraction = 0.3646f
internal val cancelButtonPadding = 1.dp
}
@@ -348,9 +421,14 @@
scrollState = state,
modifier = modifier,
bottomButton =
- if (alertButtonsParams is AlertButtonsParams.BottomButton)
- alertButtonsParams.bottomButton
- else null
+ if (alertButtonsParams is AlertButtonsParams.EdgeButton) {
+ {
+ Box(
+ Modifier.padding(top = edgeButtonExtraTopPadding),
+ content = alertButtonsParams.edgeButton
+ )
+ }
+ } else null
) {
ScalingLazyColumn(
state = state,
@@ -375,7 +453,7 @@
when (alertButtonsParams) {
is AlertButtonsParams.ConfirmDismissButtons ->
item { ConfirmDismissButtons(alertButtonsParams) }
- is AlertButtonsParams.BottomButton ->
+ is AlertButtonsParams.EdgeButton ->
if (content == null) {
item { Spacer(Modifier.height(AlertBottomSpacing)) }
}
@@ -451,8 +529,8 @@
private sealed interface AlertButtonsParams {
object NoButtons : AlertButtonsParams
- class BottomButton(
- val bottomButton: @Composable BoxScope.() -> Unit,
+ class EdgeButton(
+ val edgeButton: @Composable BoxScope.() -> Unit,
) : AlertButtonsParams
class ConfirmDismissButtons(
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Button.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Button.kt
index 1fddec4..7c6dc37 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Button.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Button.kt
@@ -112,9 +112,8 @@
* preview the button in different states. Note that if `null` is provided, interactions will
* still happen internally.
* @param content Slot for composable body content displayed on the Button
- *
- * TODO(b/261838497) Add Material3 UX guidance links
*/
+// TODO(b/261838497) Add Material3 UX guidance links
@Composable
fun Button(
onClick: () -> Unit,
@@ -191,9 +190,8 @@
* preview the button in different states. Note that if `null` is provided, interactions will
* still happen internally.
* @param content Slot for composable body content displayed on the Button
- *
- * TODO(b/261838497) Add Material3 UX guidance links
*/
+// TODO(b/261838497) Add Material3 UX guidance links
@Composable
fun FilledTonalButton(
onClick: () -> Unit,
@@ -269,9 +267,8 @@
* preview the button in different states. Note that if `null` is provided, interactions will
* still happen internally.
* @param content Slot for composable body content displayed on the OutlinedButton
- *
- * TODO(b/261838497) Add Material3 UX guidance links
*/
+// TODO(b/261838497) Add Material3 UX guidance links
@Composable
fun OutlinedButton(
onClick: () -> Unit,
@@ -347,9 +344,8 @@
* preview the button in different states. Note that if `null` is provided, interactions will
* still happen internally.
* @param content Slot for composable body content displayed on the ChildButton
- *
- * TODO(b/261838497) Add Material3 UX guidance links
*/
+// TODO(b/261838497) Add Material3 UX guidance links
@Composable
fun ChildButton(
onClick: () -> Unit,
@@ -443,9 +439,8 @@
* still happen internally.
* @param label A slot for providing the button's main label. The contents are expected to be text
* which is "start" aligned if there is an icon preset and "start" or "center" aligned if not.
- *
- * TODO(b/261838497) Add Material3 UX guidance links
*/
+// TODO(b/261838497) Add Material3 UX guidance links
@Composable
fun Button(
onClick: () -> Unit,
@@ -564,9 +559,8 @@
* still happen internally.
* @param label A slot for providing the button's main label. The contents are expected to be text
* which is "start" aligned if there is an icon preset and "start" or "center" aligned if not.
- *
- * TODO(b/261838497) Add Material3 UX guidance links
*/
+// TODO(b/261838497) Add Material3 UX guidance links
@Composable
fun FilledTonalButton(
onClick: () -> Unit,
@@ -680,9 +674,8 @@
* still happen internally.
* @param label A slot for providing the button's main label. The contents are expected to be text
* which is "start" aligned if there is an icon preset and "start" or "center" aligned if not.
- *
- * TODO(b/261838497) Add Material3 UX guidance links
*/
+// TODO(b/261838497) Add Material3 UX guidance links
@Composable
fun OutlinedButton(
onClick: () -> Unit,
@@ -796,9 +789,8 @@
* still happen internally.
* @param label A slot for providing the button's main label. The contents are expected to be text
* which is "start" aligned if there is an icon preset and "start" or "center" aligned if not.
- *
- * TODO(b/261838497) Add Material3 UX guidance links
*/
+// TODO(b/261838497) Add Material3 UX guidance links
@Composable
fun ChildButton(
onClick: () -> Unit,
@@ -933,9 +925,8 @@
* shape is a key characteristic of the Wear Material3 Theme
* @param border Optional [BorderStroke] that will be used to resolve the border for this button in
* different states.
- *
- * TODO(b/261838497) Add Material3 samples and UX guidance links
*/
+// TODO(b/261838497) Add Material3 samples and UX guidance links
@Composable
fun CompactButton(
onClick: () -> Unit,
@@ -1462,18 +1453,6 @@
*/
val CompactButtonHeight = CompactButtonTokens.ContainerHeight
- /** The height to be applied for an extra small [EdgeButton]. */
- val EdgeButtonHeightExtraSmall = 46.dp
-
- /** The height to be applied for a small [EdgeButton]. */
- val EdgeButtonHeightSmall = 56.dp
-
- /** The height to be applied for a medium [EdgeButton]. */
- val EdgeButtonHeightMedium = 70.dp
-
- /** The height to be applied for a large [EdgeButton]. */
- val EdgeButtonHeightLarge = 96.dp
-
/**
* The default padding to be provided around a [CompactButton] in order to ensure that its
* tappable area meets minimum UX guidance.
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/EdgeButton.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/EdgeButton.kt
index c25b822..2070be7 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/EdgeButton.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/EdgeButton.kt
@@ -107,12 +107,7 @@
* @param modifier Modifier to be applied to the button. When animating the button to appear/
* disappear from the screen, a Modifier.height can be used to change the height of the component,
* but that won't change the space available for the content (though it may be scaled)
- * @param preferredHeight Defines the base size of the button, see the 4 standard sizes specified in
- * [ButtonDefaults]. This is used to determine the size constraints passed on to the content, and
- * the size of the edge button if no height Modifier is specified. Note that if a height Modifier
- * is specified for the EdgeButton, that will determine the size of the container, and the content
- * may be scaled and or have alpha applied to fit. The default is
- * [ButtonDefaults.EdgeButtonHeightSmall]
+ * @param buttonSize Defines the size of the button. See [EdgeButtonSize].
* @param enabled Controls the enabled state of the button. When `false`, this button will not be
* clickable
* @param colors [ButtonColors] that will be used to resolve the background and content color for
@@ -130,7 +125,7 @@
fun EdgeButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
- preferredHeight: Dp = ButtonDefaults.EdgeButtonHeightSmall,
+ buttonSize: EdgeButtonSize = EdgeButtonSize.Small,
enabled: Boolean = true,
colors: ButtonColors = ButtonDefaults.buttonColors(),
border: BorderStroke? = null,
@@ -142,6 +137,8 @@
val density = LocalDensity.current
val screenWidthDp = screenWidthDp().dp
+ val preferredHeight = buttonSize.maximumHeight
+
val contentShapeHelper =
remember(preferredHeight) {
ShapeHelper(density).apply {
@@ -269,6 +266,29 @@
)
}
+/**
+ * Size of the [EdgeButton]. This in turns determines the full shape of the edge button, including
+ * width, height, rounding radius for the top corners, the ellipsis size for the bottom part of the
+ * shape and the space available for the content.
+ */
+class EdgeButtonSize internal constructor(internal val maximumHeight: Dp) {
+ internal fun maximumHeightPlusPadding() = maximumHeight + VERTICAL_PADDING * 2
+
+ companion object {
+ /** The Size to be applied for an extra small [EdgeButton]. */
+ val ExtraSmall = EdgeButtonSize(46.dp)
+
+ /** The Size to be applied for an small [EdgeButton]. */
+ val Small = EdgeButtonSize(56.dp)
+
+ /** The Size to be applied for an medium [EdgeButton]. */
+ val Medium = EdgeButtonSize(70.dp)
+
+ /** The Size to be applied for an large [EdgeButton]. */
+ val Large = EdgeButtonSize(96.dp)
+ }
+}
+
private fun Modifier.sizeAndOffset(rectFn: (Constraints) -> Rect) =
layout { measurable, constraints ->
val rect = rectFn(constraints)
@@ -294,7 +314,7 @@
internal class ShapeHelper(private val density: Density) {
private val extraSmallHeightPx =
- with(density) { ButtonDefaults.EdgeButtonHeightExtraSmall.toPx() }
+ with(density) { EdgeButtonSize.ExtraSmall.maximumHeight.toPx() }
private val bottomPaddingPx = with(density) { VERTICAL_PADDING.toPx() }
private val extraSmallEllipsisHeightPx = with(density) { EXTRA_SMALL_ELLIPSIS_HEIGHT.toPx() }
private val targetSidePadding = with(density) { TARGET_SIDE_PADDING.toPx() }
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/IconButton.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/IconButton.kt
index 2d6ce2b..a7f7193 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/IconButton.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/IconButton.kt
@@ -88,9 +88,8 @@
* preview the button in different states. Note that if `null` is provided, interactions will
* still happen internally.
* @param content The content displayed on the icon button, expected to be icon or image.
- *
- * TODO(b/261838497) Add Material3 samples and UX guidance links
*/
+// TODO(b/261838497) Add Material3 samples and UX guidance links
@Composable
fun IconButton(
onClick: () -> Unit,
@@ -156,9 +155,8 @@
* preview the button in different states. Note that if `null` is provided, interactions will
* still happen internally.
* @param content The content displayed on the icon button, expected to be icon or image.
- *
- * TODO(b/261838497) Add Material3 samples and UX guidance links
*/
+// TODO(b/261838497) Add Material3 samples and UX guidance links
@Composable
fun FilledIconButton(
onClick: () -> Unit,
@@ -225,9 +223,8 @@
* preview the button in different states. Note that if `null` is provided, interactions will
* still happen internally.
* @param content The content displayed on the icon button, expected to be icon or image.
- *
- * TODO(b/261838497) Add Material3 samples and UX guidance links
*/
+// TODO(b/261838497) Add Material3 samples and UX guidance links
@Composable
fun FilledTonalIconButton(
onClick: () -> Unit,
@@ -298,9 +295,8 @@
* preview the button in different states. Note that if `null` is provided, interactions will
* still happen internally.
* @param content The content displayed on the icon button, expected to be icon or image.
- *
- * TODO(b/261838497) Add Material3 samples and UX guidance links
*/
+// TODO(b/261838497) Add Material3 samples and UX guidance links
@Composable
fun OutlinedIconButton(
onClick: () -> Unit,
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/MaterialTheme.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/MaterialTheme.kt
index 51f3b6c..22fc16e 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/MaterialTheme.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/MaterialTheme.kt
@@ -49,9 +49,8 @@
* @param shapes A set of shapes to be used by the components in this hierarchy
* @param motionScheme a set of motion specs used to animate content for this hierarchy.
* @param content Slot for composable content displayed with this theme
- *
- * TODO(b/273543423) Update references to Material3 design specs
*/
+// TODO(b/273543423) Update references to Material3 design specs
@Composable
fun MaterialTheme(
colorScheme: ColorScheme = MaterialTheme.colorScheme,
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/PagerScaffold.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/PagerScaffold.kt
index 2676d72..e0a017e 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/PagerScaffold.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/PagerScaffold.kt
@@ -52,6 +52,8 @@
import androidx.wear.compose.foundation.pager.PagerDefaults
import androidx.wear.compose.foundation.pager.PagerState
import androidx.wear.compose.foundation.pager.VerticalPager
+import androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior
+import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults
import kotlin.math.absoluteValue
/**
@@ -76,6 +78,9 @@
* conflicts with the page indicator. By default this is null, so the page indicator will be
* visible at all times, setting this to [PagerScaffoldDefaults.FadeOutAnimation] ensures the
* indicator only shows during paging, and fades out when the Pager is idle.
+ * @param rotaryScrollableBehavior Parameter for changing rotary behavior. By default rotary support
+ * is disabled for [HorizontalPagerScaffold]. It can be enabled by passing
+ * [RotaryScrollableDefaults.snapBehavior] with pagerState parameter.
* @param content A composable function that takes the current page index as a parameter and defines
* the content to be displayed on that page.
*/
@@ -85,11 +90,18 @@
modifier: Modifier = Modifier,
pageIndicator: (@Composable BoxScope.() -> Unit)? = { HorizontalPageIndicator(pagerState) },
pageIndicatorAnimationSpec: AnimationSpec<Float>? = null,
+ rotaryScrollableBehavior: RotaryScrollableBehavior? = null,
content: @Composable PagerScope.(page: Int) -> Unit,
) =
PagerScaffoldImpl(
scrollInfoProvider = ScrollInfoProvider(pagerState, orientation = Orientation.Horizontal),
- pagerContent = { AnimatedHorizontalPager(pagerState, content = content) },
+ pagerContent = {
+ AnimatedHorizontalPager(
+ state = pagerState,
+ rotaryScrollableBehavior = rotaryScrollableBehavior,
+ content = content
+ )
+ },
modifier = modifier,
pagerState = pagerState,
pageIndicator = pageIndicator,
@@ -106,6 +118,10 @@
* default and coordinates showing/hiding [TimeText] and [VerticalPageIndicator] according to
* whether the Pager is being paged, this is determined by the [PagerState].
*
+ * [VerticalPagerScaffold] supports rotary input by default. Rotary input allows users to scroll
+ * through the pager's content - by using a crown or a rotating bezel on their Wear OS device. It
+ * can be modified or turned off using the [rotaryScrollableBehavior] parameter.
+ *
* Example of using [AppScaffold] and [VerticalPagerScaffold]:
*
* @sample androidx.wear.compose.material3.samples.VerticalPagerScaffoldSample
@@ -118,6 +134,9 @@
* conflicts with the page indicator. By default this is null, so the page indicator will be
* visible at all times, setting this to [PagerScaffoldDefaults.FadeOutAnimation] ensures the
* indicator only shows during paging, and fades out when the Pager is idle.
+ * @param rotaryScrollableBehavior Parameter for changing rotary behavior. We recommend to use
+ * [RotaryScrollableDefaults.snapBehavior] with pagerState parameter. Passing null turns off the
+ * rotary handling if it is not required.
* @param content A composable function that takes the current page index as a parameter and defines
* the content to be displayed on that page.
*/
@@ -127,11 +146,19 @@
modifier: Modifier = Modifier,
pageIndicator: (@Composable BoxScope.() -> Unit)? = { VerticalPageIndicator(pagerState) },
pageIndicatorAnimationSpec: AnimationSpec<Float>? = null,
+ rotaryScrollableBehavior: RotaryScrollableBehavior? =
+ RotaryScrollableDefaults.snapBehavior(pagerState),
content: @Composable PagerScope.(page: Int) -> Unit,
) =
PagerScaffoldImpl(
scrollInfoProvider = ScrollInfoProvider(pagerState, orientation = Orientation.Vertical),
- pagerContent = { AnimatedVerticalPager(pagerState, content = content) },
+ pagerContent = {
+ AnimatedVerticalPager(
+ state = pagerState,
+ rotaryScrollableBehavior = rotaryScrollableBehavior,
+ content = content
+ )
+ },
modifier = modifier,
pagerState = pagerState,
pageIndicator = pageIndicator,
@@ -220,6 +247,7 @@
* the leftmost 25% of the screen will trigger the gesture. Even when RTL mode is enabled, this
* parameter only ever applies to the left edge of the screen. Setting this to 0 will disable the
* gesture.
+ * @param rotaryScrollableBehavior Parameter for changing rotary behavior
* @param content A composable function that defines the content of each page displayed by the
* Pager. This is where the UI elements that should appear within each page should be placed.
*/
@@ -236,6 +264,7 @@
key: ((index: Int) -> Any)? = null,
@FloatRange(from = 0.0, to = 1.0)
swipeToDismissEdgeZoneFraction: Float = PagerDefaults.SwipeToDismissEdgeZoneFraction,
+ rotaryScrollableBehavior: RotaryScrollableBehavior?,
content: @Composable PagerScope.(page: Int) -> Unit
) {
val touchExplorationStateProvider = remember { DefaultTouchExplorationStateProvider() }
@@ -252,6 +281,7 @@
key = key,
swipeToDismissEdgeZoneFraction =
if (touchExplorationServicesEnabled) 0f else swipeToDismissEdgeZoneFraction,
+ rotaryScrollableBehavior = rotaryScrollableBehavior
) { page ->
AnimatedPageContent(
orientation = Orientation.Horizontal,
@@ -289,6 +319,7 @@
* position will be maintained based on the key, which means if you add/remove items before the
* current visible item the item with the given key will be kept as the first visible one. If null
* is passed the position in the list will represent the key.
+ * @param rotaryScrollableBehavior Parameter for changing rotary behavior.
* @param content A composable function that defines the content of each page displayed by the
* Pager. This is where the UI elements that should appear within each page should be placed.
*/
@@ -303,6 +334,7 @@
userScrollEnabled: Boolean = true,
reverseLayout: Boolean = false,
key: ((index: Int) -> Any)? = null,
+ rotaryScrollableBehavior: RotaryScrollableBehavior?,
content: @Composable PagerScope.(page: Int) -> Unit
) {
VerticalPager(
@@ -314,6 +346,7 @@
userScrollEnabled = userScrollEnabled,
reverseLayout = reverseLayout,
key = key,
+ rotaryScrollableBehavior = rotaryScrollableBehavior
) { page ->
AnimatedPageContent(
orientation = Orientation.Vertical,
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ScreenScaffold.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ScreenScaffold.kt
index 50260db..30552a1 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ScreenScaffold.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ScreenScaffold.kt
@@ -19,6 +19,7 @@
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
@@ -34,7 +35,9 @@
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.ActiveFocusListener
import androidx.wear.compose.foundation.ScrollInfoProvider
import androidx.wear.compose.foundation.lazy.LazyColumnState
@@ -116,6 +119,10 @@
* Example of using AppScaffold and ScreenScaffold:
*
* @sample androidx.wear.compose.material3.samples.ScaffoldSample
+ *
+ * Example of using ScreenScaffold with a [EdgeButton]:
+ *
+ * @sample androidx.wear.compose.material3.samples.EdgeButtonListSample
* @param scrollState The scroll state for [androidx.wear.compose.foundation.lazy.LazyColumn], used
* to drive screen transitions such as [TimeText] scroll away and showing/hiding
* [ScrollIndicator].
@@ -376,6 +383,28 @@
}
}
+/** Contains the default values used by [ScreenScaffold] */
+object ScreenScaffoldDefaults {
+ /**
+ * Creates padding values with extra bottom padding for an EdgeButton.
+ *
+ * @param edgeButtonSize The size of the EdgeButton.
+ * @param start The padding on the start side of the content.
+ * @param top The padding on the top side of the content.
+ * @param end The padding on the end side of the content.
+ * @param extraBottom Additional padding to be added to the bottom padding calculated from the
+ * edge button size.
+ * @return A [PaddingValues] object with the calculated padding.
+ */
+ fun contentPaddingWithEdgeButton(
+ edgeButtonSize: EdgeButtonSize,
+ start: Dp = 0.dp,
+ top: Dp = 0.dp,
+ end: Dp = 0.dp,
+ extraBottom: Dp = 0.dp,
+ ) = PaddingValues(start, top, end, extraBottom + edgeButtonSize.maximumHeightPlusPadding())
+}
+
// Sets the height that will be used down the line, using a state as parameter, to avoid
// recompositions when the height changes.
internal fun Modifier.dynamicHeight(heightState: () -> Float) =
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Shapes.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Shapes.kt
index 178aba6..4a00aab 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Shapes.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Shapes.kt
@@ -54,9 +54,8 @@
* [CornerSize] (used by Cards).
* @param extraLarge By default, provides [ShapeDefaults.ExtraLarge], a [RoundedCornerShape] with
* 32dp [CornerSize].
- *
- * TODO(b/273226734) Review documentation with references to components that use the shape themes.
*/
+// TODO(b/273226734) Review documentation with references to components that use the shape themes.
@Immutable
class Shapes(
val extraSmall: CornerBasedShape = ShapeDefaults.ExtraSmall,
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Slider.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Slider.kt
index dd4ff33..ccbc467 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Slider.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Slider.kt
@@ -445,7 +445,9 @@
containerColor = fromToken(SliderTokens.ContainerColor),
buttonIconColor = fromToken(SliderTokens.ButtonIconColor),
selectedBarColor = fromToken(SliderTokens.SelectedBarColor),
- unselectedBarColor = fromToken(SliderTokens.UnselectedBarColor),
+ unselectedBarColor =
+ fromToken(SliderTokens.UnselectedBarColor)
+ .copy(alpha = SliderTokens.UnselectedBarOpacity),
selectedBarSeparatorColor =
fromToken(SliderTokens.SelectedBarSeparatorColor),
unselectedBarSeparatorColor =
@@ -463,11 +465,13 @@
),
disabledSelectedBarColor = fromToken(SliderTokens.DisabledSelectedBarColor),
disabledUnselectedBarColor =
- fromToken(SliderTokens.DisabledUnselectedBarColor),
+ fromToken(SliderTokens.DisabledUnselectedBarColor)
+ .copy(alpha = SliderTokens.DisabledUnselectedBarOpacity),
disabledSelectedBarSeparatorColor =
fromToken(SliderTokens.DisabledSelectedBarSeparatorColor),
disabledUnselectedBarSeparatorColor =
- fromToken(SliderTokens.DisabledUnselectedBarSeparatorColor),
+ fromToken(SliderTokens.DisabledUnselectedBarSeparatorColor)
+ .copy(alpha = SliderTokens.DisabledUnselectedBarSeparatorOpacity),
)
.also { defaultSliderColorsCached = it }
}
@@ -479,7 +483,9 @@
containerColor = fromToken(SliderTokens.ContainerColor),
buttonIconColor = fromToken(SliderTokens.ButtonIconColor),
selectedBarColor = fromToken(SliderTokens.VariantSelectedBarColor),
- unselectedBarColor = fromToken(SliderTokens.UnselectedBarColor),
+ unselectedBarColor =
+ fromToken(SliderTokens.UnselectedBarColor)
+ .copy(alpha = SliderTokens.UnselectedBarOpacity),
selectedBarSeparatorColor =
fromToken(SliderTokens.SelectedBarSeparatorColor),
unselectedBarSeparatorColor =
@@ -497,11 +503,13 @@
),
disabledSelectedBarColor = fromToken(SliderTokens.DisabledSelectedBarColor),
disabledUnselectedBarColor =
- fromToken(SliderTokens.DisabledUnselectedBarColor),
+ fromToken(SliderTokens.DisabledUnselectedBarColor)
+ .copy(alpha = SliderTokens.DisabledUnselectedBarOpacity),
disabledSelectedBarSeparatorColor =
fromToken(SliderTokens.DisabledSelectedBarSeparatorColor),
disabledUnselectedBarSeparatorColor =
- fromToken(SliderTokens.DisabledUnselectedBarSeparatorColor),
+ fromToken(SliderTokens.DisabledUnselectedBarSeparatorColor)
+ .copy(alpha = SliderTokens.DisabledUnselectedBarSeparatorOpacity),
)
.also { defaultVariantSliderColorsCached = it }
}
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextButton.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextButton.kt
index bc8fa44..63556b6 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextButton.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextButton.kt
@@ -90,9 +90,8 @@
* preview the button in different states. Note that if `null` is provided, interactions will
* still happen internally.
* @param content The content displayed on the text button, expected to be text or image.
- *
- * TODO(b/261838497) Add Material3 UX guidance links
*/
+// TODO(b/261838497) Add Material3 UX guidance links
@Composable
fun TextButton(
onClick: () -> Unit,
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TimePicker.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TimePicker.kt
index bcab35d..04fe78f 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TimePicker.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TimePicker.kt
@@ -332,7 +332,7 @@
}
.focusRequester(focusRequesterConfirmButton)
.focusable(),
- preferredHeight = ButtonDefaults.EdgeButtonHeightSmall,
+ buttonSize = EdgeButtonSize.Small,
colors =
buttonColors(
contentColor = colors.confirmButtonContentColor,
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Typography.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Typography.kt
index f210918..6e87044f 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Typography.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Typography.kt
@@ -109,9 +109,8 @@
* @property numeralExtraSmall NumeralExtraSmall is the smallest role for digits. Numerals use
* tabular spacing by default. They are for numbers that need to accommodate longer strings of
* digits, where no localization is required like in-workout metrics.
- *
- * TODO(b/273526150) Review documentation for typography, add examples for each size.
*/
+// TODO(b/273526150) Review documentation for typography, add examples for each size.
@Immutable
class Typography
internal constructor(
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/LazyColumnScrollTransformModifiers.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/LazyColumnScrollTransformModifiers.kt
index c386e2c..f44fd46 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/LazyColumnScrollTransformModifiers.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/lazy/LazyColumnScrollTransformModifiers.kt
@@ -92,14 +92,13 @@
remember(backgroundColor, shape) {
ScalingMorphingBackgroundPainter(spec, shape, backgroundColor) { scrollProgress }
}
-
this@scrollTransform then
- TargetMorphingHeightConsumerModifierElement { minMorphingHeight = it?.toFloat() } then
- [email protected](painter) then
- [email protected] { height, scrollProgress ->
- with(spec) { scrollProgress.placementHeight(height.toFloat()).fastRoundToInt() }
- } then
- [email protected] { contentTransformation(spec) { scrollProgress } }
+ TargetMorphingHeightConsumerModifierElement { minMorphingHeight = it?.toFloat() }
+ .paint(painter)
+ .transformedHeight { height, scrollProgress ->
+ with(spec) { scrollProgress.placementHeight(height.toFloat()).fastRoundToInt() }
+ }
+ .graphicsLayer { contentTransformation(spec) { scrollProgress } }
}
/**
@@ -121,11 +120,11 @@
val spec = remember { LazyColumnScrollTransformBehavior { minMorphingHeight } }
this@scrollTransform then
- TargetMorphingHeightConsumerModifierElement { minMorphingHeight = it?.toFloat() } then
- [email protected] { height, scrollProgress ->
- with(spec) { scrollProgress.placementHeight(height.toFloat()).fastRoundToInt() }
- } then
- [email protected] { contentTransformation(spec) { scrollProgress } }
+ TargetMorphingHeightConsumerModifierElement { minMorphingHeight = it?.toFloat() }
+ .transformedHeight { height, scrollProgress ->
+ with(spec) { scrollProgress.placementHeight(height.toFloat()).fastRoundToInt() }
+ }
+ .graphicsLayer { contentTransformation(spec) { scrollProgress } }
}
private fun GraphicsLayerScope.contentTransformation(
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SliderTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SliderTokens.kt
index 1157ecc..6f30680 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SliderTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SliderTokens.kt
@@ -29,10 +29,13 @@
val DisabledSelectedBarColor = ColorSchemeKeyTokens.OutlineVariant
val DisabledSelectedBarSeparatorColor = ColorSchemeKeyTokens.SurfaceContainer
val DisabledUnselectedBarColor = ColorSchemeKeyTokens.Background
+ val DisabledUnselectedBarOpacity = 0.3f
val DisabledUnselectedBarSeparatorColor = ColorSchemeKeyTokens.OutlineVariant
+ val DisabledUnselectedBarSeparatorOpacity = 0.5f
val SelectedBarColor = ColorSchemeKeyTokens.Primary
val SelectedBarSeparatorColor = ColorSchemeKeyTokens.PrimaryContainer
val UnselectedBarColor = ColorSchemeKeyTokens.Background
+ val UnselectedBarOpacity = 0.3f
val UnselectedBarSeparatorColor = ColorSchemeKeyTokens.Primary
val UnselectedBarSeparatorOpacity = 0.5f
val VariantSelectedBarColor = ColorSchemeKeyTokens.PrimaryDim
diff --git a/wear/compose/integration-tests/demos/build.gradle b/wear/compose/integration-tests/demos/build.gradle
index e1b71d7..d3d4f2a 100644
--- a/wear/compose/integration-tests/demos/build.gradle
+++ b/wear/compose/integration-tests/demos/build.gradle
@@ -26,8 +26,8 @@
defaultConfig {
applicationId "androidx.wear.compose.integration.demos"
minSdk 25
- versionCode 43
- versionName "1.43"
+ versionCode 44
+ versionName "1.44"
}
buildTypes {
diff --git a/wear/protolayout/protolayout-material3/api/current.txt b/wear/protolayout/protolayout-material3/api/current.txt
index f083fc52..b912adf 100644
--- a/wear/protolayout/protolayout-material3/api/current.txt
+++ b/wear/protolayout/protolayout-material3/api/current.txt
@@ -96,6 +96,10 @@
@kotlin.DslMarker public @interface MaterialScopeMarker {
}
+ public final class PrimaryLayoutKt {
+ method public static androidx.wear.protolayout.LayoutElementBuilders.LayoutElement primaryLayout(androidx.wear.protolayout.material3.MaterialScope, kotlin.jvm.functions.Function1<? super androidx.wear.protolayout.material3.MaterialScope,? extends androidx.wear.protolayout.LayoutElementBuilders.LayoutElement> mainSlot, optional kotlin.jvm.functions.Function1<? super androidx.wear.protolayout.material3.MaterialScope,? extends androidx.wear.protolayout.LayoutElementBuilders.LayoutElement>? titleSlot, optional kotlin.jvm.functions.Function1<? super androidx.wear.protolayout.material3.MaterialScope,? extends androidx.wear.protolayout.LayoutElementBuilders.LayoutElement>? bottomSlot, optional kotlin.jvm.functions.Function1<? super androidx.wear.protolayout.material3.MaterialScope,? extends androidx.wear.protolayout.LayoutElementBuilders.LayoutElement>? labelForBottomSlot, optional androidx.wear.protolayout.ModifiersBuilders.Clickable? onClick);
+ }
+
public final class Shape {
field public static final int CORNER_EXTRA_LARGE = 0; // 0x0
field public static final int CORNER_EXTRA_SMALL = 1; // 0x1
diff --git a/wear/protolayout/protolayout-material3/api/restricted_current.txt b/wear/protolayout/protolayout-material3/api/restricted_current.txt
index f083fc52..b912adf 100644
--- a/wear/protolayout/protolayout-material3/api/restricted_current.txt
+++ b/wear/protolayout/protolayout-material3/api/restricted_current.txt
@@ -96,6 +96,10 @@
@kotlin.DslMarker public @interface MaterialScopeMarker {
}
+ public final class PrimaryLayoutKt {
+ method public static androidx.wear.protolayout.LayoutElementBuilders.LayoutElement primaryLayout(androidx.wear.protolayout.material3.MaterialScope, kotlin.jvm.functions.Function1<? super androidx.wear.protolayout.material3.MaterialScope,? extends androidx.wear.protolayout.LayoutElementBuilders.LayoutElement> mainSlot, optional kotlin.jvm.functions.Function1<? super androidx.wear.protolayout.material3.MaterialScope,? extends androidx.wear.protolayout.LayoutElementBuilders.LayoutElement>? titleSlot, optional kotlin.jvm.functions.Function1<? super androidx.wear.protolayout.material3.MaterialScope,? extends androidx.wear.protolayout.LayoutElementBuilders.LayoutElement>? bottomSlot, optional kotlin.jvm.functions.Function1<? super androidx.wear.protolayout.material3.MaterialScope,? extends androidx.wear.protolayout.LayoutElementBuilders.LayoutElement>? labelForBottomSlot, optional androidx.wear.protolayout.ModifiersBuilders.Clickable? onClick);
+ }
+
public final class Shape {
field public static final int CORNER_EXTRA_LARGE = 0; // 0x0
field public static final int CORNER_EXTRA_SMALL = 1; // 0x1
diff --git a/wear/protolayout/protolayout-material3/samples/src/main/java/androidx/wear/protolayout/material3/samples/Material3ComponentsSample.kt b/wear/protolayout/protolayout-material3/samples/src/main/java/androidx/wear/protolayout/material3/samples/Material3ComponentsSample.kt
index 1103e2c..37b25a2 100644
--- a/wear/protolayout/protolayout-material3/samples/src/main/java/androidx/wear/protolayout/material3/samples/Material3ComponentsSample.kt
+++ b/wear/protolayout/protolayout-material3/samples/src/main/java/androidx/wear/protolayout/material3/samples/Material3ComponentsSample.kt
@@ -26,10 +26,12 @@
import androidx.wear.protolayout.expression.DynamicBuilders.DynamicString
import androidx.wear.protolayout.material3.ColorTokens
import androidx.wear.protolayout.material3.Typography
+import androidx.wear.protolayout.material3.buttonGroup
import androidx.wear.protolayout.material3.getColorProp
import androidx.wear.protolayout.material3.icon
import androidx.wear.protolayout.material3.iconEdgeButton
import androidx.wear.protolayout.material3.materialScope
+import androidx.wear.protolayout.material3.primaryLayout
import androidx.wear.protolayout.material3.text
import androidx.wear.protolayout.material3.textEdgeButton
@@ -84,4 +86,22 @@
}
}
+@Sampled
+fun topLeveLayout(
+ context: Context,
+ deviceConfiguration: DeviceParameters,
+ clickable: Clickable
+): LayoutElement =
+ materialScope(context, deviceConfiguration) {
+ primaryLayout(
+ titleSlot = { text("App title".prop()) },
+ mainSlot = {
+ buttonGroup {
+ // To be populated
+ }
+ },
+ bottomSlot = { iconEdgeButton(clickable, "Description".prop()) { icon("id") } }
+ )
+ }
+
fun String.prop() = StringProp.Builder(this).build()
diff --git a/wear/protolayout/protolayout-material3/src/main/java/androidx/wear/protolayout/material3/EdgeButton.kt b/wear/protolayout/protolayout-material3/src/main/java/androidx/wear/protolayout/material3/EdgeButton.kt
index c34e8ca..d0caa30 100644
--- a/wear/protolayout/protolayout-material3/src/main/java/androidx/wear/protolayout/material3/EdgeButton.kt
+++ b/wear/protolayout/protolayout-material3/src/main/java/androidx/wear/protolayout/material3/EdgeButton.kt
@@ -291,3 +291,6 @@
/** The color or icon tint color to be used for all content within a button. */
public val content: ColorProp
)
+
+internal fun LayoutElement.isSlotEdgeButton(): Boolean =
+ this is Box && METADATA_TAG == this.modifiers?.metadata?.toTagName()
diff --git a/wear/protolayout/protolayout-material3/src/main/java/androidx/wear/protolayout/material3/Helpers.kt b/wear/protolayout/protolayout-material3/src/main/java/androidx/wear/protolayout/material3/Helpers.kt
index 9f088c4..32f5e8c 100644
--- a/wear/protolayout/protolayout-material3/src/main/java/androidx/wear/protolayout/material3/Helpers.kt
+++ b/wear/protolayout/protolayout-material3/src/main/java/androidx/wear/protolayout/material3/Helpers.kt
@@ -19,8 +19,11 @@
import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE
import androidx.annotation.Dimension
+import androidx.annotation.Dimension.Companion.DP
import androidx.annotation.Dimension.Companion.SP
import androidx.wear.protolayout.DimensionBuilders.dp
+import androidx.wear.protolayout.DimensionBuilders.expand
+import androidx.wear.protolayout.LayoutElementBuilders.Spacer
import androidx.wear.protolayout.ModifiersBuilders.ElementMetadata
import androidx.wear.protolayout.ModifiersBuilders.SEMANTICS_ROLE_BUTTON
import androidx.wear.protolayout.ModifiersBuilders.Semantics
@@ -31,6 +34,9 @@
/** Returns byte array representation of tag from String. */
internal fun String.toTagBytes(): ByteArray = toByteArray(StandardCharsets.UTF_8)
+/** Returns String representation of tag from Metadata. */
+internal fun ElementMetadata.toTagName(): String = String(tagData, StandardCharsets.UTF_8)
+
internal fun <T> Iterable<T>.addBetween(newItem: T): Sequence<T> = sequence {
var isFirst = true
for (element in this@addBetween) {
@@ -59,3 +65,8 @@
internal fun Int.toDp() = dp(this.toFloat())
internal fun String.toElementMetadata() = ElementMetadata.Builder().setTagData(toTagBytes()).build()
+
+/** Builds a horizontal Spacer, with width set to expand and height set to the given value. */
+internal fun horizontalSpacer(@Dimension(unit = DP) heightDp: Int): Spacer {
+ return Spacer.Builder().setWidth(expand()).setHeight(dp(heightDp.toFloat())).build()
+}
diff --git a/wear/protolayout/protolayout-material3/src/main/java/androidx/wear/protolayout/material3/PrimaryLayout.kt b/wear/protolayout/protolayout-material3/src/main/java/androidx/wear/protolayout/material3/PrimaryLayout.kt
new file mode 100644
index 0000000..a9c41a0
--- /dev/null
+++ b/wear/protolayout/protolayout-material3/src/main/java/androidx/wear/protolayout/material3/PrimaryLayout.kt
@@ -0,0 +1,409 @@
+/*
+ * 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.wear.protolayout.material3
+
+import androidx.annotation.Dimension
+import androidx.annotation.Dimension.Companion.DP
+import androidx.annotation.VisibleForTesting
+import androidx.wear.protolayout.DimensionBuilders
+import androidx.wear.protolayout.DimensionBuilders.DpProp
+import androidx.wear.protolayout.DimensionBuilders.dp
+import androidx.wear.protolayout.DimensionBuilders.expand
+import androidx.wear.protolayout.DimensionBuilders.wrap
+import androidx.wear.protolayout.LayoutElementBuilders.Box
+import androidx.wear.protolayout.LayoutElementBuilders.Column
+import androidx.wear.protolayout.LayoutElementBuilders.LayoutElement
+import androidx.wear.protolayout.ModifiersBuilders
+import androidx.wear.protolayout.ModifiersBuilders.Clickable
+import androidx.wear.protolayout.ModifiersBuilders.ElementMetadata
+import androidx.wear.protolayout.ModifiersBuilders.Modifiers
+import androidx.wear.protolayout.ModifiersBuilders.Padding
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.BOTTOM_EDGE_BUTTON_TOP_MARGIN_DP
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.BOTTOM_SLOT_EMPTY_MARGIN_BOTTOM_PERCENTAGE
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.BOTTOM_SLOT_OTHER_MARGIN_SIDE_PERCENTAGE
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.BOTTOM_SLOT_OTHER_NO_LABEL_MARGIN_BOTTOM_PERCENTAGE
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.BOTTOM_SLOT_OTHER_NO_LABEL_MARGIN_TOP_PERCENTAGE
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.BOTTOM_SLOT_OTHER_WITH_LABEL_MARGIN_BOTTOM_PERCENTAGE
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.BOTTOM_SLOT_OTHER_WITH_LABEL_MARGIN_TOP_PERCENTAGE
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.FOOTER_LABEL_SLOT_MARGIN_SIDE_PERCENTAGE
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.FOOTER_LABEL_TO_BOTTOM_SLOT_SPACER_HEIGHT_DP
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.HEADER_ICON_SIZE_DP
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.HEADER_ICON_TITLE_SPACER_HEIGHT_DP
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.HEADER_MARGIN_BOTTOM_DP
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.HEADER_MARGIN_SIDE_PERCENTAGE
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.HEADER_MARGIN_TOP_DP
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.MAIN_SLOT_WITHOUT_BOTTOM_SLOT_WITHOUT_TITLE_MARGIN_SIDE_PERCENTAGE
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.MAIN_SLOT_WITHOUT_BOTTOM_SLOT_WITH_TITLE_MARGIN_SIDE_PERCENTAGE
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.MAIN_SLOT_WITH_BOTTOM_SLOT_WITHOUT_TITLE_MARGIN_SIDE_PERCENTAGE
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.MAIN_SLOT_WITH_BOTTOM_SLOT_WITH_TITLE_MARGIN_SIDE_PERCENTAGE
+import androidx.wear.protolayout.material3.PrimaryLayoutDefaults.METADATA_TAG
+
+/**
+ * ProtoLayout Material3 full screen layout that represents a suggested Material3 layout style that
+ * is responsive and takes care of the elements placement, together with the recommended margin and
+ * padding applied.
+ *
+ * This layout is meant to occupy the whole screen, so nothing else should be added on top of it.
+ *
+ * On the top, there is an icon that will be automatically placed by the system, followed by the
+ * optional title slot. The icon slot needs to be reserved for the whole ProtoLayout Layout and no
+ * other content should be added at the top of the screen as it will be overlapped with the system
+ * placed icon.
+ *
+ * At the bottom, there is an optional fixed slot for either {@link EdgeButton} as a main action or
+ * small non tappable content.
+ *
+ * The middle of the layout is main content, that will fill the available space. For the best
+ * results across different screen sizes, it's recommended that this content's dimension are also
+ * [DimensionBuilders.expand] or [DimensionBuilders.weight]. Additional content in the main one can
+ * be added after a `225dp` breakpoint.
+ *
+ * @param mainSlot The main, central content for this layout. It's recommended for this content to
+ * fill the available width and height for the best result across different screen size. This
+ * layout places proper padding to prevent content from being cropped by the screen. Note that
+ * depending on the corner shapes and different elements on the screen, there might be a need to
+ * change padding on some of the elements in this slot. The content passed here can also have an
+ * additional content value added to it, after `225dp` breakpoint. Some of the examples of content
+ * that can be passed in here are:
+ * * [buttonGroup] with buttons or cards
+ * * two [buttonGroup
+ * * Expanded card
+ *
+ * @param titleSlot The app title in the top slot, just below the icon. This should be one line of
+ * [text] with [Typography.TITLE_SMALL] typography, describing the main purpose of this layout.
+ * Title is an optional slot which can be omitted to make space for other elements. Defaults to
+ * [ColorTokens.ON_BACKGROUND] color.
+ * @param bottomSlot The content for bottom slot in this layout, that will be anchored to the bottom
+ * edge of the screen. This should be either a small non tappable content such as Text with
+ * optional label for it or tappable main action with [textEdgeButton] or [iconEdgeButton] which
+ * is designed to have its bottom following the screen's curvature. This bottom slot is optional,
+ * if unset the main content will expand more towards the edge of the screen.
+ * @param labelForBottomSlot The label displayed just above the [bottomSlot]. Default will be one
+ * line of [text] with [Typography.TITLE_SMALL] typography, [ColorTokens.ON_SURFACE] color that
+ * should contain additional description of this layout. When the [bottomSlot] is not provided or
+ * it an edge button, the given label will be ignored.
+ * @param onClick The clickable action for whole layout. If any area (outside of other added
+ * tappable components) is clicked, it will fire the associated action.
+ * @sample androidx.wear.protolayout.material3.samples.topLeveLayout
+ */
+// TODO: b/356568440 - Add sample above and put it in a proper samples file and link with @sample
+// TODO: b/346958146 - Link visuals once they are available.
+// TODO: b/353247528 - Handle the icon.
+// TODO: b/369162409 -Allow side and bottom margins in PrimaryLayout to be customizable.
+// TODO: b/370976767 - Specify that this should be used with MaterialTileService.
+public fun MaterialScope.primaryLayout(
+ mainSlot: (MaterialScope.() -> LayoutElement),
+ titleSlot: (MaterialScope.() -> LayoutElement)? = null,
+ bottomSlot: (MaterialScope.() -> LayoutElement)? = null,
+ labelForBottomSlot: (MaterialScope.() -> LayoutElement)? = null,
+ onClick: Clickable? = null
+): LayoutElement =
+ primaryLayoutWithOverrideIcon(
+ overrideIcon = false,
+ titleSlot = titleSlot,
+ mainSlot = mainSlot,
+ bottomSlot = bottomSlot,
+ labelForBottomSlot = labelForBottomSlot,
+ onClick = onClick
+ )
+
+/**
+ * Overrides the icon slot by showing colors circle. For the rest, see [primaryLayout]. This should
+ * only be used for testing or building internal samples to validate the UI.
+ */
+// TODO: b/353247528 - Set as @VisibleForTesting only.
+internal fun MaterialScope.primaryLayoutWithOverrideIcon(
+ overrideIcon: Boolean,
+ titleSlot: (MaterialScope.() -> LayoutElement)? = null,
+ mainSlot: (MaterialScope.() -> LayoutElement)? = null,
+ bottomSlot: (MaterialScope.() -> LayoutElement)? = null,
+ labelForBottomSlot: (MaterialScope.() -> LayoutElement)? = null,
+ onClick: Clickable? = null,
+): LayoutElement {
+ val screenWidth = deviceConfiguration.screenWidthDp
+ val screenHeight = deviceConfiguration.screenHeightDp
+ val labelSlot: LayoutElement? =
+ labelForBottomSlot?.let {
+ withStyle(
+ defaultTextElementStyle =
+ TextElementStyle(
+ typography = Typography.TITLE_SMALL,
+ color = getColorProp(ColorTokens.ON_SURFACE)
+ )
+ )
+ .labelForBottomSlot()
+ }
+
+ val modifiers =
+ Modifiers.Builder()
+ .setMetadata(ElementMetadata.Builder().setTagData(METADATA_TAG.toTagBytes()).build())
+
+ onClick?.apply { modifiers.setClickable(this) }
+
+ val mainSlotSideMargin: DpProp =
+ dp(
+ screenWidth *
+ if (bottomSlot != null)
+ (if (titleSlot != null)
+ MAIN_SLOT_WITH_BOTTOM_SLOT_WITH_TITLE_MARGIN_SIDE_PERCENTAGE
+ else MAIN_SLOT_WITH_BOTTOM_SLOT_WITHOUT_TITLE_MARGIN_SIDE_PERCENTAGE)
+ else
+ (if (titleSlot != null)
+ MAIN_SLOT_WITHOUT_BOTTOM_SLOT_WITH_TITLE_MARGIN_SIDE_PERCENTAGE
+ else MAIN_SLOT_WITHOUT_BOTTOM_SLOT_WITHOUT_TITLE_MARGIN_SIDE_PERCENTAGE)
+ )
+
+ val mainLayout =
+ Column.Builder()
+ .setModifiers(modifiers.build())
+ .setWidth(screenWidth.toDp())
+ .setHeight(screenHeight.toDp())
+ // Contains icon and optional title.
+ .addContent(
+ getHeaderContent(
+ titleSlot?.let {
+ withStyle(
+ defaultTextElementStyle =
+ TextElementStyle(
+ typography = Typography.TITLE_SMALL,
+ color = getColorProp(ColorTokens.ON_BACKGROUND)
+ )
+ )
+ .titleSlot()
+ },
+ overrideIcon
+ )
+ )
+ // Contains main content. This Box is needed to set to expand, even if empty so it
+ // fills the empty space until bottom content.
+
+ mainSlot?.let { mainLayout.addContent(mainSlot().getMainContentBox(mainSlotSideMargin)) }
+
+ bottomSlot?.let {
+ // Contains bottom slot, optional label or needed padding if empty.
+ mainLayout.addContent(getFooterContent(bottomSlot(), labelSlot))
+ }
+
+ return mainLayout.build()
+}
+
+private fun MaterialScope.getIconPlaceholder(overrideIcon: Boolean): LayoutElement {
+ val iconSlot =
+ Box.Builder().setWidth(HEADER_ICON_SIZE_DP.toDp()).setHeight(HEADER_ICON_SIZE_DP.toDp())
+ if (overrideIcon) {
+ iconSlot.setModifiers(
+ Modifiers.Builder()
+ .setBackground(
+ ModifiersBuilders.Background.Builder()
+ .setCorner(getCorner(Shape.CORNER_FULL))
+ .setColor(getColorProp(ColorTokens.ON_BACKGROUND))
+ .build()
+ )
+ .build()
+ )
+ }
+ return iconSlot.build()
+}
+
+/** Returns header content with the mandatory icon and optional title. */
+private fun MaterialScope.getHeaderContent(
+ titleSlot: LayoutElement?,
+ overrideIcon: Boolean
+): Column {
+ val headerBuilder =
+ Column.Builder()
+ .setWidth(wrap())
+ .setHeight(wrap())
+ .setModifiers(Modifiers.Builder().setPadding(getMarginForHeader()).build())
+ .addContent(getIconPlaceholder(overrideIcon))
+
+ titleSlot?.apply {
+ headerBuilder
+ .addContent(horizontalSpacer(HEADER_ICON_TITLE_SPACER_HEIGHT_DP))
+ .addContent(titleSlot)
+ }
+
+ return headerBuilder.build()
+}
+
+/** Returns central slot with the optional main content. It expands to fill the available space. */
+private fun LayoutElement.getMainContentBox(sideMargin: DpProp): Box =
+ Box.Builder()
+ .setWidth(expand())
+ .setHeight(expand())
+ .setModifiers(
+ Modifiers.Builder()
+ .setPadding(
+ Padding.Builder() // Top and bottom space has been added to other elements.
+ .setStart(sideMargin)
+ .setEnd(sideMargin)
+ .build()
+ )
+ .build()
+ )
+ .addContent(this)
+ .build()
+
+/**
+ * Returns the footer content, containing bottom slot and optional label with the corresponding
+ * spacing and margins depending on what is that content, or Box with padding if there's no bottom
+ * slot.
+ */
+private fun MaterialScope.getFooterContent(
+ bottomSlot: LayoutElement?,
+ labelSlot: LayoutElement?
+): LayoutElement {
+ val footer = Box.Builder().setWidth(wrap()).setHeight(wrap())
+
+ if (bottomSlot == null) {
+ footer.setWidth(expand())
+ footer.setHeight(
+ dp(BOTTOM_SLOT_EMPTY_MARGIN_BOTTOM_PERCENTAGE * deviceConfiguration.screenHeightDp)
+ )
+ } else if (bottomSlot.isSlotEdgeButton()) {
+ // Label shouldn't be used with EdgeButton.
+ footer.setModifiers(
+ Modifiers.Builder()
+ .setPadding(
+ Padding.Builder().setTop(BOTTOM_EDGE_BUTTON_TOP_MARGIN_DP.toDp()).build()
+ )
+ .build()
+ )
+
+ footer.addContent(bottomSlot)
+ } else {
+ val otherBottomSlot = Column.Builder().setWidth(wrap()).setHeight(wrap())
+
+ footer.setModifiers(
+ Modifiers.Builder()
+ .setPadding(
+ Padding.Builder()
+ .setTop(
+ dp(
+ (if (labelSlot == null)
+ BOTTOM_SLOT_OTHER_NO_LABEL_MARGIN_TOP_PERCENTAGE
+ else BOTTOM_SLOT_OTHER_WITH_LABEL_MARGIN_TOP_PERCENTAGE) *
+ deviceConfiguration.screenHeightDp
+ )
+ )
+ .setBottom(
+ dp(
+ (if (labelSlot == null)
+ BOTTOM_SLOT_OTHER_NO_LABEL_MARGIN_BOTTOM_PERCENTAGE
+ else BOTTOM_SLOT_OTHER_WITH_LABEL_MARGIN_BOTTOM_PERCENTAGE) *
+ deviceConfiguration.screenHeightDp
+ )
+ )
+ .build()
+ )
+ .build()
+ )
+
+ labelSlot?.apply {
+ otherBottomSlot
+ .addContent(
+ generateLabelContent(
+ dp(
+ FOOTER_LABEL_SLOT_MARGIN_SIDE_PERCENTAGE *
+ deviceConfiguration.screenWidthDp
+ )
+ )
+ )
+ .addContent(horizontalSpacer(FOOTER_LABEL_TO_BOTTOM_SLOT_SPACER_HEIGHT_DP))
+ }
+
+ footer.addContent(
+ otherBottomSlot
+ .addContent(
+ bottomSlot.generateBottomSlotContent(
+ dp(
+ BOTTOM_SLOT_OTHER_MARGIN_SIDE_PERCENTAGE *
+ deviceConfiguration.screenWidthDp
+ )
+ )
+ )
+ .build()
+ )
+ }
+
+ return footer.build()
+}
+
+private fun LayoutElement.generateBottomSlotContent(sidePadding: DpProp): LayoutElement =
+ Box.Builder()
+ .setModifiers(
+ Modifiers.Builder()
+ .setPadding(Padding.Builder().setStart(sidePadding).setEnd(sidePadding).build())
+ .build()
+ )
+ .addContent(this)
+ .build()
+
+private fun LayoutElement.generateLabelContent(sidePadding: DpProp): LayoutElement =
+ Box.Builder()
+ .setModifiers(
+ Modifiers.Builder()
+ .setPadding(Padding.Builder().setStart(sidePadding).setEnd(sidePadding).build())
+ .build()
+ )
+ .addContent(this)
+ .build()
+
+private fun MaterialScope.getMarginForHeader(): Padding {
+ return Padding.Builder()
+ .setTop(HEADER_MARGIN_TOP_DP.toDp())
+ .setBottom(HEADER_MARGIN_BOTTOM_DP.toDp())
+ .setStart(dp(HEADER_MARGIN_SIDE_PERCENTAGE * deviceConfiguration.screenWidthDp))
+ .setEnd(dp(HEADER_MARGIN_SIDE_PERCENTAGE * deviceConfiguration.screenWidthDp))
+ .build()
+}
+
+/** Contains the default values used by Material layout. */
+internal object PrimaryLayoutDefaults {
+ /** Tool tag for Metadata in Modifiers, so we know that Row is actually a PrimaryLayout. */
+ @VisibleForTesting const val METADATA_TAG: String = "PL"
+
+ @Dimension(unit = DP) const val HEADER_MARGIN_TOP_DP: Int = 3
+
+ @Dimension(unit = DP) const val HEADER_MARGIN_BOTTOM_DP: Int = 6
+
+ const val HEADER_MARGIN_SIDE_PERCENTAGE: Float = 14.5f / 100
+
+ @Dimension(unit = DP) const val HEADER_ICON_SIZE_DP: Int = 24
+
+ @Dimension(unit = DP) const val HEADER_ICON_TITLE_SPACER_HEIGHT_DP: Int = 2
+
+ // The remaining margins around EdgeButton are within the component itself.
+ @Dimension(unit = DP) const val BOTTOM_EDGE_BUTTON_TOP_MARGIN_DP: Int = 4
+
+ const val BOTTOM_SLOT_OTHER_NO_LABEL_MARGIN_TOP_PERCENTAGE: Float = 4f / 100
+ const val BOTTOM_SLOT_OTHER_NO_LABEL_MARGIN_BOTTOM_PERCENTAGE: Float = 8.3f / 100
+
+ const val BOTTOM_SLOT_OTHER_WITH_LABEL_MARGIN_TOP_PERCENTAGE: Float = 3f / 100
+ const val BOTTOM_SLOT_OTHER_WITH_LABEL_MARGIN_BOTTOM_PERCENTAGE: Float = 5f / 100
+ const val BOTTOM_SLOT_OTHER_MARGIN_SIDE_PERCENTAGE: Float = 26f / 100
+
+ @Dimension(unit = DP) const val FOOTER_LABEL_TO_BOTTOM_SLOT_SPACER_HEIGHT_DP: Int = 2
+
+ const val FOOTER_LABEL_SLOT_MARGIN_SIDE_PERCENTAGE: Float = 16.64f / 100
+
+ const val BOTTOM_SLOT_EMPTY_MARGIN_BOTTOM_PERCENTAGE: Float = 14f / 100
+ const val MAIN_SLOT_WITH_BOTTOM_SLOT_WITH_TITLE_MARGIN_SIDE_PERCENTAGE: Float = 3f / 100
+ const val MAIN_SLOT_WITH_BOTTOM_SLOT_WITHOUT_TITLE_MARGIN_SIDE_PERCENTAGE: Float = 6f / 100
+ const val MAIN_SLOT_WITHOUT_BOTTOM_SLOT_WITH_TITLE_MARGIN_SIDE_PERCENTAGE: Float = 7.3f / 100
+ const val MAIN_SLOT_WITHOUT_BOTTOM_SLOT_WITHOUT_TITLE_MARGIN_SIDE_PERCENTAGE: Float = 8.3f / 100
+}