Merge "Rename PUBLISHED_KOTLIN_ONLY_LIBRARY for clarity and simplify optimising for kotlin only consumers" into androidx-main
diff --git a/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/text/BasicTextField2ToggleTextBenchmark.kt b/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/text/BasicTextField2ToggleTextBenchmark.kt
new file mode 100644
index 0000000..bb2d93c
--- /dev/null
+++ b/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/text/BasicTextField2ToggleTextBenchmark.kt
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.foundation.benchmark.text
+
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.testutils.benchmark.ComposeBenchmarkRule
+import androidx.compose.testutils.benchmark.benchmarkFirstCompose
+import androidx.compose.testutils.benchmark.benchmarkFirstDraw
+import androidx.compose.testutils.benchmark.benchmarkFirstLayout
+import androidx.compose.testutils.benchmark.benchmarkFirstMeasure
+import androidx.compose.testutils.benchmark.benchmarkLayoutPerf
+import androidx.compose.testutils.benchmark.toggleStateBenchmarkDraw
+import androidx.compose.testutils.benchmark.toggleStateBenchmarkLayout
+import androidx.compose.testutils.benchmark.toggleStateBenchmarkMeasure
+import androidx.compose.testutils.benchmark.toggleStateBenchmarkRecompose
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.text.benchmark.TextBenchmarkTestRule
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.test.filters.SmallTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.RuleChain
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@SmallTest
+@RunWith(Parameterized::class)
+class BasicTextField2ToggleTextBenchmark(
+ private val textLength: Int
+) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "length={0}")
+ fun initParameters(): Array<Any> = arrayOf(32, 512).filterForCi()
+ }
+
+ private val textBenchmarkRule = TextBenchmarkTestRule()
+ private val benchmarkRule = ComposeBenchmarkRule()
+
+ @get:Rule
+ val testRule = RuleChain
+ .outerRule(textBenchmarkRule)
+ .around(benchmarkRule)
+
+ private val width = textBenchmarkRule.widthDp.dp
+ private val fontSize = textBenchmarkRule.fontSizeSp.sp
+
+ private val caseFactory = {
+ textBenchmarkRule.generator { generator ->
+ BasicTextField2ToggleTextTestCase(
+ textGenerator = generator,
+ textLength = textLength,
+ textNumber = textBenchmarkRule.repeatTimes,
+ width = width,
+ fontSize = fontSize
+ )
+ }
+ }
+
+ /**
+ * Measure the time taken to compose a [BasicTextField] composable from scratch with the
+ * given input. This is the time taken to call the [BasicTextField] composable function.
+ */
+ @Test
+ fun first_compose() {
+ benchmarkRule.benchmarkFirstCompose(caseFactory)
+ }
+
+ /**
+ * Measure the time taken by the first time measure the [BasicTextField] composable with the
+ * given input. This is mainly the time used to measure all the [Measurable]s in the
+ * [BasicTextField] composable.
+ */
+ @Test
+ fun first_measure() {
+ benchmarkRule.benchmarkFirstMeasure(caseFactory)
+ }
+
+ /**
+ * Measure the time taken by the first time layout the [BasicTextField] composable with the
+ * given input.
+ */
+ @Test
+ fun first_layout() {
+ benchmarkRule.benchmarkFirstLayout(caseFactory)
+ }
+
+ /**
+ * Measure the time taken by first time draw the [BasicTextField] composable with the given
+ * input.
+ */
+ @Test
+ fun first_draw() {
+ benchmarkRule.benchmarkFirstDraw(caseFactory)
+ }
+
+ /**
+ * Measure the time taken by layout the [BasicTextField] composable after the layout
+ * constrains changed. This is mainly the time used to re-measure and re-layout the composable.
+ */
+ @Test
+ fun layout() {
+ benchmarkRule.benchmarkLayoutPerf(caseFactory)
+ }
+
+ /**
+ * Measure the time taken to recompose the [BasicTextField] composable when text gets toggled.
+ */
+ @Test
+ fun toggleText_recompose() {
+ benchmarkRule.toggleStateBenchmarkRecompose(caseFactory, requireRecomposition = false)
+ }
+
+ /**
+ * Measure the time taken to measure the [BasicTextField] composable when text gets toggled.
+ */
+ @Test
+ fun toggleText_measure() {
+ benchmarkRule.toggleStateBenchmarkMeasure(
+ caseFactory = caseFactory,
+ toggleCausesRecompose = false,
+ assertOneRecomposition = false
+ )
+ }
+
+ /**
+ * Measure the time taken to layout the [BasicTextField] composable when text gets toggled.
+ */
+ @Test
+ fun toggleText_layout() {
+ benchmarkRule.toggleStateBenchmarkLayout(
+ caseFactory = caseFactory,
+ assertOneRecomposition = false,
+ toggleCausesRecompose = false
+ )
+ }
+
+ /**
+ * Measure the time taken to draw the [BasicTextField] composable when text gets toggled.
+ */
+ @Test
+ fun toggleText_draw() {
+ benchmarkRule.toggleStateBenchmarkDraw(
+ caseFactory = caseFactory,
+ toggleCausesRecompose = false,
+ assertOneRecomposition = false
+ )
+ }
+}
diff --git a/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/text/BasicTextField2ToggleTextTestCase.kt b/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/text/BasicTextField2ToggleTextTestCase.kt
new file mode 100644
index 0000000..c22686e
--- /dev/null
+++ b/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/text/BasicTextField2ToggleTextTestCase.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:Suppress("DEPRECATION")
+
+package androidx.compose.foundation.benchmark.text
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.testutils.LayeredComposeTestCase
+import androidx.compose.testutils.ToggleableTestCase
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.InterceptPlatformTextInput
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.benchmark.RandomTextGenerator
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import kotlinx.coroutines.awaitCancellation
+
+@OptIn(ExperimentalComposeUiApi::class)
+class BasicTextField2ToggleTextTestCase(
+ private val textGenerator: RandomTextGenerator,
+ private val textLength: Int,
+ private val textNumber: Int,
+ private val width: Dp,
+ private val fontSize: TextUnit
+) : LayeredComposeTestCase(), ToggleableTestCase {
+
+ private val states = List(textNumber) {
+ TextFieldState(textGenerator.nextParagraph(length = textLength))
+ }
+
+ @Composable
+ override fun MeasuredContent() {
+ for (state in states) {
+ BasicTextField(
+ state = state,
+ textStyle = TextStyle(color = Color.Black, fontSize = fontSize),
+ modifier = Modifier
+ .background(color = Color.Cyan)
+ .requiredWidth(width)
+ )
+ }
+ }
+
+ @Composable
+ override fun ContentWrappers(content: @Composable () -> Unit) {
+ Column(
+ modifier = Modifier
+ .width(width)
+ .verticalScroll(rememberScrollState())
+ ) {
+ InterceptPlatformTextInput(
+ interceptor = { _, _ -> awaitCancellation() },
+ content = content
+ )
+ }
+ }
+
+ override fun toggleState() {
+ states.forEach {
+ it.setTextAndPlaceCursorAtEnd(textGenerator.nextParagraph(length = textLength))
+ }
+ }
+}
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextFieldOutputTransformationDemos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextFieldOutputTransformationDemos.kt
index 9b4690a..a476523 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextFieldOutputTransformationDemos.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextFieldOutputTransformationDemos.kt
@@ -195,7 +195,9 @@
}
if (middleWedge) {
val index = asCharSequence().indexOf("ghi")
- insert(index, middle.text.toString())
+ if (index > 0) {
+ insert(index, middle.text.toString())
+ }
}
if (suffixEnabled) {
append(suffix.text)
diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt
index b90192b..237e51f 100644
--- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt
+++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt
@@ -596,6 +596,48 @@
}
@Test
+ fun moveItemToTheTop_itemWithMoreChildren_outsideBounds_shouldNotCrash() {
+ var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5))
+ val listSize = itemSize * 3
+ val listSizeDp = with(rule.density) { listSize.toDp() }
+ rule.setContent {
+ LazyList(
+ maxSize = listSizeDp,
+ startIndex = 3
+ ) {
+ items(list, key = { it }) {
+ Item(it)
+ if (it != list.last()) {
+ Box(modifier = Modifier)
+ }
+ }
+ }
+ }
+
+ assertPositions(
+ 3 to 0f,
+ 4 to itemSize,
+ 5 to itemSize * 2
+ )
+
+ // move item 5 out of bounds
+ rule.runOnUiThread {
+ list = listOf(5, 0, 1, 2, 3, 4)
+ }
+
+ // should not crash
+ onAnimationFrame { fraction ->
+ if (fraction == 1.0f) {
+ assertPositions(
+ 2 to 0f,
+ 3 to itemSize,
+ 4 to itemSize * 2
+ )
+ }
+ }
+ }
+
+ @Test
fun moveItemToTheBottomOutsideOfBounds_withSpacing() {
var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5))
val listSize = itemSize * 3 + spacing * 2
diff --git a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/LazyDslSamples.kt b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/LazyDslSamples.kt
index 3b003f7..d57ab48 100644
--- a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/LazyDslSamples.kt
+++ b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/LazyDslSamples.kt
@@ -96,7 +96,10 @@
stickyHeader {
Text(
"Section $section",
- Modifier.fillMaxWidth().background(Color.LightGray).padding(8.dp)
+ Modifier
+ .fillMaxWidth()
+ .background(Color.LightGray)
+ .padding(8.dp)
)
}
items(10) {
@@ -109,9 +112,9 @@
@Sampled
@Composable
fun AnimateItemSample() {
- var list by remember { mutableStateOf(listOf("A", "B", "C")) }
+ var list by remember { mutableStateOf(listOf("1", "2", "3")) }
Column {
- Button(onClick = { list = list + "D" }) {
+ Button(onClick = { list = list + "${list.count() + 1}" }) {
Text("Add new item")
}
Button(onClick = { list = list.shuffled() }) {
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/MagnifierTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/MagnifierTest.kt
index c1da808..c49618d5 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/MagnifierTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/MagnifierTest.kt
@@ -409,6 +409,10 @@
fun platformMagnifierModifier_updatesProperties_whenZoomChanged() {
var zoom by mutableStateOf(1f)
val platformMagnifier = CountingPlatformMagnifier()
+ val factory = PlatformMagnifierFactory(
+ platformMagnifier,
+ canUpdateZoom = true
+ )
rule.setContent {
Box(
Modifier.magnifier(
@@ -416,10 +420,7 @@
magnifierCenter = { Offset.Unspecified },
zoom = zoom,
onSizeChanged = null,
- platformMagnifierFactory = PlatformMagnifierFactory(
- platformMagnifier,
- canUpdateZoom = true
- )
+ platformMagnifierFactory = factory
)
)
}
@@ -556,7 +557,7 @@
@SdkSuppress(minSdkVersion = 28)
@Test
- fun platformMagnifierModifier_firesOnSizeChanged_initially_whenSourceCenterUnspecified() {
+ fun platformMagnifierModifier_doesNotFireOnSizeChanged_initially_whenSourceCenterUnspecified() {
val magnifierSize = IntSize(10, 11)
val sizeEvents = mutableListOf<DpSize>()
val platformMagnifier = CountingPlatformMagnifier().apply {
@@ -574,6 +575,34 @@
)
}
+ rule.runOnIdle { assertThat(sizeEvents).isEmpty() }
+ }
+
+ @SdkSuppress(minSdkVersion = 28)
+ @Test
+ fun platformMagnifierModifier_firesOnSizeChanged_afterSourceCenterIsSpecified() {
+ val magnifierSize = IntSize(10, 11)
+ val sizeEvents = mutableListOf<DpSize>()
+ val platformMagnifier = CountingPlatformMagnifier().apply {
+ size = magnifierSize
+ }
+ var sourceCenter by mutableStateOf(Offset.Unspecified)
+ rule.setContent {
+ Box(
+ Modifier.magnifier(
+ sourceCenter = { sourceCenter },
+ magnifierCenter = { Offset.Unspecified },
+ zoom = Float.NaN,
+ onSizeChanged = { sizeEvents += it },
+ platformMagnifierFactory = PlatformMagnifierFactory(platformMagnifier)
+ )
+ )
+ }
+
+ rule.runOnIdle { assertThat(sizeEvents).isEmpty() }
+
+ sourceCenter = Offset(1f, 1f)
+
rule.runOnIdle {
assertThat(sizeEvents).containsExactly(
with(rule.density) {
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingGestureTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingGestureTest.kt
index 5b1a62f..37c4219 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingGestureTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingGestureTest.kt
@@ -23,22 +23,31 @@
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InsertGesture
import android.view.inputmethod.JoinOrSplitGesture
+import android.view.inputmethod.PreviewableHandwritingGesture
import android.view.inputmethod.RemoveSpaceGesture
import android.view.inputmethod.SelectGesture
import android.view.inputmethod.SelectRangeGesture
import androidx.annotation.RequiresApi
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.setFocusableContent
import androidx.compose.foundation.text.input.InputMethodInterceptor
+import androidx.compose.foundation.text.selection.LocalTextSelectionColors
+import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
+import androidx.compose.testutils.assertPixelColor
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toAndroidRectF
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalTextToolbar
@@ -47,12 +56,15 @@
import androidx.compose.ui.platform.TextToolbarStatus
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.requestFocus
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
+import androidx.core.graphics.ColorUtils
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
@@ -72,6 +84,10 @@
private val Tag = "CoreTextField"
+ private val backgroundColor = Color.Red
+ private val textColor = Color.Green
+ private val selectionColor = Color.Blue
+
private val lineMargin = 16f
@Test
@@ -96,6 +112,31 @@
}
@Test
+ fun textField_selectGesture_preview_wordLevel() {
+ val text = "abc def ghi"
+ val initialCursor = 3
+ testHandwritingGesture(
+ text = text,
+ initialSelection = TextRange(initialCursor),
+ gestureFactory = { textLayoutResult ->
+ val localBoundingBox = textLayoutResult.boundingBoxOf("b")
+ val screenBoundingBox = localToScreen(localBoundingBox).toAndroidRectF()
+ SelectGesture.Builder()
+ .setSelectionArea(screenBoundingBox)
+ .setGranularity(HandwritingGesture.GRANULARITY_WORD)
+ .build()
+ },
+ preview = true,
+ imageAssertion = { imageBitmap, textLayoutResult ->
+ imageBitmap.assertSelectionPreviewHighlight(textLayoutResult, text.rangeOf("abc"))
+ }
+ ) { textFieldValue, _, _ ->
+ assertThat(textFieldValue.text).isEqualTo(text)
+ assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+ }
+ }
+
+ @Test
fun textField_selectGesture_characterLevel() {
val text = "abcdef"
testHandwritingGesture(
@@ -117,6 +158,31 @@
}
@Test
+ fun textField_selectGesture_preview_characterLevel() {
+ val text = "abc def ghi"
+ val initialCursor = 3
+ testHandwritingGesture(
+ text = text,
+ initialSelection = TextRange(initialCursor),
+ gestureFactory = { textLayoutResult ->
+ val localBoundingBox = textLayoutResult.boundingBoxOf("bc")
+ val screenBoundingBox = localToScreen(localBoundingBox).toAndroidRectF()
+ SelectGesture.Builder()
+ .setSelectionArea(screenBoundingBox)
+ .setGranularity(HandwritingGesture.GRANULARITY_CHARACTER)
+ .build()
+ },
+ preview = true,
+ imageAssertion = { imageBitmap, textLayoutResult ->
+ imageBitmap.assertSelectionPreviewHighlight(textLayoutResult, text.rangeOf("bc"))
+ }
+ ) { textFieldValue, _, _ ->
+ assertThat(textFieldValue.text).isEqualTo(text)
+ assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+ }
+ }
+
+ @Test
fun textField_selectGesture_characterLevel_noSelection_insertFallbackText() {
val text = "abcdef"
val fallback = "fallbackText"
@@ -161,6 +227,29 @@
}
@Test
+ fun textField_selectGesture_preview_characterLevel_noSelection() {
+ val text = "abcdef"
+ val initialCursor = 3
+ testHandwritingGesture(
+ text = text,
+ initialSelection = TextRange(initialCursor),
+ gestureFactory = { _ ->
+ SelectGesture.Builder()
+ .setSelectionArea(RectF(0f, 0f, 1f, 1f))
+ .setGranularity(HandwritingGesture.GRANULARITY_CHARACTER)
+ .build()
+ },
+ preview = true,
+ imageAssertion = { imageBitmap, textLayoutResult ->
+ imageBitmap.assertNoHighlight(textLayoutResult)
+ }
+ ) { textFieldValue, _, _ ->
+ assertThat(textFieldValue.text).isEqualTo(text)
+ assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+ }
+ }
+
+ @Test
fun textField_selectGesture_wordLevel_noSelection_insertFallbackText() {
val text = "abc def ghi"
val fallback = "fallback"
@@ -211,6 +300,31 @@
}
@Test
+ fun textField_selectGesture_preview_wordLevel_noSelection() {
+ val text = "abc def ghi"
+ val initialCursor = 3
+ testHandwritingGesture(
+ text = text,
+ initialSelection = TextRange(initialCursor),
+ gestureFactory = { textLayoutResult ->
+ val localBoundingBox = textLayoutResult.boundingBoxOf("a")
+ val screenBoundingBox = localToScreen(localBoundingBox).toAndroidRectF()
+ SelectGesture.Builder()
+ .setSelectionArea(screenBoundingBox)
+ .setGranularity(HandwritingGesture.GRANULARITY_WORD)
+ .build()
+ },
+ preview = true,
+ imageAssertion = { imageBitmap, textLayoutResult ->
+ imageBitmap.assertNoHighlight(textLayoutResult)
+ }
+ ) { textFieldValue, _, _ ->
+ assertThat(textFieldValue.text).isEqualTo(text)
+ assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+ }
+ }
+
+ @Test
fun textField_deleteGesture_wordLevel_removeSpaceBeforeDeletion() {
val text = "abc def ghi"
testHandwritingGesture(
@@ -234,6 +348,31 @@
}
@Test
+ fun textField_deleteGesture_preview_wordLevel() {
+ val text = "abc def ghi"
+ val initialCursor = 3
+ testHandwritingGesture(
+ text = text,
+ initialSelection = TextRange(initialCursor),
+ gestureFactory = { textLayoutResult ->
+ val localBoundingBox = textLayoutResult.boundingBoxOf("h")
+ val screenBoundingBox = localToScreen(localBoundingBox).toAndroidRectF()
+ DeleteGesture.Builder()
+ .setDeletionArea(screenBoundingBox)
+ .setGranularity(HandwritingGesture.GRANULARITY_WORD)
+ .build()
+ },
+ preview = true,
+ imageAssertion = { imageBitmap, textLayoutResult ->
+ imageBitmap.assertDeletionPreviewHighlight(textLayoutResult, text.rangeOf("ghi"))
+ }
+ ) { textFieldValue, _, _ ->
+ assertThat(textFieldValue.text).isEqualTo(text)
+ assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+ }
+ }
+
+ @Test
fun textField_deleteGesture_wordLevel_onlyRemoveSpaceBeforeDeletion() {
val text = "abc\n def ghi"
testHandwritingGesture(
@@ -327,6 +466,31 @@
}
@Test
+ fun textField_deleteGesture_preview_characterLevel() {
+ val text = "abcdefghi"
+ val initialCursor = 3
+ testHandwritingGesture(
+ text = text,
+ initialSelection = TextRange(initialCursor),
+ gestureFactory = { textLayoutResult ->
+ val localBoundingBox = textLayoutResult.boundingBoxOf("def")
+ val screenBoundingBox = localToScreen(localBoundingBox).toAndroidRectF()
+ DeleteGesture.Builder()
+ .setDeletionArea(screenBoundingBox)
+ .setGranularity(HandwritingGesture.GRANULARITY_CHARACTER)
+ .build()
+ },
+ preview = true,
+ imageAssertion = { imageBitmap, textLayoutResult ->
+ imageBitmap.assertDeletionPreviewHighlight(textLayoutResult, text.rangeOf("def"))
+ }
+ ) { textFieldValue, _, _ ->
+ assertThat(textFieldValue.text).isEqualTo(text)
+ assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+ }
+ }
+
+ @Test
fun textField_deleteGesture_characterLevel_notRemoveSpaces() {
val text = "abcdef ghi"
testHandwritingGesture(
@@ -397,6 +561,29 @@
}
}
+ @Test
+ fun textField_deleteGesture_preview_noDeletion() {
+ val text = "abc def ghi"
+ val initialCursor = 3
+ testHandwritingGesture(
+ text = text,
+ initialSelection = TextRange(initialCursor),
+ gestureFactory = { _ ->
+ DeleteGesture.Builder()
+ .setDeletionArea(RectF(-1f, -1f, 0f, 0f))
+ .setGranularity(HandwritingGesture.GRANULARITY_WORD)
+ .build()
+ },
+ preview = true,
+ imageAssertion = { imageBitmap, textLayoutResult ->
+ imageBitmap.assertNoHighlight(textLayoutResult)
+ }
+ ) { textFieldValue, _, _ ->
+ assertThat(textFieldValue.text).isEqualTo(text)
+ assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+ }
+ }
+
fun textField_selectRangeGesture_characterLevel() {
val text = "abc\ndef"
testHandwritingGesture(
@@ -425,6 +612,38 @@
}
@Test
+ fun textField_selectRangeGesture_preview_characterLevel() {
+ val text = "abc\ndef"
+ val initialCursor = 3
+ testHandwritingGesture(
+ text = text,
+ initialSelection = TextRange(initialCursor),
+ gestureFactory = { textLayoutResult ->
+ val startArea = textLayoutResult.boundingBoxOf("c").let {
+ localToScreen(it).toAndroidRectF()
+ }
+
+ val endArea = textLayoutResult.boundingBoxOf("d").let {
+ localToScreen(it).toAndroidRectF()
+ }
+
+ SelectRangeGesture.Builder()
+ .setSelectionStartArea(startArea)
+ .setSelectionEndArea(endArea)
+ .setGranularity(HandwritingGesture.GRANULARITY_CHARACTER)
+ .build()
+ },
+ preview = true,
+ imageAssertion = { imageBitmap, textLayoutResult ->
+ imageBitmap.assertSelectionPreviewHighlight(textLayoutResult, text.rangeOf("c\nd"))
+ }
+ ) { textFieldValue, _, _ ->
+ assertThat(textFieldValue.text).isEqualTo(text)
+ assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+ }
+ }
+
+ @Test
fun textField_selectRangeGesture_wordLevel() {
val text = "abc\ndef jhi"
testHandwritingGesture(
@@ -453,6 +672,41 @@
}
@Test
+ fun textField_selectRangeGesture_preview_wordLevel() {
+ val text = "abc\ndef jhi"
+ val initialCursor = 3
+ testHandwritingGesture(
+ text = text,
+ initialSelection = TextRange(initialCursor),
+ gestureFactory = { textLayoutResult ->
+ val startArea = textLayoutResult.boundingBoxOf("b").let {
+ localToScreen(it).toAndroidRectF()
+ }
+
+ val endArea = textLayoutResult.boundingBoxOf("e").let {
+ localToScreen(it).toAndroidRectF()
+ }
+
+ SelectRangeGesture.Builder()
+ .setSelectionStartArea(startArea)
+ .setSelectionEndArea(endArea)
+ .setGranularity(HandwritingGesture.GRANULARITY_WORD)
+ .build()
+ },
+ preview = true,
+ imageAssertion = { imageBitmap, textLayoutResult ->
+ imageBitmap.assertSelectionPreviewHighlight(
+ textLayoutResult,
+ text.rangeOf("abc\ndef")
+ )
+ }
+ ) { textFieldValue, _, _ ->
+ assertThat(textFieldValue.text).isEqualTo(text)
+ assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+ }
+ }
+
+ @Test
fun textField_selectRangeGesture_nothingSelectedInStartArea_insertFallbackText() {
val text = "abc\ndef"
val fallback = "fallbackText"
@@ -515,6 +769,35 @@
}
@Test
+ fun textField_selectRangeGesture_preview_nothingSelectedInStartArea() {
+ val text = "abc\ndef"
+ val initialCursor = 3
+ testHandwritingGesture(
+ text = text,
+ initialSelection = TextRange(initialCursor),
+ gestureFactory = { textLayoutResult ->
+ val endArea = textLayoutResult.boundingBoxOf("d").let {
+ localToScreen(it).toAndroidRectF()
+ }
+ // The startArea selects nothing, but the endArea contains one character, it
+ // should still fallback.
+ SelectRangeGesture.Builder()
+ .setSelectionStartArea(RectF(0f, 0f, 1f, 1f))
+ .setSelectionEndArea(endArea)
+ .setGranularity(HandwritingGesture.GRANULARITY_CHARACTER)
+ .build()
+ },
+ preview = true,
+ imageAssertion = { imageBitmap, textLayoutResult ->
+ imageBitmap.assertNoHighlight(textLayoutResult)
+ }
+ ) { textFieldValue, _, _ ->
+ assertThat(textFieldValue.text).isEqualTo(text)
+ assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+ }
+ }
+
+ @Test
fun textField_selectRangeGesture_noSelection_fail() {
val text = "abcdef"
testHandwritingGesture(
@@ -564,6 +847,37 @@
}
@Test
+ fun textField_deleteRangeGesture_preview_characterLevel() {
+ val text = "abc\ndef"
+ val initialCursor = 3
+ testHandwritingGesture(
+ text = text,
+ initialSelection = TextRange(initialCursor),
+ gestureFactory = { textLayoutResult ->
+ val startArea = textLayoutResult.boundingBoxOf("c").let {
+ localToScreen(it).toAndroidRectF()
+ }
+ val endArea = textLayoutResult.boundingBoxOf("d").let {
+ localToScreen(it).toAndroidRectF()
+ }
+
+ DeleteRangeGesture.Builder()
+ .setDeletionStartArea(startArea)
+ .setDeletionEndArea(endArea)
+ .setGranularity(HandwritingGesture.GRANULARITY_CHARACTER)
+ .build()
+ },
+ preview = true,
+ imageAssertion = { imageBitmap, textLayoutResult ->
+ imageBitmap.assertDeletionPreviewHighlight(textLayoutResult, text.rangeOf("c\nd"))
+ }
+ ) { textFieldValue, _, _ ->
+ assertThat(textFieldValue.text).isEqualTo(text)
+ assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+ }
+ }
+
+ @Test
fun textField_deleteRangeGesture_wordLevel() {
val text = "abc def\n jhi lmn"
testHandwritingGesture(
@@ -592,6 +906,39 @@
}
@Test
+ fun textField_deleteRangeGesture_preview_wordLevel() {
+ val text = "abc def\n jhi lmn"
+ val initialCursor = 3
+ testHandwritingGesture(
+ text = text,
+ initialSelection = TextRange(initialCursor),
+ gestureFactory = { textLayoutResult ->
+ val startArea = textLayoutResult.boundingBoxOf("e").let {
+ localToScreen(it).toAndroidRectF()
+ }
+ val endArea = textLayoutResult.boundingBoxOf("h").let {
+ localToScreen(it).toAndroidRectF()
+ }
+
+ DeleteRangeGesture.Builder()
+ .setDeletionStartArea(startArea)
+ .setDeletionEndArea(endArea)
+ .setGranularity(HandwritingGesture.GRANULARITY_WORD)
+ .build()
+ },
+ preview = true,
+ imageAssertion = { imageBitmap, textLayoutResult ->
+ imageBitmap.assertDeletionPreviewHighlight(
+ textLayoutResult, text.rangeOf("def\n jhi")
+ )
+ }
+ ) { textFieldValue, _, _ ->
+ assertThat(textFieldValue.text).isEqualTo(text)
+ assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+ }
+ }
+
+ @Test
fun textField_deleteRangeGesture_nothingDeletedInStartArea_insertFallbackText() {
val text = "abc\ndef"
val fallback = "fallbackText"
@@ -654,6 +1001,35 @@
}
@Test
+ fun textField_deleteRangeGesture_preview_nothingDeletedInStartArea() {
+ val text = "abc def\n jhi lmn"
+ val initialCursor = 3
+ testHandwritingGesture(
+ text = text,
+ initialSelection = TextRange(initialCursor),
+ gestureFactory = { textLayoutResult ->
+ val endArea = textLayoutResult.boundingBoxOf("d").let {
+ localToScreen(it).toAndroidRectF()
+ }
+ // The startArea selects nothing, but the endArea contains one character, it
+ // should still fallback.
+ DeleteRangeGesture.Builder()
+ .setDeletionStartArea(RectF(0f, 0f, 1f, 1f))
+ .setDeletionEndArea(endArea)
+ .setGranularity(HandwritingGesture.GRANULARITY_CHARACTER)
+ .build()
+ },
+ preview = true,
+ imageAssertion = { imageBitmap, textLayoutResult ->
+ imageBitmap.assertNoHighlight(textLayoutResult)
+ }
+ ) { textFieldValue, _, _ ->
+ assertThat(textFieldValue.text).isEqualTo(text)
+ assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+ }
+ }
+
+ @Test
fun textField_deleteRangeGesture_noDeletion_fail() {
val text = "abcdef"
testHandwritingGesture(
@@ -1268,6 +1644,8 @@
text: String,
initialSelection: TextRange = TextRange(text.length),
gestureFactory: LayoutCoordinates.(TextLayoutResult) -> HandwritingGesture,
+ preview: Boolean = false,
+ imageAssertion: ((ImageBitmap, TextLayoutResult) -> Unit)? = null,
assertion: (TextFieldValue, resultCode: Int, TextToolbar) -> Unit
) {
var textFieldValue by mutableStateOf(TextFieldValue(text, initialSelection))
@@ -1280,14 +1658,20 @@
override val handwritingGestureLineMargin: Float = lineMargin
}
CompositionLocalProvider(
+ LocalTextSelectionColors provides TextSelectionColors(
+ selectionColor,
+ selectionColor
+ ),
LocalTextToolbar provides textToolbar,
LocalViewConfiguration provides viewConfiguration
) {
CoreTextField(
value = textFieldValue,
onValueChange = { textFieldValue = it },
+ textStyle = TextStyle(color = textColor),
modifier = Modifier
.fillMaxSize()
+ .background(backgroundColor)
.testTag(Tag)
.onGloballyPositioned { layoutCoordinates = it },
onTextLayout = {
@@ -1303,12 +1687,20 @@
var resultCode = InputConnection.HANDWRITING_GESTURE_RESULT_UNKNOWN
inputMethodInterceptor.withInputConnection {
- performHandwritingGesture(gesture, /* executor= */null) { resultCode = it }
+ if (preview) {
+ previewHandwritingGesture(gesture as PreviewableHandwritingGesture, null)
+ } else {
+ performHandwritingGesture(gesture, /* executor= */ null) { resultCode = it }
+ }
}
rule.runOnIdle {
assertion.invoke(textFieldValue, resultCode, textToolbar)
}
+
+ if (imageAssertion != null) {
+ imageAssertion(rule.onNodeWithTag(Tag).captureToImage(), textLayoutResult!!)
+ }
}
private fun setContent(
@@ -1327,6 +1719,56 @@
return rect.translate(localOriginInScreen)
}
+ private fun ImageBitmap.assertSelectionPreviewHighlight(
+ textLayoutResult: TextLayoutResult,
+ range: TextRange
+ ) {
+ assertHighlight(textLayoutResult, range, selectionColor)
+ }
+
+ private fun ImageBitmap.assertDeletionPreviewHighlight(
+ textLayoutResult: TextLayoutResult,
+ range: TextRange
+ ) {
+ val deletionPreviewColor = textColor.copy(alpha = textColor.alpha * 0.2f)
+ val compositeColor = Color(
+ ColorUtils.compositeColors(
+ deletionPreviewColor.toArgb(),
+ backgroundColor.toArgb()
+ )
+ )
+ assertHighlight(textLayoutResult, range, compositeColor)
+ }
+
+ private fun ImageBitmap.assertNoHighlight(textLayoutResult: TextLayoutResult) {
+ assertHighlight(textLayoutResult, TextRange.Zero, Color.Unspecified)
+ }
+
+ private fun ImageBitmap.assertHighlight(
+ textLayoutResult: TextLayoutResult,
+ range: TextRange,
+ highlightColor: Color
+ ) {
+ val pixelMap =
+ toPixelMap(width = textLayoutResult.size.width, height = textLayoutResult.size.height)
+ for (offset in 0 until textLayoutResult.layoutInput.text.length) {
+ if (textLayoutResult.layoutInput.text[offset] == '\n') {
+ continue
+ }
+ // Check the top left pixel of each character (assumes LTR). This pixel is always part
+ // of the background (not part of the text foreground).
+ val line = textLayoutResult.multiParagraph.getLineForOffset(offset)
+ val lineTop = textLayoutResult.multiParagraph.getLineTop(line).ceilToIntPx()
+ val horizontal =
+ textLayoutResult.multiParagraph.getHorizontalPosition(offset, true).ceilToIntPx()
+ if (offset in range) {
+ pixelMap.assertPixelColor(highlightColor, horizontal, lineTop)
+ } else {
+ pixelMap.assertPixelColor(backgroundColor, horizontal, lineTop)
+ }
+ }
+ }
+
private fun FakeTextToolbar(): TextToolbar {
return object : TextToolbar {
private var _status: TextToolbarStatus = TextToolbarStatus.Hidden
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/TextFieldDelegateIntegrationTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/TextFieldDelegateIntegrationTest.kt
index 938bbf7..c5dd825 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/TextFieldDelegateIntegrationTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/TextFieldDelegateIntegrationTest.kt
@@ -70,15 +70,52 @@
TextFieldDelegate.draw(
canvas = actualCanvas,
value = TextFieldValue(text = "Hello, World", selection = selection),
- selectionPaint = Paint().apply { color = selectionColor },
+ selectionPreviewHighlightRange = TextRange.Zero,
+ deletionPreviewHighlightRange = TextRange.Zero,
offsetMapping = OffsetMapping.Identity,
- textLayoutResult = layoutResult
+ textLayoutResult = layoutResult,
+ highlightPaint = Paint(),
+ selectionBackgroundColor = selectionColor
)
assertThat(actualBitmap.sameAs(expectedBitmap)).isTrue()
}
@Test
+ fun draw_highlight_test() {
+ val textDelegate = TextDelegate(
+ text = AnnotatedString("Hello, World"),
+ style = TextStyle.Default,
+ maxLines = 2,
+ density = density,
+ fontFamilyResolver = fontFamilyResolver
+ )
+ val layoutResult = textDelegate.layout(Constraints.fixedWidth(1024), layoutDirection)
+ val deletionPreviewHighlightRange = TextRange(3, 5)
+
+ val actualBitmap = layoutResult.toBitmap()
+ val actualCanvas = Canvas(android.graphics.Canvas(actualBitmap))
+ TextFieldDelegate.draw(
+ canvas = actualCanvas,
+ value = TextFieldValue(text = "Hello, World", selection = TextRange.Zero),
+ selectionPreviewHighlightRange = TextRange.Zero,
+ deletionPreviewHighlightRange = deletionPreviewHighlightRange,
+ offsetMapping = OffsetMapping.Identity,
+ textLayoutResult = layoutResult,
+ highlightPaint = Paint(),
+ selectionBackgroundColor = Color.Blue
+ )
+
+ val expectedBitmap = layoutResult.toBitmap()
+ val expectedCanvas = Canvas(android.graphics.Canvas(expectedBitmap))
+ val selectionPath = layoutResult.multiParagraph.getPathForRange(3, 5)
+ // Default text color is black, so deletion preview highlight is black with 20% alpha.
+ expectedCanvas.drawPath(selectionPath, Paint().apply { color = Color(0f, 0f, 0f, 0.2f) })
+ TextPainter.paint(expectedCanvas, layoutResult)
+ assertThat(actualBitmap.sameAs(expectedBitmap)).isTrue()
+ }
+
+ @Test
fun layout_height_constraint_max_height() {
val textDelegate = TextDelegate(
text = AnnotatedString("Hello, World"),
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/EditorInfoTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/EditorInfoTest.kt
index 683499e..2a4c26e4 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/EditorInfoTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/EditorInfoTest.kt
@@ -18,8 +18,13 @@
import android.text.InputType
import android.view.inputmethod.DeleteGesture
+import android.view.inputmethod.DeleteRangeGesture
import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InsertGesture
+import android.view.inputmethod.JoinOrSplitGesture
+import android.view.inputmethod.RemoveSpaceGesture
import android.view.inputmethod.SelectGesture
+import android.view.inputmethod.SelectRangeGesture
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.ImeOptions
@@ -599,8 +604,29 @@
val info = EditorInfo()
info.update(ImeOptions.Default)
- assertThat(info.supportedHandwritingGestures).contains(SelectGesture::class.java)
- assertThat(info.supportedHandwritingGestures).contains(DeleteGesture::class.java)
+ assertThat(info.supportedHandwritingGestures).containsExactly(
+ SelectGesture::class.java,
+ DeleteGesture::class.java,
+ SelectRangeGesture::class.java,
+ DeleteRangeGesture::class.java,
+ JoinOrSplitGesture::class.java,
+ InsertGesture::class.java,
+ RemoveSpaceGesture::class.java,
+ )
+ }
+
+ @SdkSuppress(minSdkVersion = 34)
+ @Test
+ fun supportedStylusHandwritingGesturePreviews() {
+ val info = EditorInfo()
+ info.update(ImeOptions.Default)
+
+ assertThat(info.supportedHandwritingGesturePreviews).containsExactly(
+ SelectGesture::class.java,
+ DeleteGesture::class.java,
+ SelectRangeGesture::class.java,
+ DeleteRangeGesture::class.java,
+ )
}
private fun EditorInfo.update(
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/Magnifier.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/Magnifier.android.kt
index 186bdd2..7d2e1e9 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/Magnifier.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/Magnifier.android.kt
@@ -20,8 +20,11 @@
import android.view.View
import android.widget.Magnifier
import androidx.annotation.ChecksSdkIntAtLeast
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.neverEqualPolicy
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameMillis
import androidx.compose.ui.Modifier
@@ -39,7 +42,6 @@
import androidx.compose.ui.node.requireDensity
import androidx.compose.ui.node.requireView
import androidx.compose.ui.platform.InspectorInfo
-import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.SemanticsPropertyKey
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.unit.Density
@@ -48,6 +50,7 @@
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.toSize
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
/**
@@ -263,7 +266,7 @@
private var view: View? = null
/**
- * Current density provided by [LocalDensity]. Used as a receiver to callback functions that
+ * Current density provided by [requireDensity]. Used as a receiver to callback functions that
* are expected return pixel targeted offsets.
*/
private var density: Density? = null
@@ -274,9 +277,28 @@
private var magnifier: PlatformMagnifier? = null
/**
- * Anchor Composable's position in root layout.
+ * The latest [LayoutCoordinates] that is reported by [onGloballyPositioned] callback. Using
+ * [neverEqualPolicy] guarantees that every update to this value restarts snapshots aware
+ * listeners since the [LayoutCoordinates] instance itself does not change.
*/
- private var anchorPositionInRoot: Offset by mutableStateOf(Offset.Unspecified)
+ private var layoutCoordinates: LayoutCoordinates? by mutableStateOf(null, neverEqualPolicy())
+
+ /**
+ * Lazily initialized state that keeps track of anchor Composable's position in root layout.
+ * This state should be derived from [layoutCoordinates]. This variable shouldn't be used
+ * directly from the code, only [anchorPositionInRoot] should initialize and read from this.
+ */
+ private var anchorPositionInRootState: State<Offset>? = null
+
+ private val anchorPositionInRoot: Offset
+ get() {
+ if (anchorPositionInRootState == null) {
+ anchorPositionInRootState = derivedStateOf {
+ layoutCoordinates?.positionInRoot() ?: Offset.Unspecified
+ }
+ }
+ return anchorPositionInRootState?.value ?: Offset.Unspecified
+ }
/**
* Position where [sourceCenter] is mapped on root layout. This is passed to platform magnifier
@@ -290,6 +312,8 @@
*/
private var previousSize: IntSize? = null
+ private var drawSignalChannel: Channel<Unit>? = null
+
fun update(
sourceCenter: Density.() -> Offset,
magnifierCenter: (Density.() -> Offset)?,
@@ -305,9 +329,12 @@
val previousZoom = this.zoom
val previousSize = this.size
val previousCornerRadius = this.cornerRadius
+ val previousUseTextDefault = this.useTextDefault
val previousElevation = this.elevation
val previousClippingEnabled = this.clippingEnabled
val previousPlatformMagnifierFactory = this.platformMagnifierFactory
+ val previousView = this.view
+ val previousDensity = this.density
this.sourceCenter = sourceCenter
this.magnifierCenter = magnifierCenter
@@ -320,26 +347,45 @@
this.onSizeChanged = onSizeChanged
this.platformMagnifierFactory = platformMagnifierFactory
- // On platforms >=Q, the zoom level can be updated dynamically on an existing magnifier, so
- // if the zoom changes between recompositions we don't need to recreate the magnifier. On
- // older platforms, the zoom can only be set initially, so we use the zoom itself as a key
- // so the magnifier gets recreated if it changes.
- if (
- magnifier == null ||
- (zoom != previousZoom && !platformMagnifierFactory.canUpdateZoom) ||
- size != previousSize ||
- cornerRadius != previousCornerRadius ||
- elevation != previousElevation ||
- clippingEnabled != previousClippingEnabled ||
- platformMagnifierFactory != previousPlatformMagnifierFactory
- ) {
+ val view = requireView()
+ val density = requireDensity()
+
+ val shouldRecreate = magnifier != null && // only recreate if it was already created
+ // On platforms >=Q, the zoom level can be updated dynamically on an existing magnifier,
+ // so if the zoom changes between recompositions we don't need to recreate the
+ // magnifier. On older platforms, the zoom can only be set initially, so we use the
+ // zoom itself as a key so the magnifier gets recreated if it changes.
+ ((!zoom.equalsIncludingNaN(previousZoom) && !platformMagnifierFactory.canUpdateZoom) ||
+ size != previousSize ||
+ cornerRadius != previousCornerRadius ||
+ elevation != previousElevation ||
+ useTextDefault != previousUseTextDefault ||
+ clippingEnabled != previousClippingEnabled ||
+ platformMagnifierFactory != previousPlatformMagnifierFactory ||
+ view != previousView ||
+ density != previousDensity)
+
+ if (shouldRecreate) {
recreateMagnifier()
}
+
updateMagnifier()
}
override fun onAttach() {
onObservedReadsChanged()
+ drawSignalChannel = Channel()
+ coroutineScope.launch {
+ while (true) {
+ drawSignalChannel?.receive()
+ // don't update the magnifier immediately, actual frame draw happens right after
+ // all draw commands are recorded. Magnifier update should happen in the next frame.
+ if (magnifier != null) {
+ withFrameMillis { }
+ magnifier?.updateContent()
+ }
+ }
+ }
}
override fun onDetach() {
@@ -349,23 +395,14 @@
override fun onObservedReadsChanged() {
observeReads {
- val previousView = view
- val view = requireView().also { this.view = it }
- val previousDensity = density
- val density = requireDensity().also { this.density = it }
-
- if (magnifier == null || view != previousView || density != previousDensity) {
- recreateMagnifier()
- }
-
updateMagnifier()
}
}
private fun recreateMagnifier() {
magnifier?.dismiss()
- val view = view ?: return
- val density = density ?: return
+ val view = (view ?: requireView()).also { view = it }
+ val density = (density ?: requireDensity()).also { density = it }
magnifier = platformMagnifierFactory.create(
view = view,
useTextDefault = useTextDefault,
@@ -380,37 +417,38 @@
}
private fun updateMagnifier() {
- val magnifier = magnifier ?: return
- val density = density ?: return
+ val density = density ?: requireDensity().also { density = it }
val sourceCenterOffset = sourceCenter(density)
- sourceCenterInRoot =
- if (anchorPositionInRoot.isSpecified && sourceCenterOffset.isSpecified) {
- anchorPositionInRoot + sourceCenterOffset
- } else {
- Offset.Unspecified
- }
- // Once the position is set, it's never null again, so we don't need to worry
- // about dismissing the magnifier if this expression changes value.
- if (sourceCenterInRoot.isSpecified) {
- // Calculate magnifier center if it's provided. Only accept if the returned value is
- // specified. Then add [anchorPositionInRoot] for relative positioning.
+ // the order of these checks are important since we don't want to query
+ // `anchorPositionInRoot` if `sourceCenterOffset` is unspecified.
+ if (sourceCenterOffset.isSpecified && anchorPositionInRoot.isSpecified) {
+ sourceCenterInRoot = anchorPositionInRoot + sourceCenterOffset
+ // Calculate magnifier center if it's provided. Only accept if the returned
+ // value is specified. Then add [anchorPositionInRoot] for relative positioning.
val magnifierCenter = magnifierCenter?.invoke(density)
?.takeIf { it.isSpecified }
?.let { anchorPositionInRoot + it }
?: Offset.Unspecified
- magnifier.update(
+ if (magnifier == null) {
+ recreateMagnifier()
+ }
+
+ magnifier?.update(
sourceCenter = sourceCenterInRoot,
magnifierCenter = magnifierCenter,
zoom = zoom
)
updateSizeIfNecessary()
- } else {
- // Can't place the magnifier at an unspecified location, so just hide it.
- magnifier.dismiss()
+ return
}
+
+ // If the flow reaches here, it means that the magnifier could not be placed at a specified
+ // position. We now need to hide it so it doesn't show up at an invalid location.
+ sourceCenterInRoot = Offset.Unspecified
+ magnifier?.dismiss()
}
private fun updateSizeIfNecessary() {
@@ -425,19 +463,14 @@
override fun ContentDrawScope.draw() {
drawContent()
- // don't update the magnifier immediately, actual frame draw happens right after all draw
- // commands are recorded. Magnifier update should happen in the next frame.
- coroutineScope.launch {
- withFrameMillis { }
- magnifier?.updateContent()
- }
+ drawSignalChannel?.trySend(Unit)
}
override fun onGloballyPositioned(coordinates: LayoutCoordinates) {
// The mutable state must store the Offset, not the LocalCoordinates, because the same
// LocalCoordinates instance may be sent to this callback multiple times, not implement
// equals, or be stable, and so won't invalidate the snapshotFlow.
- anchorPositionInRoot = coordinates.positionInRoot()
+ layoutCoordinates = coordinates
}
override fun SemanticsPropertyReceiver.applySemantics() {
@@ -448,3 +481,13 @@
@ChecksSdkIntAtLeast(api = 28)
internal fun isPlatformMagnifierSupported(sdkVersion: Int = Build.VERSION.SDK_INT) =
sdkVersion >= 28
+
+/**
+ * Normally `Float.NaN == Float.NaN` returns false but we use [Float.NaN] to mean Unspecified.
+ * The comparison between two unspecified values should return _equal_ if we are only interested
+ * in state changes.
+ */
+internal fun Float.equalsIncludingNaN(other: Float): Boolean {
+ if (this.isNaN() && other.isNaN()) return true
+ return this == other
+}
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/EditorInfo.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/EditorInfo.android.kt
index 1bdf683..a53b03d 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/EditorInfo.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/EditorInfo.android.kt
@@ -203,5 +203,11 @@
InsertGesture::class.java,
RemoveSpaceGesture::class.java
)
+ editorInfo.supportedHandwritingGesturePreviews = setOf(
+ SelectGesture::class.java,
+ DeleteGesture::class.java,
+ SelectRangeGesture::class.java,
+ DeleteRangeGesture::class.java
+ )
}
}
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/HandwritingGesture.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/HandwritingGesture.android.kt
index d47c722..ba0422e 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/HandwritingGesture.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/HandwritingGesture.android.kt
@@ -421,6 +421,30 @@
}
@DoNotInline
+ internal fun LegacyTextFieldState.previewHandwritingGesture(
+ gesture: PreviewableHandwritingGesture,
+ textFieldSelectionManager: TextFieldSelectionManager?,
+ cancellationSignal: CancellationSignal?
+ ): Boolean {
+ val text = untransformedText ?: return false
+ if (text != layoutResult?.value?.layoutInput?.text) {
+ // The text is transformed or layout is null, handwriting gesture failed.
+ return false
+ }
+ when (gesture) {
+ is SelectGesture -> previewSelectGesture(gesture, textFieldSelectionManager)
+ is DeleteGesture -> previewDeleteGesture(gesture, textFieldSelectionManager)
+ is SelectRangeGesture -> previewSelectRangeGesture(gesture, textFieldSelectionManager)
+ is DeleteRangeGesture -> previewDeleteRangeGesture(gesture, textFieldSelectionManager)
+ else -> return false
+ }
+ cancellationSignal?.setOnCancelListener {
+ textFieldSelectionManager?.clearPreviewHighlight()
+ }
+ return true
+ }
+
+ @DoNotInline
private fun LegacyTextFieldState.performSelectGesture(
gesture: SelectGesture,
textSelectionManager: TextFieldSelectionManager?,
@@ -439,6 +463,20 @@
}
@DoNotInline
+ private fun LegacyTextFieldState.previewSelectGesture(
+ gesture: SelectGesture,
+ textFieldSelectionManager: TextFieldSelectionManager?
+ ) {
+ textFieldSelectionManager?.setSelectionPreviewHighlight(
+ getRangeForScreenRect(
+ gesture.selectionArea.toComposeRect(),
+ gesture.granularity.toTextGranularity(),
+ TextInclusionStrategy.ContainsCenter
+ )
+ )
+ }
+
+ @DoNotInline
private fun LegacyTextFieldState.performDeleteGesture(
gesture: DeleteGesture,
text: AnnotatedString,
@@ -463,6 +501,20 @@
}
@DoNotInline
+ private fun LegacyTextFieldState.previewDeleteGesture(
+ gesture: DeleteGesture,
+ textFieldSelectionManager: TextFieldSelectionManager?
+ ) {
+ textFieldSelectionManager?.setDeletionPreviewHighlight(
+ getRangeForScreenRect(
+ gesture.deletionArea.toComposeRect(),
+ gesture.granularity.toTextGranularity(),
+ TextInclusionStrategy.ContainsCenter
+ )
+ )
+ }
+
+ @DoNotInline
private fun LegacyTextFieldState.performSelectRangeGesture(
gesture: SelectRangeGesture,
textSelectionManager: TextFieldSelectionManager?,
@@ -486,6 +538,21 @@
}
@DoNotInline
+ private fun LegacyTextFieldState.previewSelectRangeGesture(
+ gesture: SelectRangeGesture,
+ textFieldSelectionManager: TextFieldSelectionManager?
+ ) {
+ textFieldSelectionManager?.setSelectionPreviewHighlight(
+ getRangeForScreenRects(
+ gesture.selectionStartArea.toComposeRect(),
+ gesture.selectionEndArea.toComposeRect(),
+ gesture.granularity.toTextGranularity(),
+ TextInclusionStrategy.ContainsCenter
+ )
+ )
+ }
+
+ @DoNotInline
private fun LegacyTextFieldState.performDeleteRangeGesture(
gesture: DeleteRangeGesture,
text: AnnotatedString,
@@ -510,6 +577,21 @@
}
@DoNotInline
+ private fun LegacyTextFieldState.previewDeleteRangeGesture(
+ gesture: DeleteRangeGesture,
+ textFieldSelectionManager: TextFieldSelectionManager?
+ ) {
+ textFieldSelectionManager?.setDeletionPreviewHighlight(
+ getRangeForScreenRects(
+ gesture.deletionStartArea.toComposeRect(),
+ gesture.deletionEndArea.toComposeRect(),
+ gesture.granularity.toTextGranularity(),
+ TextInclusionStrategy.ContainsCenter
+ )
+ )
+ }
+
+ @DoNotInline
private fun LegacyTextFieldState.performJoinOrSplitGesture(
gesture: JoinOrSplitGesture,
text: AnnotatedString,
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/RecordingInputConnection.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/RecordingInputConnection.android.kt
index 4d897e0..34e71cb 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/RecordingInputConnection.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/RecordingInputConnection.android.kt
@@ -18,6 +18,7 @@
import android.os.Build
import android.os.Bundle
+import android.os.CancellationSignal
import android.os.Handler
import android.text.TextUtils
import android.util.Log
@@ -30,10 +31,12 @@
import android.view.inputmethod.HandwritingGesture
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputContentInfo
+import android.view.inputmethod.PreviewableHandwritingGesture
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
import androidx.compose.foundation.text.LegacyTextFieldState
import androidx.compose.foundation.text.input.internal.HandwritingGestureApi34.performHandwritingGesture
+import androidx.compose.foundation.text.input.internal.HandwritingGestureApi34.previewHandwritingGesture
import androidx.compose.foundation.text.selection.TextFieldSelectionManager
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.text.input.CommitTextCommand
@@ -426,6 +429,22 @@
}
}
+ override fun previewHandwritingGesture(
+ gesture: PreviewableHandwritingGesture,
+ cancellationSignal: CancellationSignal?
+ ): Boolean {
+ if (DEBUG) { logDebug("previewHandwritingGesture($gesture, $cancellationSignal)") }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ return Api34LegacyPerformHandwritingGestureImpl.previewHandwritingGesture(
+ legacyTextFieldState,
+ textFieldSelectionManager,
+ gesture,
+ cancellationSignal
+ )
+ }
+ return false
+ }
+
// endregion
// region Unsupported callbacks
@@ -533,4 +552,18 @@
consumer.accept(result)
}
}
+
+ @DoNotInline
+ fun previewHandwritingGesture(
+ legacyTextFieldState: LegacyTextFieldState?,
+ textFieldSelectionManager: TextFieldSelectionManager?,
+ gesture: PreviewableHandwritingGesture,
+ cancellationSignal: CancellationSignal?
+ ): Boolean {
+ return legacyTextFieldState?.previewHandwritingGesture(
+ gesture,
+ textFieldSelectionManager,
+ cancellationSignal
+ ) ?: false
+ }
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemAnimator.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemAnimator.kt
index 612ba62..0d1bb80 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemAnimator.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemAnimator.kt
@@ -241,6 +241,7 @@
lane = info.lane,
span = info.span
)
+
item.nonScrollableItem = true
// check if we have any active placement animation on the item
val inProgress =
@@ -248,6 +249,14 @@
if ((!inProgress && newIndex == previousKeyToIndexMap?.getIndex(key))) {
removeInfoForKey(key)
} else {
+ // anytime we compose a new item, and we use it,
+ // we need to update our item info mapping
+ info.updateAnimation(
+ item,
+ coroutineScope,
+ graphicsContext,
+ crossAxisOffset = info.crossAxisOffset
+ )
if (newIndex < firstVisibleIndex) {
movingAwayToStartBound.add(item)
} else {
@@ -444,7 +453,8 @@
fun updateAnimation(
positionedItem: T,
coroutineScope: CoroutineScope,
- graphicsContext: GraphicsContext
+ graphicsContext: GraphicsContext,
+ crossAxisOffset: Int = positionedItem.crossAxisOffset
) {
for (i in positionedItem.placeablesCount until animations.size) {
animations[i]?.release()
@@ -453,7 +463,7 @@
animations = animations.copyOf(positionedItem.placeablesCount)
}
constraints = positionedItem.constraints
- crossAxisOffset = positionedItem.crossAxisOffset
+ this.crossAxisOffset = crossAxisOffset
lane = positionedItem.lane
span = positionedItem.span
repeat(positionedItem.placeablesCount) { index ->
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt
index c163320..00dd988 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt
@@ -325,11 +325,9 @@
.scrollable(
state = scrollState,
orientation = orientation,
- // Disable scrolling when textField is disabled, there is no where to scroll, and
- // another dragging gesture is taking place
- enabled = enabled &&
- scrollState.maxValue > 0 &&
- textFieldSelectionState.draggingHandle == null,
+ // Disable scrolling when textField is disabled or another dragging gesture is taking
+ // place
+ enabled = enabled && textFieldSelectionState.draggingHandle == null,
reverseDirection = ScrollableDefaults.reverseDirection(
layoutDirection = layoutDirection,
orientation = orientation,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
index 1338218..c8f3fc3 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
@@ -434,9 +434,12 @@
TextFieldDelegate.draw(
canvas,
value,
+ state.selectionPreviewHighlightRange,
+ state.deletionPreviewHighlightRange,
offsetMapping,
layoutResult.value,
- state.selectionPaint
+ state.highlightPaint,
+ state.selectionBackgroundColor
)
}
}
@@ -614,7 +617,7 @@
}
}
- val showCursor = enabled && !readOnly && windowInfo.isWindowFocused
+ val showCursor = enabled && !readOnly && windowInfo.isWindowFocused && !state.hasHighlight()
val cursorModifier = Modifier.cursor(state, value, offsetMapping, cursorBrush, showCursor)
DisposableEffect(manager) {
@@ -976,6 +979,8 @@
// Text has been changed, enter the HandleState.None and hide the cursor handle.
handleState = HandleState.None
}
+ selectionPreviewHighlightRange = TextRange.Zero
+ deletionPreviewHighlightRange = TextRange.Zero
onValueChangeOriginal(it)
recomposeScope.invalidate()
}
@@ -984,8 +989,16 @@
keyboardActionRunner.runAction(imeAction)
}
- /** The paint used to draw highlight background for selected text. */
- val selectionPaint: Paint = Paint()
+ /** The paint used to draw highlight backgrounds. */
+ val highlightPaint: Paint = Paint()
+ var selectionBackgroundColor = Color.Unspecified
+
+ /** Range of text to be highlighted to display handwriting gesture previews from the IME. */
+ var selectionPreviewHighlightRange: TextRange by mutableStateOf(TextRange.Zero)
+ var deletionPreviewHighlightRange: TextRange by mutableStateOf(TextRange.Zero)
+
+ fun hasHighlight() =
+ !selectionPreviewHighlightRange.collapsed || !deletionPreviewHighlightRange.collapsed
fun update(
untransformedText: AnnotatedString,
@@ -1000,7 +1013,7 @@
selectionBackgroundColor: Color
) {
this.onValueChangeOriginal = onValueChange
- this.selectionPaint.color = selectionBackgroundColor
+ this.selectionBackgroundColor = selectionBackgroundColor
this.keyboardActionRunner.apply {
this.keyboardActions = keyboardActions
this.focusManager = focusManager
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt
index b6567d5..eabeb8e 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt
@@ -23,7 +23,9 @@
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
+import androidx.compose.ui.graphics.isUnspecified
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.findRootCoordinates
import androidx.compose.ui.text.AnnotatedString
@@ -112,28 +114,75 @@
*
* @param canvas The target canvas.
* @param value The editor state
+ * @param selectionPreviewHighlightRange Range to be highlighted to preview a handwriting
+ * selection gesture
+ * @param deletionPreviewHighlightRange Range to be highlighted to preview a handwriting
+ * deletion gesture
* @param offsetMapping The offset map
- * @param selectionPaint The selection paint
+ * @param textLayoutResult The text layout result
+ * @param highlightPaint Paint used to draw highlight backgrounds
+ * @param selectionBackgroundColor The selection highlight background color
*/
@JvmStatic
internal fun draw(
canvas: Canvas,
value: TextFieldValue,
+ selectionPreviewHighlightRange: TextRange,
+ deletionPreviewHighlightRange: TextRange,
offsetMapping: OffsetMapping,
textLayoutResult: TextLayoutResult,
- selectionPaint: Paint
+ highlightPaint: Paint,
+ selectionBackgroundColor: Color
) {
- if (!value.selection.collapsed) {
- val start = offsetMapping.originalToTransformed(value.selection.min)
- val end = offsetMapping.originalToTransformed(value.selection.max)
- if (start != end) {
- val selectionPath = textLayoutResult.getPathForRange(start, end)
- canvas.drawPath(selectionPath, selectionPaint)
- }
+ if (!selectionPreviewHighlightRange.collapsed) {
+ highlightPaint.color = selectionBackgroundColor
+ drawHighlight(
+ canvas,
+ selectionPreviewHighlightRange,
+ offsetMapping,
+ textLayoutResult,
+ highlightPaint
+ )
+ } else if (!deletionPreviewHighlightRange.collapsed) {
+ val textColor =
+ textLayoutResult.layoutInput.style.color.takeUnless { it.isUnspecified }
+ ?: Color.Black
+ highlightPaint.color = textColor.copy(alpha = textColor.alpha * 0.2f)
+ drawHighlight(
+ canvas,
+ deletionPreviewHighlightRange,
+ offsetMapping,
+ textLayoutResult,
+ highlightPaint
+ )
+ } else if (!value.selection.collapsed) {
+ highlightPaint.color = selectionBackgroundColor
+ drawHighlight(
+ canvas,
+ value.selection,
+ offsetMapping,
+ textLayoutResult,
+ highlightPaint
+ )
}
TextPainter.paint(canvas, textLayoutResult)
}
+ private fun drawHighlight(
+ canvas: Canvas,
+ range: TextRange,
+ offsetMapping: OffsetMapping,
+ textLayoutResult: TextLayoutResult,
+ paint: Paint
+ ) {
+ val start = offsetMapping.originalToTransformed(range.min)
+ val end = offsetMapping.originalToTransformed(range.max)
+ if (start != end) {
+ val selectionPath = textLayoutResult.getPathForRange(start, end)
+ canvas.drawPath(selectionPath, paint)
+ }
+ }
+
/**
* Notify system that focused input area.
*
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldMagnifier.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldMagnifier.kt
index 7cf3de1..fe8166b 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldMagnifier.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldMagnifier.kt
@@ -98,7 +98,14 @@
// Hide the magnifier when dragged too far (outside the horizontal bounds of how big the
// magnifier actually is). See
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/Editor.java;l=5228-5231;drc=2fdb6bd709be078b72f011334362456bb758922c
- if ((dragX - centerX).absoluteValue > magnifierSize.width / 2) {
+ // Also check whether magnifierSize is calculated. A platform magnifier instance is not
+ // created until it's requested for the first time. So the size will only be calculated after we
+ // return a specified offset from this function.
+ // It is very unlikely that this behavior would cause a flicker since magnifier immediately
+ // shows up where the pointer is being dragged. The pointer needs to drag further than the half
+ // of magnifier's width to hide by the following logic.
+ if (magnifierSize != IntSize.Zero &&
+ (dragX - centerX).absoluteValue > magnifierSize.width / 2) {
return Offset.Unspecified
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.kt
index 4cb66ac..465fc46 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.kt
@@ -1034,7 +1034,14 @@
// Hide the magnifier when dragged too far (outside the horizontal bounds of how big the
// magnifier actually is). See
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/Editor.java;l=5228-5231;drc=2fdb6bd709be078b72f011334362456bb758922c
- if ((dragX - textConstrainedX).absoluteValue > magnifierSize.width / 2) {
+ // Also check whether magnifierSize is calculated. A platform magnifier instance is not
+ // created until it's requested for the first time. So the size will only be calculated after we
+ // return a specified offset from this function.
+ // It is very unlikely that this behavior would cause a flicker since magnifier immediately
+ // shows up where the pointer is being dragged. The pointer needs to drag further than the half
+ // of magnifier's width to hide by the following logic.
+ if (magnifierSize != IntSize.Zero &&
+ (dragX - textConstrainedX).absoluteValue > magnifierSize.width / 2) {
return Offset.Unspecified
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt
index bb5b445..a32cf68 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt
@@ -576,6 +576,23 @@
updateFloatingToolbar(show = false)
}
+ internal fun setSelectionPreviewHighlight(range: TextRange) {
+ state?.selectionPreviewHighlightRange = range
+ state?.deletionPreviewHighlightRange = TextRange.Zero
+ if (!range.collapsed) exitSelectionMode()
+ }
+
+ internal fun setDeletionPreviewHighlight(range: TextRange) {
+ state?.deletionPreviewHighlightRange = range
+ state?.selectionPreviewHighlightRange = TextRange.Zero
+ if (!range.collapsed) exitSelectionMode()
+ }
+
+ internal fun clearPreviewHighlight() {
+ state?.deletionPreviewHighlightRange = TextRange.Zero
+ state?.selectionPreviewHighlightRange = TextRange.Zero
+ }
+
/**
* The method for copying text.
*
@@ -1036,7 +1053,14 @@
// Hide the magnifier when dragged too far (outside the horizontal bounds of how big the
// magnifier actually is). See
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/Editor.java;l=5228-5231;drc=2fdb6bd709be078b72f011334362456bb758922c
- if ((dragX - centerX).absoluteValue > magnifierSize.width / 2) {
+ // Also check whether magnifierSize is calculated. A platform magnifier instance is not
+ // created until it's requested for the first time. So the size will only be calculated after we
+ // return a specified offset from this function.
+ // It is very unlikely that this behavior would cause a flicker since magnifier immediately
+ // shows up where the pointer is being dragged. The pointer needs to drag further than the half
+ // of magnifier's width to hide by the following logic.
+ if (magnifierSize != IntSize.Zero &&
+ (dragX - centerX).absoluteValue > magnifierSize.width / 2) {
return Offset.Unspecified
}
diff --git a/compose/ui/ui-graphics/api/current.txt b/compose/ui/ui-graphics/api/current.txt
index 515e4f5..41516b7 100644
--- a/compose/ui/ui-graphics/api/current.txt
+++ b/compose/ui/ui-graphics/api/current.txt
@@ -918,7 +918,7 @@
method @androidx.compose.runtime.Stable public static androidx.compose.ui.graphics.Shadow lerp(androidx.compose.ui.graphics.Shadow start, androidx.compose.ui.graphics.Shadow stop, float fraction);
}
- @androidx.compose.runtime.Immutable public interface Shape {
+ @androidx.compose.runtime.Stable public interface Shape {
method public androidx.compose.ui.graphics.Outline createOutline(long size, androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.ui.unit.Density density);
}
diff --git a/compose/ui/ui-graphics/api/restricted_current.txt b/compose/ui/ui-graphics/api/restricted_current.txt
index 68a8faa..9da68f8 100644
--- a/compose/ui/ui-graphics/api/restricted_current.txt
+++ b/compose/ui/ui-graphics/api/restricted_current.txt
@@ -990,7 +990,7 @@
method @androidx.compose.runtime.Stable public static androidx.compose.ui.graphics.Shadow lerp(androidx.compose.ui.graphics.Shadow start, androidx.compose.ui.graphics.Shadow stop, float fraction);
}
- @androidx.compose.runtime.Immutable public interface Shape {
+ @androidx.compose.runtime.Stable public interface Shape {
method public androidx.compose.ui.graphics.Outline createOutline(long size, androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.ui.unit.Density density);
}
diff --git a/compose/ui/ui-graphics/build.gradle b/compose/ui/ui-graphics/build.gradle
index 6b1601a..8046e187 100644
--- a/compose/ui/ui-graphics/build.gradle
+++ b/compose/ui/ui-graphics/build.gradle
@@ -76,7 +76,9 @@
// This has stub APIs for access to legacy Android APIs, so we don't want
// any dependency on this module.
compileOnly(project(":compose:ui:ui-android-stubs"))
- implementation("androidx.graphics:graphics-path:1.0.0-beta02")
+ // TODO: Re-pin when 1.0.1 is released
+ //implementation("androidx.graphics:graphics-path:1.0.1")
+ implementation(project(":graphics:graphics-path"))
implementation libs.androidx.core
api("androidx.annotation:annotation-experimental:1.4.0")
}
diff --git a/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/OutlineTest.kt b/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/OutlineTest.kt
index 776c8d5..c106240 100644
--- a/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/OutlineTest.kt
+++ b/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/OutlineTest.kt
@@ -22,6 +22,7 @@
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -55,4 +56,44 @@
)
assertEquals(Rect(0f, 15f, 100f, 200f), pathOutline.bounds)
}
+
+ @Test
+ fun testRectOutlineEquality() {
+ val outlineRect = Outline.Rectangle(Rect(1f, 2f, 3f, 4f))
+ val equalOutlineRect = Outline.Rectangle(Rect(1f, 2f, 3f, 4f))
+ val differentOutlineRect = Outline.Rectangle(Rect(4f, 3f, 2f, 1f))
+ assertEquals(outlineRect, equalOutlineRect)
+ assertNotEquals(outlineRect, differentOutlineRect)
+ }
+
+ @Test
+ fun testRoundRectOutlineEquality() {
+ val roundRectOutline = Outline.Rounded(
+ RoundRect(5f, 10f, 15f, 20f, CornerRadius(7f))
+ )
+ val equalRoundRectOutline = Outline.Rounded(
+ RoundRect(5f, 10f, 15f, 20f, CornerRadius(7f))
+ )
+ val differentRoundRectOutline = Outline.Rounded(
+ RoundRect(20f, 15f, 10f, 5f, CornerRadius(3f))
+ )
+ assertEquals(roundRectOutline, equalRoundRectOutline)
+ assertNotEquals(roundRectOutline, differentRoundRectOutline)
+ }
+
+ @Test
+ fun testPathOutlineEquality() {
+ val path = Path().apply {
+ moveTo(5f, 15f)
+ lineTo(100f, 200f)
+ lineTo(0f, 200f)
+ close()
+ }
+ val pathOutline = Outline.Generic(path)
+ val pathOutline2 = Outline.Generic(path)
+
+ // Generic outlines should only be referentially equal, as the path can change over time
+ assertEquals(pathOutline, pathOutline)
+ assertNotEquals(pathOutline, pathOutline2)
+ }
}
diff --git a/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayerTest.kt b/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayerTest.kt
index bf3b965..c6dc2dc 100644
--- a/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayerTest.kt
+++ b/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayerTest.kt
@@ -21,9 +21,6 @@
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
import android.os.Build
-import android.os.Handler
-import android.os.HandlerThread
-import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
@@ -51,7 +48,6 @@
import androidx.compose.ui.graphics.drawscope.inset
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.nativeCanvas
-import androidx.compose.ui.graphics.throwIllegalArgumentException
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.unit.Density
@@ -71,10 +67,7 @@
import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt
import kotlin.test.assertNotNull
-import kotlinx.coroutines.android.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.withContext
-import org.junit.After
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -92,61 +85,12 @@
val TEST_SIZE = IntSize(TEST_WIDTH, TEST_HEIGHT)
}
- private var waitThread: HandlerThread? = null
- private var waitHandler: Handler? = null
-
- private fun obtainWaitHandler(): Handler {
- synchronized(this) {
- var thread = waitThread
- if (thread == null) {
- thread = HandlerThread("waitThread").also {
- it.start()
- waitThread = it
- }
- }
-
- var handler = waitHandler
- if (handler == null) {
- handler = Handler(thread.looper)
- }
- return handler
- }
- }
-
- /**
- * Helper method used to synchronously obtain an [ImageBitmap] from a [GraphicsLayer]
- */
- private fun GraphicsLayer.toImageBitmap(handler: Handler): ImageBitmap {
- if (Looper.myLooper() === handler.looper) {
- throwIllegalArgumentException("Handler looper cannot be the same as the current " +
- "looper: ${Looper.myLooper()?.thread?.name} ${handler.looper.thread.name}")
- }
- val latch = CountDownLatch(1)
- var bitmap: ImageBitmap?
- runBlocking {
- withContext(handler.asCoroutineDispatcher().immediate) {
- bitmap = toImageBitmap()
- latch.countDown()
- }
- latch.await()
- }
- return bitmap!!
- }
-
- @After
- fun teardown() {
- synchronized(this) {
- waitThread?.quit()
- waitThread = null
- }
- }
-
@Test
fun testGraphicsLayerBitmap() {
- var bitmap: ImageBitmap? = null
+ lateinit var layer: GraphicsLayer
graphicsLayerTest(
block = { graphicsContext ->
- graphicsContext.createGraphicsLayer().apply {
+ layer = graphicsContext.createGraphicsLayer().apply {
assertEquals(IntSize.Zero, this.size)
record {
drawRect(
@@ -169,13 +113,13 @@
size = size / 2f
)
}
- bitmap = toImageBitmap(obtainWaitHandler())
}
},
verify = {
+ val bitmap: ImageBitmap = layer.toImageBitmap()
assertNotNull(bitmap)
- assertEquals(TEST_SIZE, IntSize(bitmap!!.width, bitmap!!.height))
- bitmap!!.toPixelMap().verifyQuadrants(
+ assertEquals(TEST_SIZE, IntSize(bitmap.width, bitmap.height))
+ bitmap.toPixelMap().verifyQuadrants(
Color.Red,
Color.Blue,
Color.Green,
@@ -206,7 +150,7 @@
// Nulling out the dependency here should be safe despite attempting to obtain an
// ImageBitmap afterwards
provider = null
- graphicsLayer!!.toImageBitmap(obtainWaitHandler()).toPixelMap().verifyQuadrants(
+ graphicsLayer!!.toImageBitmap().toPixelMap().verifyQuadrants(
Color.Red,
Color.Red,
Color.Red,
@@ -1290,7 +1234,9 @@
val path = Path().also { it.addOval(Rect(1f, 2f, 3f, 4f)) }
val generic = Outline.Generic(path)
layer.setOutline(generic)
- assertEquals(generic, layer.outline)
+ // We wrap the path in a different Outline object from what we pass in, so compare
+ // the paths instead of the outline instances
+ assertEquals(generic.path, (layer.outline as Outline.Generic).path)
}
)
}
@@ -1355,7 +1301,7 @@
private fun graphicsLayerTest(
block: DrawScope.(GraphicsContext) -> Unit,
- verify: ((PixelMap) -> Unit)? = null,
+ verify: (suspend (PixelMap) -> Unit)? = null,
entireScene: Boolean = false,
usePixelCopy: Boolean = false
) {
@@ -1399,7 +1345,7 @@
resumed.countDown()
}
}
- Assert.assertTrue(resumed.await(300000, TimeUnit.MILLISECONDS))
+ assertTrue(resumed.await(3000, TimeUnit.MILLISECONDS))
if (verify != null) {
val target = if (entireScene) {
@@ -1407,8 +1353,8 @@
} else {
contentView!!
}
- if (usePixelCopy && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- verify(target.captureToImage().toPixelMap())
+ val pixelMap = if (usePixelCopy && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ target.captureToImage().toPixelMap()
} else {
val recordLatch = CountDownLatch(1)
testActivity!!.runOnUiThread {
@@ -1423,11 +1369,14 @@
}
recordLatch.countDown()
}
- assertTrue(recordLatch.await(30000, TimeUnit.MILLISECONDS))
+ assertTrue(recordLatch.await(3000, TimeUnit.MILLISECONDS))
val bitmap = runBlocking {
rootGraphicsLayer!!.toImageBitmap()
}
- verify(bitmap.toPixelMap())
+ bitmap.toPixelMap()
+ }
+ runBlocking {
+ verify(pixelMap)
}
}
} finally {
diff --git a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayer.android.kt b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayer.android.kt
index 330d8ec..7d30041 100644
--- a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayer.android.kt
+++ b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayer.android.kt
@@ -472,6 +472,10 @@
androidCanvas.concat(impl.calculateMatrix())
}
+ internal fun drawForPersistence(canvas: Canvas) {
+ impl.draw(canvas)
+ }
+
/**
* Draw the contents of this [GraphicsLayer] into the specified [Canvas]
*/
diff --git a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/LayerManager.android.kt b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/LayerManager.android.kt
index 23b14d9..f1f1132 100644
--- a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/LayerManager.android.kt
+++ b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/LayerManager.android.kt
@@ -19,11 +19,13 @@
import android.graphics.PixelFormat
import android.media.ImageReader
import android.os.Build
+import android.os.Looper
import android.view.Surface
import androidx.annotation.RequiresApi
import androidx.collection.ObjectList
import androidx.collection.mutableObjectListOf
import androidx.compose.ui.graphics.CanvasHolder
+import androidx.core.os.HandlerCompat
/**
* Class responsible for managing the layer lifecycle to support
@@ -44,10 +46,17 @@
*/
private var imageReader: ImageReader? = null
+ private val handler = HandlerCompat.createAsync(Looper.getMainLooper()) {
+ persistLayers(layerList)
+ true
+ }
+
fun persist(layer: GraphicsLayer) {
if (!layerList.contains(layer)) {
layerList.add(layer)
- persistLayers(layerList)
+ if (!handler.hasMessages(0)) {
+ handler.sendEmptyMessage(0)
+ }
}
}
@@ -65,7 +74,7 @@
* another internal CanvasContext instance owned by the internal HwuiContext instance of
* a Surface. This is only necessary for Android M and above.
*/
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && layers.isNotEmpty()) {
val reader = imageReader ?: ImageReader.newInstance(
1,
1,
@@ -78,7 +87,7 @@
// are not supported
if (canvas.isHardwareAccelerated) {
canvasHolder.drawInto(canvas) {
- layers.forEach { layer -> layer.draw(this, null) }
+ layers.forEach { layer -> layer.drawForPersistence(this) }
}
}
surface.unlockCanvasAndPost(canvas)
diff --git a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Outline.kt b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Outline.kt
index b3c7de3..0c20ed5 100644
--- a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Outline.kt
+++ b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Outline.kt
@@ -106,18 +106,8 @@
override val bounds: Rect
get() = path.getBounds()
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (other !is Generic) return false
-
- if (path != other.path) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- return path.hashCode()
- }
+ // No equals or hashcode, two different outlines using the same path shouldn't be considered
+ // equal as the path may have changed since the previous outline was rendered
}
/**
diff --git a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Shape.kt b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Shape.kt
index e174944..0ce177b 100644
--- a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Shape.kt
+++ b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Shape.kt
@@ -16,7 +16,7 @@
package androidx.compose.ui.graphics
-import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
@@ -24,7 +24,7 @@
/**
* Defines a generic shape.
*/
-@Immutable
+@Stable
interface Shape {
/**
* Creates [Outline] of this shape for the given [size].
diff --git a/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyAndMouseEventsTest.kt b/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyAndMouseEventsTest.kt
index 71d02be..2f412d9 100644
--- a/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyAndMouseEventsTest.kt
+++ b/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyAndMouseEventsTest.kt
@@ -438,7 +438,8 @@
Offset.Zero, 0, expectedMetaState = expectedMetaState)
// Key Toggle Off
- recorder.events[8].verifyKeyEvent(keyDown, key.nativeKeyCode)
+ recorder.events[8].verifyKeyEvent(keyDown, key.nativeKeyCode,
+ expectedMetaState = expectedMetaState)
recorder.events[9].verifyKeyEvent(keyUp, key.nativeKeyCode)
// Mouse Press
diff --git a/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyEventsTest.kt b/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyEventsTest.kt
index 0216a50..4d1f794 100644
--- a/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyEventsTest.kt
+++ b/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyEventsTest.kt
@@ -514,7 +514,8 @@
recorder.events[1].verifyKeyEvent(
keyUp, key.nativeKeyCode, expectedMetaState = expectedMetaState
)
- recorder.events[2].verifyKeyEvent(keyDown, key.nativeKeyCode)
+ recorder.events[2].verifyKeyEvent(keyDown, key.nativeKeyCode,
+ expectedMetaState = expectedMetaState)
recorder.events[3].verifyKeyEvent(keyUp, key.nativeKeyCode)
}
}
diff --git a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/InputDispatcher.kt b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/InputDispatcher.kt
index 1723384..1d17c65 100644
--- a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/InputDispatcher.kt
+++ b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/InputDispatcher.kt
@@ -705,6 +705,30 @@
protected abstract fun KeyInputState.enqueueUp(key: Key)
+ /**
+ * Used to control lock key toggling behaviour on different platforms. Defaults to Android-style
+ * toggling. To change toggling behaviour, override this method and switch to using
+ * [LockKeyState.isLockKeyOnExcludingOffPress], or implement a different toggling behaviour.
+ */
+ protected open val KeyInputState.capsLockOn: Boolean
+ get() = capsLockState.isLockKeyOnIncludingOffPress
+
+ /**
+ * Used to control lock key toggling behaviour on different platforms. Defaults to Android-style
+ * toggling. To change toggling behaviour, override this method and switch to using
+ * [LockKeyState.isLockKeyOnExcludingOffPress], or implement a different toggling behaviour.
+ */
+ protected open val KeyInputState.numLockOn: Boolean
+ get() = numLockState.isLockKeyOnIncludingOffPress
+
+ /**
+ * Used to control lock key toggling behaviour on different platforms. Defaults to Android-style
+ * toggling. To change toggling behaviour, override this method and switch to using
+ * [LockKeyState.isLockKeyOnExcludingOffPress], or implement a different toggling behaviour.
+ */
+ protected open val KeyInputState.scrollLockOn: Boolean
+ get() = scrollLockState.isLockKeyOnIncludingOffPress
+
@OptIn(ExperimentalTestApi::class)
protected abstract fun MouseInputState.enqueueScroll(delta: Float, scrollWheel: ScrollWheel)
@@ -778,6 +802,56 @@
}
/**
+ * Toggling states for lock keys.
+ *
+ * Note that lock keys may not be toggled in the same way across all platforms.
+ *
+ * Take caps lock as an example; consistently, all platforms turn caps lock on upon the first
+ * key down event, and it stays on after the subsequent key up. However, on some platforms caps
+ * lock will turn off immediately upon the next key down event (MacOS for example), whereas
+ * other platforms (e.g. Linux, Android) wait for the next key up event before turning caps
+ * lock off.
+ *
+ * This enum breaks the lock key state down into four possible options - depending upon the
+ * interpretation of these four states, Android-like or MacOS-like behaviour can both be achieved.
+ *
+ * To get Android-like behaviour, use [isLockKeyOnIncludingOffPress],
+ * whereas for MacOS-style behaviour, use [isLockKeyOnExcludingOffPress].
+ */
+internal enum class LockKeyState(val state: Int) {
+ UP_AND_OFF(0),
+ DOWN_AND_ON(1),
+ UP_AND_ON(2),
+ DOWN_AND_OPTIONAL(3);
+
+ /**
+ * Whether or not the lock key is on. The lock key is considered on from the start of the
+ * "on press" until the end of the "off press", i.e. from the first key down event to the
+ * second key up event of the corresponding lock key.
+ */
+ val isLockKeyOnIncludingOffPress get() = state > 0
+
+ /**
+ * Whether or not the lock key is on. The lock key is considered on from the start of the
+ * "on press" until the start of the "off press", i.e. from the first key down event to the
+ * second key down event of the corresponding lock key.
+ */
+ val isLockKeyOnExcludingOffPress get() = this == DOWN_AND_ON || this == UP_AND_ON
+
+ /**
+ * Returns the next state in the cycle of lock key states.
+ */
+ fun next(): LockKeyState {
+ return when (this) {
+ UP_AND_OFF -> DOWN_AND_ON
+ DOWN_AND_ON -> UP_AND_ON
+ UP_AND_ON -> DOWN_AND_OPTIONAL
+ DOWN_AND_OPTIONAL -> UP_AND_OFF
+ }
+ }
+}
+
+/**
* The current key input state. Contains the keys that are pressed, the down time of the
* keyboard (which is the time of the last key down event), the state of the lock keys and
* the device ID.
@@ -789,9 +863,9 @@
var repeatKey: Key? = null
var repeatCount = 0
var lastRepeatTime = downTime
- var capsLockOn = false
- var numLockOn = false
- var scrollLockOn = false
+ var capsLockState: LockKeyState = LockKeyState.UP_AND_OFF
+ var numLockState: LockKeyState = LockKeyState.UP_AND_OFF
+ var scrollLockState: LockKeyState = LockKeyState.UP_AND_OFF
fun isKeyDown(key: Key): Boolean = downKeys.contains(key)
@@ -801,6 +875,7 @@
repeatKey = null
repeatCount = 0
}
+ updateLockKeys(key)
}
fun setKeyDown(key: Key) {
@@ -812,23 +887,12 @@
/**
* Updates lock key state values.
- *
- * Note that lock keys may not be toggled in the same way across all platforms.
- *
- * Take caps lock as an example; consistently, all platforms turn caps lock on upon the first
- * key down event, and it stays on after the subsequent key up. However, on some platforms caps
- * lock will turn off immediately upon the next key down event (MacOS for example), whereas
- * other platforms (e.g. linux) wait for the next key up event before turning caps lock off.
- *
- * By calling this function whenever a lock key is pressed down, MacOS-like behaviour is
- * achieved.
*/
- // TODO(Onadim): Investigate how lock key toggling is handled in Android, ChromeOS and Windows.
private fun updateLockKeys(key: Key) {
when (key) {
- Key.CapsLock -> capsLockOn = !capsLockOn
- Key.NumLock -> numLockOn = !numLockOn
- Key.ScrollLock -> scrollLockOn = !scrollLockOn
+ Key.CapsLock -> capsLockState = capsLockState.next()
+ Key.NumLock -> numLockState = numLockState.next()
+ Key.ScrollLock -> scrollLockState = scrollLockState.next()
}
}
}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/AndroidLayoutDrawTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/AndroidLayoutDrawTest.kt
index 2ca13db..ad00359 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/AndroidLayoutDrawTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/AndroidLayoutDrawTest.kt
@@ -396,7 +396,9 @@
val scope = ReusableGraphicsLayerScope()
scope.cameraDistance = cameraDistance
scope.compositingStrategy = compositingStrategy
- updateLayerProperties(scope, LayoutDirection.Ltr, Density(1f))
+ scope.layoutDirection = LayoutDirection.Ltr
+ scope.graphicsDensity = Density(1f)
+ updateLayerProperties(scope)
}
return expectedLayerType == view.layerType &&
expectedOverlappingRendering == view.hasOverlappingRendering()
@@ -436,7 +438,9 @@
).apply {
val scope = ReusableGraphicsLayerScope()
scope.cameraDistance = cameraDistance
- updateLayerProperties(scope, LayoutDirection.Ltr, Density(1f))
+ scope.layoutDirection = LayoutDirection.Ltr
+ scope.graphicsDensity = Density(1f)
+ updateLayerProperties(scope)
}
// Verify that the camera distance is applied properly even after accounting for
// the internal dp conversion within View
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/draw/ClipDrawTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/draw/ClipDrawTest.kt
index 6bc2204..490bb83 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/draw/ClipDrawTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/draw/ClipDrawTest.kt
@@ -47,6 +47,7 @@
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathOperation
import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.addOutline
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.toArgb
@@ -471,6 +472,126 @@
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
+ fun switchBetweenDifferentOutlines_differentPath_observableShape() {
+ var invertedTriangle by mutableStateOf(false)
+ val observableShape = object : Shape {
+ override fun createOutline(
+ size: Size,
+ layoutDirection: LayoutDirection,
+ density: Density
+ ): Outline {
+ return if (invertedTriangle) {
+ invertedTriangleShape.createOutline(size, layoutDirection, density)
+ } else {
+ triangleShape.createOutline(size, layoutDirection, density)
+ }
+ }
+ }
+ // to be replaced with a DrawModifier wrapped into remember, so the recomposition
+ // is not causing invalidation as the DrawModifier didn't change
+ val drawCallback: DrawScope.() -> Unit = {
+ drawRect(
+ Color.Cyan,
+ topLeft = Offset(-100f, -100f),
+ size = Size(size.width + 200f, size.height + 200f)
+ )
+ }
+
+ val clip = Modifier.graphicsLayer {
+ shape = observableShape
+ clip = true
+ drawLatch.countDown()
+ }
+
+ rule.runOnUiThreadIR {
+ activity.setContent {
+ AtLeastSize(
+ size = 30,
+ modifier = Modifier
+ .background(Color.Green)
+ .then(clip)
+ .drawBehind(drawCallback)
+ ) {
+ }
+ }
+ }
+
+ takeScreenShot(30).apply {
+ assertTriangle(Color.Cyan, Color.Green)
+ }
+
+ drawLatch = CountDownLatch(1)
+ rule.runOnUiThreadIR { invertedTriangle = true }
+
+ takeScreenShot(30).apply {
+ assertInvertedTriangle(Color.Cyan, Color.Green)
+ }
+ }
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+ @Test
+ fun switchBetweenDifferentOutlines_samePath_observableShape() {
+ var invertedTriangle by mutableStateOf(false)
+ val path = Path()
+ val observableShape = object : Shape {
+ override fun createOutline(
+ size: Size,
+ layoutDirection: LayoutDirection,
+ density: Density
+ ): Outline {
+ val outline = if (invertedTriangle) {
+ invertedTriangleShape.createOutline(size, layoutDirection, density)
+ } else {
+ triangleShape.createOutline(size, layoutDirection, density)
+ }
+ path.reset()
+ path.addOutline(outline)
+ return Outline.Generic(path)
+ }
+ }
+ // to be replaced with a DrawModifier wrapped into remember, so the recomposition
+ // is not causing invalidation as the DrawModifier didn't change
+ val drawCallback: DrawScope.() -> Unit = {
+ drawRect(
+ Color.Cyan,
+ topLeft = Offset(-100f, -100f),
+ size = Size(size.width + 200f, size.height + 200f)
+ )
+ }
+
+ val clip = Modifier.graphicsLayer {
+ shape = observableShape
+ clip = true
+ drawLatch.countDown()
+ }
+
+ rule.runOnUiThreadIR {
+ activity.setContent {
+ AtLeastSize(
+ size = 30,
+ modifier = Modifier
+ .background(Color.Green)
+ .then(clip)
+ .drawBehind(drawCallback)
+ ) {
+ }
+ }
+ }
+
+ takeScreenShot(30).apply {
+ assertTriangle(Color.Cyan, Color.Green)
+ }
+
+ drawLatch = CountDownLatch(1)
+ rule.runOnUiThreadIR { invertedTriangle = true }
+
+ takeScreenShot(30).apply {
+ assertInvertedTriangle(Color.Cyan, Color.Green)
+ }
+ }
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+ @Test
fun emitClipLater() {
val model = mutableStateOf(false)
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
index d1b40df..c63e2a9 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
@@ -4172,12 +4172,7 @@
explicitLayer: GraphicsLayer?
): OwnedLayer {
return object : OwnedLayer {
- override fun updateLayerProperties(
- scope: ReusableGraphicsLayerScope,
- layoutDirection: LayoutDirection,
- density: Density
- ) {
- }
+ override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {}
override fun isInLayer(position: Offset) = true
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt
index e8557cf..e0f3bf0 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt
@@ -570,12 +570,7 @@
internal open class MockLayer() : OwnedLayer {
- override fun updateLayerProperties(
- scope: ReusableGraphicsLayerScope,
- layoutDirection: LayoutDirection,
- density: Density
- ) {
- }
+ override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {}
override fun isInLayer(position: Offset) = true
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt
index c2ec417..339d19f 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt
@@ -508,11 +508,7 @@
drawBlock: (Canvas, GraphicsLayer?) -> Unit,
invalidateParentLayer: () -> Unit
) {}
- override fun updateLayerProperties(
- scope: ReusableGraphicsLayerScope,
- layoutDirection: LayoutDirection,
- density: Density
- ) {
+ override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
transform.reset()
// This is not expected to be 100% accurate
transform.scale(scope.scaleX, scope.scaleY)
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/GraphicsLayerOwnerLayer.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/GraphicsLayerOwnerLayer.android.kt
index 12737fd..8399995 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/GraphicsLayerOwnerLayer.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/GraphicsLayerOwnerLayer.android.kt
@@ -28,9 +28,7 @@
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
-import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.ReusableGraphicsLayerScope
-import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.draw
@@ -80,7 +78,7 @@
private val scope = CanvasDrawScope()
private var mutatedFields: Int = 0
private var transformOrigin: TransformOrigin = TransformOrigin.Center
- private var shape: Shape = RectangleShape
+ private var outline: Outline? = null
private var tmpPath: Path? = null
/**
* Optional paint used when the RenderNode is rendered on a software backed
@@ -88,17 +86,10 @@
*/
private var softwareLayerPaint: Paint? = null
- override fun updateLayerProperties(
- scope: ReusableGraphicsLayerScope,
- layoutDirection: LayoutDirection,
- density: Density,
- ) {
- var maybeChangedFields = scope.mutatedFields or mutatedFields
- if (this.layoutDirection != layoutDirection || this.density != density) {
- this.layoutDirection = layoutDirection
- this.density = density
- maybeChangedFields = maybeChangedFields or Fields.Shape
- }
+ override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
+ val maybeChangedFields = scope.mutatedFields or mutatedFields
+ this.layoutDirection = scope.layoutDirection
+ this.density = scope.graphicsDensity
if (maybeChangedFields and Fields.TransformOrigin != 0) {
this.transformOrigin = scope.transformOrigin
}
@@ -152,10 +143,6 @@
transformOrigin.pivotFractionY * size.height
)
}
- if (maybeChangedFields and Fields.Shape != 0) {
- this.shape = scope.shape
- updateOutline()
- }
if (maybeChangedFields and Fields.Clip != 0) {
graphicsLayer.clip = scope.clip
}
@@ -171,8 +158,16 @@
}
}
+ var outlineChanged = false
+
+ if (outline != scope.outline) {
+ outlineChanged = true
+ outline = scope.outline
+ updateOutline()
+ }
+
mutatedFields = scope.mutatedFields
- if (maybeChangedFields != 0) {
+ if (maybeChangedFields != 0 || outlineChanged) {
triggerRepaint()
}
}
@@ -189,7 +184,7 @@
}
private fun updateOutline() {
- val outline = shape.createOutline(size.toSize(), layoutDirection, density)
+ val outline = outline ?: return
graphicsLayer.setOutline(outline)
if (outline is Outline.Generic && Build.VERSION.SDK_INT < 33) {
// before 33 many of the paths are not clipping by rendernode. instead we have to
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/OutlineResolver.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/OutlineResolver.android.kt
index 7f15fef..30d26a5 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/OutlineResolver.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/OutlineResolver.android.kt
@@ -27,17 +27,13 @@
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
-import androidx.compose.ui.graphics.RectangleShape
-import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asAndroidPath
-import androidx.compose.ui.unit.Density
-import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.util.fastRoundToInt
/**
- * Resolves the [AndroidOutline] from the [Shape] of an [OwnedLayer].
+ * Resolves the [AndroidOutline] from the [Outline] of an [androidx.compose.ui.node.OwnedLayer].
*/
-internal class OutlineResolver(private var density: Density) {
+internal class OutlineResolver {
/**
* Flag to determine if the shape specified on the outline is supported.
@@ -51,14 +47,9 @@
private val cachedOutline = AndroidOutline().apply { alpha = 1f }
/**
- * The size of the layer. This is used in generating the [Outline] from the [Shape].
+ * The [Outline] of the Layer.
*/
- private var size: Size = Size.Zero
-
- /**
- * The [Shape] of the Outline of the Layer.
- */
- private var shape: Shape = RectangleShape
+ private var outline: Outline? = null
/**
* Asymmetric rounded rectangles need to use a Path. This caches that Path so that
@@ -107,7 +98,7 @@
/**
* Returns the Android Outline to be used in the layer.
*/
- val outline: AndroidOutline?
+ val androidOutline: AndroidOutline?
get() {
updateCache()
return if (!outlineNeeded || !isSupportedOutline) null else cachedOutline
@@ -145,7 +136,6 @@
/**
* Returns the size for a rectangular, or rounded rect outline (regardless if it
* is symmetric or asymmetric)
- * For path based outlines this returns [Size.Zero]
*/
private var rectSize: Size = Size.Zero
@@ -154,43 +144,32 @@
*/
private var outlineNeeded = false
- private var layoutDirection = LayoutDirection.Ltr
-
private var tmpTouchPointPath: Path? = null
private var tmpOpPath: Path? = null
- private var calculatedOutline: Outline? = null
/**
* Updates the values of the outline. Returns `true` when the shape has changed.
*/
fun update(
- shape: Shape,
+ outline: Outline?,
alpha: Float,
clipToOutline: Boolean,
elevation: Float,
- layoutDirection: LayoutDirection,
- density: Density
+ size: Size,
): Boolean {
cachedOutline.alpha = alpha
- val shapeChanged = this.shape != shape
- if (shapeChanged) {
- this.shape = shape
+ val outlineChanged = this.outline != outline
+ if (outlineChanged) {
+ this.outline = outline
cacheIsDirty = true
}
- val outlineNeeded = clipToOutline || elevation > 0f
+ this.rectSize = size
+ val outlineNeeded = outline != null && (clipToOutline || elevation > 0f)
if (this.outlineNeeded != outlineNeeded) {
this.outlineNeeded = outlineNeeded
cacheIsDirty = true
}
- if (this.layoutDirection != layoutDirection) {
- this.layoutDirection = layoutDirection
- cacheIsDirty = true
- }
- if (this.density != density) {
- this.density = density
- cacheIsDirty = true
- }
- return shapeChanged
+ return outlineChanged
}
/**
@@ -200,7 +179,7 @@
if (!outlineNeeded) {
return true
}
- val outline = calculatedOutline ?: return true
+ val outline = outline ?: return true
return isInOutline(outline, position.x, position.y, tmpTouchPointPath, tmpOpPath)
}
@@ -256,31 +235,20 @@
}
}
- /**
- * Updates the size.
- */
- fun update(size: Size) {
- if (this.size != size) {
- this.size = size
- cacheIsDirty = true
- }
- }
-
private fun updateCache() {
if (cacheIsDirty) {
rectTopLeft = Offset.Zero
- rectSize = size
roundedCornerRadius = 0f
outlinePath = null
cacheIsDirty = false
usePathForClip = false
- if (outlineNeeded && size.width > 0.0f && size.height > 0.0f) {
+ val outline = outline
+ if (outline != null && outlineNeeded &&
+ rectSize.width > 0.0f && rectSize.height > 0.0f) {
// Always assume the outline type is supported
// The methods to configure the outline will determine/update the flag
// if it not supported on the API level
isSupportedOutline = true
- val outline = shape.createOutline(size, layoutDirection, density)
- calculatedOutline = outline
when (outline) {
is Outline.Rectangle -> updateCacheWithRect(outline.rect)
is Outline.Rounded -> updateCacheWithRoundRect(outline.roundRect)
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/RenderNodeLayer.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/RenderNodeLayer.android.kt
index b1c7718..2e6c0be 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/RenderNodeLayer.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/RenderNodeLayer.android.kt
@@ -19,10 +19,8 @@
import android.os.Build
import android.view.View
import androidx.annotation.RequiresApi
-import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.geometry.MutableRect
import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.CanvasHolder
import androidx.compose.ui.graphics.Fields
@@ -36,10 +34,8 @@
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.GraphicLayerInfo
import androidx.compose.ui.node.OwnedLayer
-import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.LayoutDirection
/**
* RenderNode implementation of OwnedLayer.
@@ -63,11 +59,7 @@
ownerView.notifyLayerIsDirty(this, value)
}
}
- private val outlineResolver = Snapshot.withoutReadObservation {
- // we don't really care about observation here as density is applied manually
- // not observing the density changes saves performance on recording reads
- OutlineResolver(ownerView.density)
- }
+ private val outlineResolver = OutlineResolver()
private var isDestroyed = false
private var drawnWithZ = false
@@ -117,11 +109,7 @@
private var mutatedFields: Int = 0
- override fun updateLayerProperties(
- scope: ReusableGraphicsLayerScope,
- layoutDirection: LayoutDirection,
- density: Density,
- ) {
+ override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
val maybeChangedFields = scope.mutatedFields or mutatedFields
if (maybeChangedFields and Fields.TransformOrigin != 0) {
this.transformOrigin = scope.transformOrigin
@@ -179,15 +167,14 @@
renderNode.compositingStrategy = scope.compositingStrategy
}
val shapeChanged = outlineResolver.update(
- scope.shape,
+ scope.outline,
scope.alpha,
clipToOutline,
scope.shadowElevation,
- layoutDirection,
- density
+ scope.size,
)
if (outlineResolver.cacheIsDirty) {
- renderNode.setOutline(outlineResolver.outline)
+ renderNode.setOutline(outlineResolver.androidOutline)
}
val isClippingManually = clipToOutline && !outlineResolver.outlineClipSupported
if (wasClippingManually != isClippingManually || (isClippingManually && shapeChanged)) {
@@ -232,8 +219,7 @@
renderNode.top + height
)
) {
- outlineResolver.update(Size(width.toFloat(), height.toFloat()))
- renderNode.setOutline(outlineResolver.outline)
+ renderNode.setOutline(outlineResolver.androidOutline)
invalidate()
matrixCache.invalidate()
}
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ViewLayer.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ViewLayer.android.kt
index 0ccc913..6e820de 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ViewLayer.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ViewLayer.android.kt
@@ -21,10 +21,8 @@
import android.view.View
import android.view.ViewOutlineProvider
import androidx.annotation.RequiresApi
-import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.geometry.MutableRect
import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.CanvasHolder
import androidx.compose.ui.graphics.CompositingStrategy
@@ -39,10 +37,8 @@
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.GraphicLayerInfo
import androidx.compose.ui.node.OwnedLayer
-import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.LayoutDirection
import java.lang.reflect.Field
import java.lang.reflect.Method
@@ -58,11 +54,7 @@
private var drawBlock: ((canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit)? = drawBlock
private var invalidateParentLayer: (() -> Unit)? = invalidateParentLayer
- private val outlineResolver = Snapshot.withoutReadObservation {
- // we don't really care about observation here as density is applied manually
- // not observing the density changes saves performance on recording reads
- OutlineResolver(ownerView.density)
- }
+ private val outlineResolver = OutlineResolver()
// Value of the layerModifier's clipToBounds property
private var clipToBounds = false
private var clipBoundsCache: android.graphics.Rect? = null
@@ -132,11 +124,7 @@
private var mutatedFields: Int = 0
- override fun updateLayerProperties(
- scope: ReusableGraphicsLayerScope,
- layoutDirection: LayoutDirection,
- density: Density,
- ) {
+ override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
val maybeChangedFields = scope.mutatedFields or mutatedFields
if (maybeChangedFields and Fields.TransformOrigin != 0) {
this.mTransformOrigin = scope.transformOrigin
@@ -181,12 +169,11 @@
this.clipToOutline = clipToOutline
}
val shapeChanged = outlineResolver.update(
- scope.shape,
+ scope.outline,
scope.alpha,
clipToOutline,
scope.shadowElevation,
- layoutDirection,
- density
+ scope.size
)
if (outlineResolver.cacheIsDirty) {
updateOutlineResolver()
@@ -261,7 +248,7 @@
}
private fun updateOutlineResolver() {
- this.outlineProvider = if (outlineResolver.outline != null) {
+ this.outlineProvider = if (outlineResolver.androidOutline != null) {
OutlineProvider
} else {
null
@@ -287,7 +274,6 @@
if (width != this.width || height != this.height) {
pivotX = mTransformOrigin.pivotFractionX * width
pivotY = mTransformOrigin.pivotFractionY * height
- outlineResolver.update(Size(width.toFloat(), height.toFloat()))
updateOutlineResolver()
layout(left, top, left + width, top + height)
resetClipBounds()
@@ -437,7 +423,7 @@
val OutlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: android.graphics.Outline) {
view as ViewLayer
- outline.set(view.outlineResolver.outline!!)
+ outline.set(view.outlineResolver.androidOutline!!)
}
}
private var updateDisplayListIfDirtyMethod: Method? = null
diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
index bb76634..cd0eedd 100644
--- a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
+++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
@@ -2688,11 +2688,7 @@
val transform = Matrix()
val inverseTransform = Matrix()
return object : OwnedLayer {
- override fun updateLayerProperties(
- scope: ReusableGraphicsLayerScope,
- layoutDirection: LayoutDirection,
- density: Density
- ) {
+ override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
transform.reset()
// This is not expected to be 100% accurate
transform.scale(scope.scaleX, scope.scaleY)
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/GraphicsLayerScope.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/GraphicsLayerScope.kt
index 9cf5728..ca8fd83 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/GraphicsLayerScope.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/GraphicsLayerScope.kt
@@ -16,6 +16,7 @@
package androidx.compose.ui.graphics
+import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ComposableOpenTarget
import androidx.compose.runtime.RememberObserver
@@ -26,6 +27,7 @@
import androidx.compose.ui.layout.PlacementScopeMarker
import androidx.compose.ui.platform.LocalGraphicsContext
import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
/**
* Default camera distance for all layers
@@ -411,6 +413,8 @@
internal var graphicsDensity: Density = Density(1.0f)
+ internal var layoutDirection: LayoutDirection = LayoutDirection.Ltr
+
override val density: Float
get() = graphicsDensity.density
@@ -425,6 +429,10 @@
}
}
+ internal var outline: Outline? = null
+ @VisibleForTesting
+ internal set
+
fun reset() {
scaleX = 1f
scaleY = 1f
@@ -444,7 +452,12 @@
renderEffect = null
compositingStrategy = CompositingStrategy.Auto
size = Size.Unspecified
+ outline = null
// mutatedFields should be reset last as all the setters above modify it.
mutatedFields = 0
}
+
+ internal fun updateOutline() {
+ outline = shape.createOutline(size, layoutDirection, graphicsDensity)
+ }
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt
index 2e87f0b..8a6b586 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt
@@ -517,18 +517,16 @@
}
graphicsLayerScope.reset()
graphicsLayerScope.graphicsDensity = layoutNode.density
+ graphicsLayerScope.layoutDirection = layoutNode.layoutDirection
graphicsLayerScope.size = size.toSize()
snapshotObserver.observeReads(this, onCommitAffectingLayerParams) {
layerBlock.invoke(graphicsLayerScope)
+ graphicsLayerScope.updateOutline()
}
val layerPositionalProperties = layerPositionalProperties
?: LayerPositionalProperties().also { layerPositionalProperties = it }
layerPositionalProperties.copyFrom(graphicsLayerScope)
- layer.updateLayerProperties(
- graphicsLayerScope,
- layoutNode.layoutDirection,
- layoutNode.density,
- )
+ layer.updateLayerProperties(graphicsLayerScope)
isClipping = graphicsLayerScope.clip
lastLayerAlpha = graphicsLayerScope.alpha
if (invokeOnLayoutChange) {
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/OwnedLayer.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/OwnedLayer.kt
index f68b387..602a375 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/OwnedLayer.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/OwnedLayer.kt
@@ -22,10 +22,8 @@
import androidx.compose.ui.graphics.Matrix
import androidx.compose.ui.graphics.ReusableGraphicsLayerScope
import androidx.compose.ui.graphics.layer.GraphicsLayer
-import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.LayoutDirection
/**
* A layer returned by [Owner.createLayer] to separate drawn content.
@@ -33,13 +31,9 @@
internal interface OwnedLayer {
/**
- * Applies the new layer properties and causing this layer to be redrawn.
+ * Applies the new layer properties, causing this layer to be redrawn.
*/
- fun updateLayerProperties(
- scope: ReusableGraphicsLayerScope,
- layoutDirection: LayoutDirection,
- density: Density,
- )
+ fun updateLayerProperties(scope: ReusableGraphicsLayerScope)
/**
* Returns `false` if [position] is outside the clipped region or `true` if clipping
diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/SkiaLayerTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/SkiaLayerTest.kt
index 082fff8..adc95cf 100644
--- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/SkiaLayerTest.kt
+++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/SkiaLayerTest.kt
@@ -18,6 +18,7 @@
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.DefaultShadowColor
@@ -351,7 +352,8 @@
layer.resize(IntSize(1, 2))
layer.updateProperties(
- clip = true
+ clip = true,
+ size = Size(1f, 2f)
)
assertFalse(layer.isInLayer(Offset(-1f, -1f)))
@@ -363,7 +365,8 @@
layer.resize(IntSize(100, 200))
layer.updateProperties(
clip = true,
- shape = CircleShape
+ shape = CircleShape,
+ size = Size(100f, 200f)
)
assertFalse(layer.isInLayer(Offset(5f, 5f)))
@@ -394,7 +397,8 @@
shape: Shape = RectangleShape,
clip: Boolean = false,
renderEffect: RenderEffect? = null,
- compositingStrategy: CompositingStrategy = CompositingStrategy.Auto
+ compositingStrategy: CompositingStrategy = CompositingStrategy.Auto,
+ size: Size = Size.Zero
) {
val scope = ReusableGraphicsLayerScope()
scope.cameraDistance = cameraDistance
@@ -415,6 +419,9 @@
scope.clip = clip
scope.renderEffect = renderEffect
scope.compositingStrategy = compositingStrategy
- updateLayerProperties(scope, LayoutDirection.Ltr, Density(1f))
+ scope.layoutDirection = LayoutDirection.Ltr
+ scope.graphicsDensity = Density(1f)
+ scope.outline = shape.createOutline(size, scope.layoutDirection, scope.graphicsDensity)
+ updateLayerProperties(scope)
}
}
diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/OutlineCache.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/OutlineCache.skiko.kt
deleted file mode 100644
index 580fd2b..0000000
--- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/OutlineCache.skiko.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * Copyright 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.compose.ui.platform
-
-import androidx.compose.ui.graphics.Outline
-import androidx.compose.ui.graphics.Shape
-import androidx.compose.ui.unit.Density
-import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.LayoutDirection
-import androidx.compose.ui.unit.toSize
-
-/**
- * Class for storing outline. Recalculates outline when [size] or [shape] is changed.
- * It' s needed so we don't have to recreate it every time we use it for rendering
- * (it can be expensive to create outline every frame).
- */
-internal class OutlineCache(
- density: Density,
- size: IntSize,
- shape: Shape,
- layoutDirection: LayoutDirection
-) {
- var density = density
- set(value) {
- if (value != field) {
- field = value
- outline = createOutline()
- }
- }
-
- var size = size
- set(value) {
- if (value != field) {
- field = value
- outline = createOutline()
- }
- }
-
- var shape = shape
- set(value) {
- if (value != field) {
- field = value
- outline = createOutline()
- }
- }
-
- var layoutDirection = layoutDirection
- set(value) {
- if (value != field) {
- field = value
- outline = createOutline()
- }
- }
-
- var outline: Outline = createOutline()
- private set
-
- private fun createOutline() =
- shape.createOutline(size.toSize(), layoutDirection, density)
-}
diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/SkiaLayer.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/SkiaLayer.skiko.kt
index 41bffb8..b39d3ea 100644
--- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/SkiaLayer.skiko.kt
+++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/SkiaLayer.skiko.kt
@@ -31,7 +31,6 @@
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
-import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.RenderEffect
import androidx.compose.ui.graphics.ReusableGraphicsLayerScope
import androidx.compose.ui.graphics.SkiaBackedCanvas
@@ -47,7 +46,6 @@
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import org.jetbrains.skia.ClipMode
@@ -64,8 +62,7 @@
) : OwnedLayer {
private var size = IntSize.Zero
private var position = IntOffset.Zero
- private var outlineCache =
- OutlineCache(density, size, RectangleShape, LayoutDirection.Ltr)
+ private var outline: Outline? = null
// Internal for testing
internal val matrix = Matrix()
private val pictureRecorder = PictureRecorder()
@@ -105,7 +102,6 @@
override fun resize(size: IntSize) {
if (size != this.size) {
this.size = size
- outlineCache.size = size
updateMatrix()
invalidate()
}
@@ -133,11 +129,10 @@
val x = position.x
val y = position.y
- if (outlineCache.shape === RectangleShape) {
- return 0f <= x && x < size.width && 0f <= y && y < size.height
- }
- return isInOutline(outlineCache.outline, x, y)
+ val outline = outline ?: return true
+
+ return isInOutline(outline, x, y)
}
private fun getMatrix(inverse: Boolean): Matrix {
@@ -152,11 +147,7 @@
}
private var mutatedFields: Int = 0
- override fun updateLayerProperties(
- scope: ReusableGraphicsLayerScope,
- layoutDirection: LayoutDirection,
- density: Density,
- ) {
+ override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
val maybeChangedFields = scope.mutatedFields or mutatedFields
this.transformOrigin = scope.transformOrigin
this.translationX = scope.translationX
@@ -169,14 +160,12 @@
this.alpha = scope.alpha
this.clip = scope.clip
this.shadowElevation = scope.shadowElevation
- this.density = density
+ this.density = scope.graphicsDensity
this.renderEffect = scope.renderEffect
this.ambientShadowColor = scope.ambientShadowColor
this.spotShadowColor = scope.spotShadowColor
this.compositingStrategy = scope.compositingStrategy
- outlineCache.shape = scope.shape
- outlineCache.layoutDirection = layoutDirection
- outlineCache.density = density
+ this.outline = scope.outline
if (maybeChangedFields and Fields.MatrixAffectingFields != 0) {
updateMatrix()
}
@@ -245,13 +234,17 @@
drawShadow(canvas)
}
- if (clip) {
+ val outline = outline
+ val isClipping = if (clip && outline != null) {
canvas.save()
- when (val outline = outlineCache.outline) {
+ when (outline) {
is Outline.Rectangle -> canvas.clipRect(outline.rect)
is Outline.Rounded -> canvas.clipRoundRect(outline.roundRect)
is Outline.Generic -> canvas.clipPath(outline.path)
}
+ true
+ } else {
+ false
}
val currentRenderEffect = renderEffect
@@ -279,7 +272,7 @@
drawBlock(canvas, null)
canvas.restore()
- if (clip) {
+ if (isClipping) {
canvas.restore()
}
}
@@ -299,7 +292,7 @@
override fun updateDisplayList() = Unit
fun drawShadow(canvas: Canvas) = with(density) {
- val path = when (val outline = outlineCache.outline) {
+ val path = when (val outline = outline) {
is Outline.Rectangle -> Path().apply { addRect(outline.rect) }
is Outline.Rounded -> Path().apply { addRoundRect(outline.roundRect) }
is Outline.Generic -> outline.path
diff --git a/docs/api_guidelines/dependencies.md b/docs/api_guidelines/dependencies.md
index 770ad70..74b88cd 100644
--- a/docs/api_guidelines/dependencies.md
+++ b/docs/api_guidelines/dependencies.md
@@ -299,6 +299,11 @@
#### Protobuf {#dependencies-protobuf}
+**Note**: It is preferred to use the [`wire`](https://github.com/square/wire)
+library for handling protocol buffers in Android libraries as it has a binary
+stable runtime. An example of its usage can be found
+[here](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:benchmark/benchmark-common/build.gradle?q=wireRuntime%20file:gradle&ss=androidx%2Fplatform%2Fframeworks%2Fsupport).
+
[Protocol buffers](https://developers.google.com/protocol-buffers) provide a
language- and platform-neutral mechanism for serializing structured data. The
implementation enables developers to maintain protocol compatibility across
@@ -306,8 +311,18 @@
library versions included in their APKs.
The Protobuf library itself, however, does not guarantee ABI compatibility
-across minor versions and a specific version **must** be bundled with a library
-to avoid conflict with other dependencies used by the developer.
+across minor versions and a specific version **must** be used with a library to
+avoid conflict with other dependencies used by the developer. To do this, you
+must first create a new project to repackage the protobuf runtime classes, and
+then have it as a dependency in the project you generate protos in. In the
+project that generates protos, you must also relocate any import statements
+containing `com.google.protobuf` to your target package name. The
+[AndroidXRepackagePlugin](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:buildSrc/private/src/main/kotlin/androidx/build/AndroidXRepackageImplPlugin.kt)
+abstracts this for you. An example of its use to repackage the protobuf runtime
+library can be found
+[here](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:wear/protolayout/protolayout-external-protobuf/build.gradle)
+and its associated use in the library that generates protos can be found
+[here](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:wear/protolayout/protolayout-proto/build.gradle).
Additionally, the Java API surface generated by the Protobuf compiler is not
guaranteed to be stable and **must not** be exposed to developers. Library
diff --git a/graphics/graphics-path/build.gradle b/graphics/graphics-path/build.gradle
index 753e958..f24d85e 100644
--- a/graphics/graphics-path/build.gradle
+++ b/graphics/graphics-path/build.gradle
@@ -55,7 +55,6 @@
"-std=c++17",
"-Wno-unused-command-line-argument",
"-Wl,--hash-style=both", // Required to support API levels below 23
- "-fno-stack-protector",
"-fno-exceptions",
"-fno-unwind-tables",
"-fno-asynchronous-unwind-tables",
@@ -67,7 +66,6 @@
"-fomit-frame-pointer",
"-ffunction-sections",
"-fdata-sections",
- "-fstack-protector",
"-Wl,--gc-sections",
"-Wl,-Bsymbolic-functions",
"-nostdlib++"
diff --git a/libraryversions.toml b/libraryversions.toml
index 138ac26..27af2cd 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -171,7 +171,7 @@
WEBKIT = "1.12.0-alpha01"
# Adding a comment to prevent merge conflicts for Window artifact
WINDOW = "1.3.0-beta02"
-WINDOW_EXTENSIONS = "1.3.0-beta01"
+WINDOW_EXTENSIONS = "1.3.0-rc01"
WINDOW_EXTENSIONS_CORE = "1.1.0-alpha01"
WINDOW_SIDECAR = "1.0.0-rc01"
WORK = "2.10.0-alpha02"
diff --git a/playground-common/playground-plugin/build.gradle b/playground-common/playground-plugin/build.gradle
index cc6735b..c5b8bb8 100644
--- a/playground-common/playground-plugin/build.gradle
+++ b/playground-common/playground-plugin/build.gradle
@@ -21,8 +21,8 @@
dependencies {
implementation(project(":shared"))
- implementation("com.gradle:gradle-enterprise-gradle-plugin:3.16")
- implementation("com.gradle:common-custom-user-data-gradle-plugin:1.12")
+ implementation("com.gradle:develocity-gradle-plugin:3.17.2")
+ implementation("com.gradle:common-custom-user-data-gradle-plugin:2.0.1")
implementation("supportBuildSrc:private")
implementation("supportBuildSrc:public")
implementation("supportBuildSrc:plugins")
@@ -37,8 +37,8 @@
implementationClass = "androidx.playground.PlaygroundPlugin"
}
gradleEnterpriseConventions {
- id = "playground-ge-conventions"
- implementationClass = "androidx.playground.GradleEnterpriseConventionsPlugin"
+ id = "playground-develocity-conventions"
+ implementationClass = "androidx.playground.GradleDevelocityConventionsPlugin"
}
}
}
diff --git a/playground-common/playground-plugin/settings.gradle b/playground-common/playground-plugin/settings.gradle
index 1c6802c..9680ada 100644
--- a/playground-common/playground-plugin/settings.gradle
+++ b/playground-common/playground-plugin/settings.gradle
@@ -29,7 +29,7 @@
mavenCentral()
google()
gradlePluginPortal().content {
- it.includeModule("com.gradle", "gradle-enterprise-gradle-plugin")
+ it.includeModule("com.gradle", "develocity-gradle-plugin")
it.includeModule("com.gradle", "common-custom-user-data-gradle-plugin")
it.includeModule("org.spdx", "spdx-gradle-plugin")
it.includeModule("com.github.johnrengelman.shadow",
diff --git a/playground-common/playground-plugin/src/main/kotlin/androidx/playground/GradleEnterpriseConventionsPlugin.kt b/playground-common/playground-plugin/src/main/kotlin/androidx/playground/GradleDevelocityConventionsPlugin.kt
similarity index 82%
rename from playground-common/playground-plugin/src/main/kotlin/androidx/playground/GradleEnterpriseConventionsPlugin.kt
rename to playground-common/playground-plugin/src/main/kotlin/androidx/playground/GradleDevelocityConventionsPlugin.kt
index aa5351f..08b00ca 100644
--- a/playground-common/playground-plugin/src/main/kotlin/androidx/playground/GradleEnterpriseConventionsPlugin.kt
+++ b/playground-common/playground-plugin/src/main/kotlin/androidx/playground/GradleDevelocityConventionsPlugin.kt
@@ -16,34 +16,32 @@
package androidx.playground
-import com.gradle.enterprise.gradleplugin.internal.extension.BuildScanExtensionWithHiddenFeatures
-import org.gradle.api.Plugin
-import org.gradle.api.initialization.Settings
-import org.gradle.caching.http.HttpBuildCache
-import org.gradle.kotlin.dsl.gradleEnterprise
import java.net.InetAddress
import java.net.URI
import java.util.function.Function
+import org.gradle.api.Plugin
+import org.gradle.api.initialization.Settings
+import org.gradle.caching.http.HttpBuildCache
+import org.gradle.kotlin.dsl.develocity
-class GradleEnterpriseConventionsPlugin : Plugin<Settings> {
+class GradleDevelocityConventionsPlugin : Plugin<Settings> {
override fun apply(settings: Settings) {
- settings.apply(mapOf("plugin" to "com.gradle.enterprise"))
+ settings.apply(mapOf("plugin" to "com.gradle.develocity"))
settings.apply(mapOf("plugin" to "com.gradle.common-custom-user-data-gradle-plugin"))
// Github Actions always sets a "CI" environment variable
val isCI = System.getenv("CI") != null
- settings.gradleEnterprise {
- server = "https://ge.androidx.dev"
-
+ settings.develocity {
+ server.set("https://ge.androidx.dev")
buildScan.apply {
- publishAlways()
- (this as BuildScanExtensionWithHiddenFeatures).publishIfAuthenticated()
- isUploadInBackground = !isCI
- capture.isTaskInputFiles = true
-
+ uploadInBackground.set(!isCI)
+ capture.fileFingerprints.set(true)
obfuscation.hostname(HostnameHider())
obfuscation.ipAddresses(IpAddressHider())
+ publishing.onlyIf {
+ it.isAuthenticated
+ }
}
}
@@ -88,4 +86,4 @@
return listOf("0.0.0.0")
}
}
-}
\ No newline at end of file
+}
diff --git a/playground-common/playground-plugin/src/main/kotlin/androidx/playground/PlaygroundPlugin.kt b/playground-common/playground-plugin/src/main/kotlin/androidx/playground/PlaygroundPlugin.kt
index b6960f9..22c1f44 100644
--- a/playground-common/playground-plugin/src/main/kotlin/androidx/playground/PlaygroundPlugin.kt
+++ b/playground-common/playground-plugin/src/main/kotlin/androidx/playground/PlaygroundPlugin.kt
@@ -21,7 +21,7 @@
class PlaygroundPlugin : Plugin<Settings> {
override fun apply(settings: Settings) {
- settings.apply(mapOf("plugin" to "playground-ge-conventions"))
+ settings.apply(mapOf("plugin" to "playground-develocity-conventions"))
settings.extensions.create("playground", PlaygroundExtension::class.java, settings)
validateJvm(settings)
}
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt
index 2513b5d..f253f94 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt
@@ -16,11 +16,20 @@
package androidx.privacysandbox.ui.integration.testapp
+import android.app.Activity
import android.os.Bundle
+import android.util.Log
+import android.view.ViewGroup
+import android.widget.TextView
+import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.privacysandbox.sdkruntime.client.SdkSandboxManagerCompat
+import androidx.privacysandbox.sdkruntime.client.SdkSandboxProcessDeathCallbackCompat
+import androidx.privacysandbox.ui.client.view.SandboxedSdkUiSessionState
+import androidx.privacysandbox.ui.client.view.SandboxedSdkUiSessionStateChangedListener
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.integration.testaidl.ISdkApi
+import kotlinx.coroutines.runBlocking
/**
* Base fragment to be used for testing different manual flows.
@@ -31,16 +40,22 @@
*/
abstract class BaseFragment : Fragment() {
private lateinit var sdkApi: ISdkApi
+ private lateinit var sdkSandboxManager: SdkSandboxManagerCompat
+ private lateinit var activity: Activity
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- val sdkSandboxManager = SdkSandboxManagerCompat.from(requireContext())
- val loadedSdks = sdkSandboxManager.getSandboxedSdks()
- val loadedSdk = loadedSdks.firstOrNull { it.getSdkInfo()?.name == SDK_NAME }
- if (loadedSdk == null) {
- throw IllegalStateException("SDK not loaded")
+ activity = requireActivity()
+ sdkSandboxManager = SdkSandboxManagerCompat.from(requireContext())
+ runBlocking {
+ val loadedSdks = sdkSandboxManager.getSandboxedSdks()
+ var loadedSdk = loadedSdks.firstOrNull { it.getSdkInfo()?.name == SDK_NAME }
+ if (loadedSdk == null) {
+ loadedSdk = sdkSandboxManager.loadSdk(SDK_NAME, Bundle())
+ sdkSandboxManager.loadSdk(MEDIATEE_SDK_NAME, Bundle())
+ }
+ sdkApi = ISdkApi.Stub.asInterface(loadedSdk.getInterface())
}
- sdkApi = ISdkApi.Stub.asInterface(loadedSdk.getInterface())
}
/**
@@ -50,6 +65,20 @@
return sdkApi
}
+ fun SandboxedSdkView.addStateChangedListener() {
+ addStateChangedListener(StateChangeListener(this))
+ }
+
+ /**
+ * Unloads all SDKs, resulting in sandbox death. This method registers a death callback to
+ * ensure that the app is not also killed.
+ */
+ fun unloadAllSdks() {
+ sdkSandboxManager.addSdkSandboxProcessDeathCallback(Runnable::run, DeathCallbackImpl())
+ sdkSandboxManager.unloadSdk(SDK_NAME)
+ sdkSandboxManager.unloadSdk(MEDIATEE_SDK_NAME)
+ }
+
/**
* Called when the app's drawer layout state changes. When called, change the Z-order of
* any [SandboxedSdkView] owned by the fragment to ensure that the remote UI is not drawn over
@@ -58,8 +87,37 @@
*/
abstract fun handleDrawerStateChange(isDrawerOpen: Boolean)
+ private inner class StateChangeListener(val view: SandboxedSdkView) :
+ SandboxedSdkUiSessionStateChangedListener {
+ override fun onStateChanged(state: SandboxedSdkUiSessionState) {
+ Log.i(TAG, "UI session state changed to: $state")
+ if (state is SandboxedSdkUiSessionState.Error) {
+ // If the session fails to open, display the error.
+ val parent = view.parent as ViewGroup
+ val index = parent.indexOfChild(view)
+ val textView = TextView(requireActivity())
+ textView.text = state.throwable.message
+
+ requireActivity().runOnUiThread {
+ parent.removeView(view)
+ parent.addView(textView, index)
+ }
+ }
+ }
+ }
+
+ private inner class DeathCallbackImpl : SdkSandboxProcessDeathCallbackCompat {
+ override fun onSdkSandboxDied() {
+ activity.runOnUiThread {
+ Toast.makeText(activity, "Sandbox died", Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+
companion object {
private const val SDK_NAME = "androidx.privacysandbox.ui.integration.testsdkprovider"
+ private const val MEDIATEE_SDK_NAME =
+ "androidx.privacysandbox.ui.integration.mediateesdkprovider"
const val TAG = "TestSandboxClient"
}
}
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/EmptyFragment.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/EmptyFragment.kt
deleted file mode 100644
index 3a27fde..0000000
--- a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/EmptyFragment.kt
+++ /dev/null
@@ -1,22 +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.privacysandbox.ui.integration.testapp
-
-class EmptyFragment : BaseFragment() {
- override fun handleDrawerStateChange(isDrawerOpen: Boolean) {
- }
-}
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt
index ae13cb1..8d7af17 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt
@@ -109,7 +109,7 @@
val itemId = it.itemId
when (itemId) {
R.id.item_main -> switchContentFragment(MainFragment(), it.title)
- R.id.item_empty -> switchContentFragment(EmptyFragment(), it.title)
+ R.id.item_sandbox_death -> switchContentFragment(SandboxDeathFragment(), it.title)
else -> {
Log.e(TAG, "Invalid fragment option")
true
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
index 7944323..e9b2ccc 100644
--- 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
@@ -17,16 +17,12 @@
package androidx.privacysandbox.ui.integration.testapp
import android.os.Bundle
-import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
-import android.widget.TextView
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
-import androidx.privacysandbox.ui.client.view.SandboxedSdkUiSessionState
-import androidx.privacysandbox.ui.client.view.SandboxedSdkUiSessionStateChangedListener
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.integration.testaidl.ISdkApi
import com.google.android.material.switchmaterial.SwitchMaterial
@@ -79,7 +75,7 @@
}
private fun loadWebViewBannerAd() {
- webViewBannerView.addStateChangedListener(StateChangeListener(webViewBannerView))
+ webViewBannerView.addStateChangedListener()
webViewBannerView.setAdapter(
SandboxedUiAdapterFactory.createFromCoreLibInfo(
sdkApi.loadLocalWebViewAd()
@@ -101,7 +97,7 @@
}
private fun loadBottomBannerAd() {
- bottomBannerView.addStateChangedListener(StateChangeListener(bottomBannerView))
+ bottomBannerView.addStateChangedListener()
bottomBannerView.layoutParams = inflatedView.findViewById<LinearLayout>(
R.id.bottom_banner_container).layoutParams
requireActivity().runOnUiThread {
@@ -115,8 +111,7 @@
}
private fun loadResizableBannerAd() {
- resizableBannerView.addStateChangedListener(
- StateChangeListener(resizableBannerView))
+ resizableBannerView.addStateChangedListener()
resizableBannerView.setAdapter(
SandboxedUiAdapterFactory.createFromCoreLibInfo(
sdkApi.loadTestAdWithWaitInsideOnDraw(/*text=*/ "Resizable View")
@@ -164,23 +159,4 @@
sdkApi.requestResize(newWidth, newHeight)
}
}
-
- private inner class StateChangeListener(val view: SandboxedSdkView) :
- SandboxedSdkUiSessionStateChangedListener {
- override fun onStateChanged(state: SandboxedSdkUiSessionState) {
- Log.i(TAG, "UI session state changed to: $state")
- if (state is SandboxedSdkUiSessionState.Error) {
- // If the session fails to open, display the error.
- val parent = view.parent as ViewGroup
- val index = parent.indexOfChild(view)
- val textView = TextView(requireActivity())
- textView.text = state.throwable.message
-
- requireActivity().runOnUiThread {
- parent.removeView(view)
- parent.addView(textView, index)
- }
- }
- }
- }
}
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/SandboxDeathFragment.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/SandboxDeathFragment.kt
new file mode 100644
index 0000000..2c85ccd
--- /dev/null
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/SandboxDeathFragment.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.privacysandbox.ui.integration.testapp
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
+import androidx.privacysandbox.ui.client.view.SandboxedSdkView
+import androidx.privacysandbox.ui.integration.testaidl.ISdkApi
+
+class SandboxDeathFragment : BaseFragment() {
+ private lateinit var sdkApi: ISdkApi
+ private lateinit var inflatedView: View
+ private lateinit var sandboxedSdkView: SandboxedSdkView
+
+ override fun handleDrawerStateChange(isDrawerOpen: Boolean) {
+ sandboxedSdkView.orderProviderUiAboveClientUi(!isDrawerOpen)
+ }
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ inflatedView = inflater.inflate(R.layout.fragment_sandbox_death, container, false)
+ sdkApi = getSdkApi()
+ onLoaded()
+ return inflatedView
+ }
+
+ private fun onLoaded() {
+ sandboxedSdkView = inflatedView.findViewById(R.id.remote_view)
+ sandboxedSdkView.addStateChangedListener()
+ sandboxedSdkView.setAdapter(
+ SandboxedUiAdapterFactory.createFromCoreLibInfo(sdkApi.loadTestAd("Test Ad")))
+ val unloadSdksButton: Button = inflatedView.findViewById(R.id.unload_all_sdks_button)
+ unloadSdksButton.setOnClickListener {
+ unloadAllSdks()
+ }
+ }
+}
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/res/layout/action_menu.xml b/privacysandbox/ui/integration-tests/testapp/src/main/res/layout/action_menu.xml
index 836375e..1cde836 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/res/layout/action_menu.xml
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/res/layout/action_menu.xml
@@ -19,6 +19,6 @@
android:id="@+id/item_main"
android:title="Main CUJ" />
<item
- android:id="@+id/item_empty"
- android:title="Empty CUJ" />
+ android:id="@+id/item_sandbox_death"
+ android:title="Sandbox Death CUJ" />
</menu>
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/res/layout/fragment_sandbox_death.xml b/privacysandbox/ui/integration-tests/testapp/src/main/res/layout/fragment_sandbox_death.xml
new file mode 100644
index 0000000..02015bb
--- /dev/null
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/res/layout/fragment_sandbox_death.xml
@@ -0,0 +1,32 @@
+<?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.
+ -->
+
+<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+ <androidx.privacysandbox.ui.client.view.SandboxedSdkView
+ android:layout_width="500dp"
+ android:layout_height="500dp"
+ android:id="@+id/remote_view"/>
+ <Button
+ android:layout_width="match_parent"
+ android:layout_height="100dp"
+ android:id="@+id/unload_all_sdks_button"
+ android:text="@string/unload_sdks_button"/>
+</androidx.appcompat.widget.LinearLayoutCompat>
\ No newline at end of file
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/res/values/strings.xml b/privacysandbox/ui/integration-tests/testapp/src/main/res/values/strings.xml
index 5ecbc59..33f5242 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/res/values/strings.xml
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/res/values/strings.xml
@@ -21,4 +21,5 @@
<string name="local_to_internet_switch">local webview</string>
<string name="mediation_switch">Mediation</string>
<string name="app_owned_mediatee_switch">AppOwnedMediatee</string>
+ <string name="unload_sdks_button">Unload SDKs</string>
</resources>
\ No newline at end of file
diff --git a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt
index a94fd8f..d25dc59 100644
--- a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt
+++ b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt
@@ -23,6 +23,7 @@
import android.os.Build
import android.os.Bundle
import android.os.IBinder
+import android.os.RemoteException
import android.util.Log
import android.view.Display
import android.view.SurfaceControlViewHost
@@ -233,14 +234,16 @@
context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val displayId = mDisplayManager.getDisplay(Display.DEFAULT_DISPLAY).displayId
- adapterInterface.openRemoteSession(
- windowInputToken,
- displayId,
- initialWidth,
- initialHeight,
- isZOrderOnTop,
- RemoteSessionClient(context, client, clientExecutor)
- )
+ tryToCallRemoteObject {
+ adapterInterface.openRemoteSession(
+ windowInputToken,
+ displayId,
+ initialWidth,
+ initialHeight,
+ isZOrderOnTop,
+ RemoteSessionClient(context, client, clientExecutor)
+ )
+ }
}
class RemoteSessionClient(
@@ -263,6 +266,11 @@
.onSessionOpened(SessionImpl(surfaceView,
remoteSessionController, surfacePackage))
}
+ tryToCallRemoteObject {
+ remoteSessionController.asBinder().linkToDeath({
+ onRemoteSessionError("Remote process died")
+ }, 0)
+ }
}
override fun onRemoteSessionError(errorString: String) {
@@ -287,7 +295,9 @@
override val view: View = surfaceView
override fun notifyConfigurationChanged(configuration: Configuration) {
- remoteSessionController.notifyConfigurationChanged(configuration)
+ tryToCallRemoteObject {
+ remoteSessionController.notifyConfigurationChanged(configuration)
+ }
}
@SuppressLint("ClassVerificationFailure")
@@ -302,7 +312,9 @@
}
val providerResizeRunnable = Runnable {
- remoteSessionController.notifyResized(width, height)
+ tryToCallRemoteObject {
+ remoteSessionController.notifyResized(width, height)
+ }
}
val syncGroup = SurfaceSyncGroup("AppAndSdkViewsSurfaceSync")
@@ -314,11 +326,27 @@
override fun notifyZOrderChanged(isZOrderOnTop: Boolean) {
surfaceView.setZOrderOnTop(isZOrderOnTop)
- remoteSessionController.notifyZOrderChanged(isZOrderOnTop)
+ tryToCallRemoteObject {
+ remoteSessionController.notifyZOrderChanged(isZOrderOnTop)
+ }
}
override fun close() {
- remoteSessionController.close()
+ tryToCallRemoteObject { remoteSessionController.close() }
+ }
+ }
+
+ private companion object {
+
+ /**
+ * Tries to call the remote object and handles exceptions if the remote object has died.
+ */
+ private inline fun tryToCallRemoteObject(function: () -> Unit) {
+ try {
+ function()
+ } catch (e: RemoteException) {
+ Log.e(TAG, "Calling remote object failed: $e")
+ }
}
}
}
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/AutoClosingRoomOpenHelperTest.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/AutoClosingRoomOpenHelperTest.java
index 0419088..79fe546 100644
--- a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/AutoClosingRoomOpenHelperTest.java
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/AutoClosingRoomOpenHelperTest.java
@@ -43,6 +43,7 @@
import org.jetbrains.annotations.NotNull;
import org.junit.After;
import org.junit.Before;
+import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
@@ -253,6 +254,8 @@
assertFalse(testDatabase.isOpen());
}
+ // TODO(336671494): broken test
+ @Ignore
@Test
@MediumTest
public void invalidationObserver_isCalledOnEachInvalidation()
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/MultiInstanceInvalidationTest.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/MultiInstanceInvalidationTest.java
index bf9a575..43e65e1 100644
--- a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/MultiInstanceInvalidationTest.java
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/MultiInstanceInvalidationTest.java
@@ -208,6 +208,8 @@
assertThat(db1.getCustomerDao().countCustomers(), is(1));
}
+ // TODO(335890993): broken test
+ @Ignore
@Test
public void invalidationInAnotherInstance_closed() throws Exception {
final SampleDatabase db1 = openDatabase(true);
diff --git a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationValueTest.kt b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationValueTest.kt
index da629f7..46e753d 100644
--- a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationValueTest.kt
+++ b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationValueTest.kt
@@ -18,6 +18,7 @@
import androidx.kruth.assertThat
import androidx.room.compiler.codegen.JArrayTypeName
+import androidx.room.compiler.processing.compat.XConverters.toKS
import androidx.room.compiler.processing.util.Source
import androidx.room.compiler.processing.util.XTestInvocation
import androidx.room.compiler.processing.util.asJClassName
@@ -1284,7 +1285,10 @@
val kClassKTypeName = kotlin.reflect.KClass::class.asKClassName().parameterizedBy(STAR)
fun checkSingleValue(annotationValue: XAnnotationValue, expectedValue: String) {
// TODO(bcorso): Consider making the value types match in this case.
- if (!invocation.isKsp || (sourceKind == SourceKind.JAVA && !isPreCompiled)) {
+ if (!invocation.isKsp ||
+ (invocation.processingEnv.toKS().kspVersion < KotlinVersion(2, 0) &&
+ sourceKind == SourceKind.JAVA &&
+ !isPreCompiled)) {
assertThat(annotationValue.valueType.asTypeName().java)
.isEqualTo(classJTypeName)
} else {
@@ -1299,7 +1303,10 @@
fun checkListValues(annotationValue: XAnnotationValue, vararg expectedValues: String) {
// TODO(bcorso): Consider making the value types match in this case.
- if (!invocation.isKsp || (sourceKind == SourceKind.JAVA && !isPreCompiled)) {
+ if (!invocation.isKsp ||
+ (invocation.processingEnv.toKS().kspVersion < KotlinVersion(2, 0) &&
+ sourceKind == SourceKind.JAVA &&
+ !isPreCompiled)) {
assertThat(annotationValue.valueType.asTypeName().java)
.isEqualTo(JArrayTypeName.of(classJTypeName))
} else {
diff --git a/settings.gradle b/settings.gradle
index 8473061..51022df 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -28,8 +28,8 @@
classpath("com.google.protobuf:protobuf-java:3.22.3")
// upgrade okio for gcpbuildcache that is compatible with the wire plugin used by androidx
classpath("com.squareup.okio:okio:3.3.0")
- classpath("com.gradle:gradle-enterprise-gradle-plugin:3.16")
- classpath("com.gradle:common-custom-user-data-gradle-plugin:1.12")
+ classpath("com.gradle:develocity-gradle-plugin:3.17.2")
+ classpath("com.gradle:common-custom-user-data-gradle-plugin:2.0.1")
classpath("androidx.build.gradle.gcpbuildcache:gcpbuildcache:1.0.0-beta07")
}
}
@@ -72,17 +72,17 @@
)
}
-apply(plugin: "com.gradle.enterprise")
+apply(plugin: "com.gradle.develocity")
apply(plugin: "com.gradle.common-custom-user-data-gradle-plugin")
apply(plugin: "androidx.build.gradle.gcpbuildcache")
def BUILD_NUMBER = System.getenv("BUILD_NUMBER")
-gradleEnterprise {
+develocity {
server = "https://ge.androidx.dev"
buildScan {
capture {
- taskInputFiles = true
+ fileFingerprints.set(true)
}
obfuscation {
hostname { host -> "unset" }
@@ -95,9 +95,7 @@
value("androidx.projects", getRequestedProjectSubsetName() ?: "Unset")
value("androidx.useMaxDepVersions", providers.gradleProperty("androidx.useMaxDepVersions").isPresent().toString())
- // Publish scan for androidx-main
- publishAlways()
- publishIfAuthenticated()
+ publishing.onlyIf { it.authenticated }
}
}
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/SwipeToRevealDemo.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/SwipeToRevealDemo.kt
index 6bb6e21..6ece9c7 100644
--- a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/SwipeToRevealDemo.kt
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/SwipeToRevealDemo.kt
@@ -42,6 +42,7 @@
import androidx.wear.compose.foundation.ExpandableState
import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
import androidx.wear.compose.foundation.RevealActionType
+import androidx.wear.compose.foundation.RevealState
import androidx.wear.compose.foundation.RevealValue
import androidx.wear.compose.foundation.SwipeToDismissBoxState
import androidx.wear.compose.foundation.edgeSwipeToDismiss
@@ -67,7 +68,7 @@
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
-@OptIn(ExperimentalWearFoundationApi::class, ExperimentalWearMaterialApi::class)
+@OptIn(ExperimentalWearFoundationApi::class)
@Composable
fun SwipeToRevealChips(swipeToDismissBoxState: SwipeToDismissBoxState) {
val expandableStateMapping = rememberExpandableStateMapping<Int>(
@@ -90,7 +91,7 @@
var undoActionEnabled by remember { mutableStateOf(true) }
val revealState = rememberRevealState()
val coroutineScope = rememberCoroutineScope()
- val deleteItem = {
+ val deleteItem: () -> Unit = {
coroutineScope.launch {
revealState.animateTo(RevealValue.Revealed)
@@ -103,7 +104,7 @@
}
}
}
- val addItem = {
+ val addItem: () -> Unit = {
coroutineScope.launch {
revealState.animateTo(RevealValue.Revealed)
itemCount++
@@ -116,85 +117,33 @@
}
}
}
- if (expanded) {
- SwipeToRevealChip(
- modifier = Modifier
- .edgeSwipeToDismiss(swipeToDismissBoxState)
- .semantics {
- customActions = listOf(
- CustomAccessibilityAction("Delete") {
- deleteItem()
- true
- },
- CustomAccessibilityAction("Duplicate") {
- addItem()
- true
- }
- )
- },
- revealState = revealState,
- onFullSwipe = { deleteItem() },
- primaryAction = {
- SwipeToRevealPrimaryAction(
- revealState = revealState,
- icon = {
- Icon(
- SwipeToRevealDefaults.Delete,
- contentDescription = "Delete"
- )
- },
- label = { Text(text = "Delete") },
- onClick = { deleteItem() },
- )
- },
- secondaryAction = {
- SwipeToRevealSecondaryAction(
- revealState = revealState,
- content = {
- Icon(Icons.Outlined.Add, contentDescription = "Duplicate")
- },
- onClick = { addItem() }
- )
- },
- undoPrimaryAction = {
- SwipeToRevealUndoAction(
- revealState = revealState,
- label = { Text("Undo Primary Action") },
- onClick = {
- if (undoActionEnabled) {
- coroutineScope.launch {
- // reset the state when undo is clicked
- revealState.animateTo(RevealValue.Covered)
- revealState.lastActionType = RevealActionType.None
- }
- }
- }
- )
- },
- undoSecondaryAction = {
- SwipeToRevealUndoAction(
- revealState = revealState,
- label = { Text("Undo Secondary Action") },
- onClick = {
- coroutineScope.launch {
- itemCount--
- // reset the state when undo is clicked
- revealState.animateTo(RevealValue.Covered)
- revealState.lastActionType = RevealActionType.None
- }
- }
- )
+ val undoDeleteItem: () -> Unit = {
+ if (undoActionEnabled) {
+ coroutineScope.launch {
+ // reset the state when undo is clicked
+ revealState.animateTo(RevealValue.Covered)
+ revealState.lastActionType = RevealActionType.None
}
- ) {
- Chip(
- onClick = { /*TODO*/ },
- colors = ChipDefaults.secondaryChipColors(),
- modifier = Modifier.fillMaxWidth(),
- label = {
- Text("Chip #$it")
- }
- )
}
+ }
+ val undoAddItem: () -> Unit = {
+ coroutineScope.launch {
+ itemCount--
+ // reset the state when undo is clicked
+ revealState.animateTo(RevealValue.Covered)
+ revealState.lastActionType = RevealActionType.None
+ }
+ }
+ if (expanded) {
+ SwipeToRevealChipExpandable(
+ modifier = Modifier.edgeSwipeToDismiss(swipeToDismissBoxState),
+ text = "Chip #$it",
+ revealState = revealState,
+ onDeleteAction = deleteItem,
+ onUndoDelete = undoDeleteItem,
+ onDuplicateAction = addItem,
+ onUndoDuplicate = undoAddItem
+ )
} else {
Spacer(modifier = Modifier.width(200.dp))
}
@@ -203,6 +152,86 @@
}
}
+@OptIn(ExperimentalWearFoundationApi::class, ExperimentalWearMaterialApi::class)
+@Composable
+private fun SwipeToRevealChipExpandable(
+ modifier: Modifier = Modifier,
+ text: String,
+ revealState: RevealState,
+ onDeleteAction: () -> Unit,
+ onUndoDelete: () -> Unit,
+ onDuplicateAction: (() -> Unit)?,
+ onUndoDuplicate: (() -> Unit)?
+) {
+ SwipeToRevealChip(
+ modifier = modifier.semantics {
+ customActions = listOfNotNull(
+ CustomAccessibilityAction("Delete") {
+ onDeleteAction()
+ true
+ },
+ onDuplicateAction?.let {
+ CustomAccessibilityAction("Duplicate") {
+ onDuplicateAction()
+ true
+ }
+ }
+ )
+ },
+ revealState = revealState,
+ onFullSwipe = onDeleteAction,
+ primaryAction = {
+ SwipeToRevealPrimaryAction(
+ revealState = revealState,
+ icon = {
+ Icon(
+ SwipeToRevealDefaults.Delete,
+ contentDescription = "Delete"
+ )
+ },
+ label = { Text(text = "Delete") },
+ onClick = onDeleteAction,
+ )
+ },
+ secondaryAction = onDuplicateAction?.let {
+ {
+ SwipeToRevealSecondaryAction(
+ revealState = revealState,
+ content = {
+ Icon(Icons.Outlined.Add, contentDescription = "Duplicate")
+ },
+ onClick = onDuplicateAction
+ )
+ }
+ },
+ undoPrimaryAction = {
+ SwipeToRevealUndoAction(
+ revealState = revealState,
+ label = { Text("Undo Delete") },
+ onClick = onUndoDelete
+ )
+ },
+ undoSecondaryAction = onUndoDuplicate?.let {
+ {
+ SwipeToRevealUndoAction(
+ revealState = revealState,
+ label = { Text("Undo Duplicate") },
+ onClick = onUndoDuplicate
+ )
+ }
+ }
+ ) {
+ Chip(
+ onClick = { /*TODO*/ },
+ colors = ChipDefaults.secondaryChipColors(),
+ modifier = Modifier.fillMaxWidth(),
+ label = {
+ Text(text)
+ }
+ )
+ }
+}
+
@Composable
fun SwipeToRevealCards(swipeToDismissBoxState: SwipeToDismissBoxState) {
val emailMap = mutableMapOf(
diff --git a/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/TestWatchFaceServices.kt b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/TestWatchFaceServices.kt
index 704f5fd..821d610 100644
--- a/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/TestWatchFaceServices.kt
+++ b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/TestWatchFaceServices.kt
@@ -526,6 +526,7 @@
testContext: Context,
private var surfaceHolderOverride: SurfaceHolder
) : WatchFaceService() {
+ var lastComplicationType: ComplicationType? = null
init {
attachBaseContext(testContext)
@@ -605,7 +606,10 @@
CanvasType.HARDWARE,
16
) {
- override fun render(canvas: Canvas, bounds: Rect, zonedDateTime: ZonedDateTime) {}
+ override fun render(canvas: Canvas, bounds: Rect, zonedDateTime: ZonedDateTime) {
+ lastComplicationType =
+ complicationSlotsManager[123]!!.complicationData.value.type
+ }
override fun renderHighlightLayer(
canvas: Canvas,
diff --git a/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt
index 2afa5d96..b77902c 100644
--- a/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt
+++ b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt
@@ -1344,6 +1344,73 @@
assertTrue(ObservableServiceC.awaitForServiceToBeBound(UPDATE_TIMEOUT_MILLIS))
}
+
+ @Test
+ @RequiresApi(Build.VERSION_CODES.O_MR1)
+ fun overrideComplicationData() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
+ return
+ }
+ val wallpaperService =
+ TestComplicationProviderDefaultsWatchFaceService(context, surfaceHolder)
+ val interactiveInstance = getOrCreateTestSubject(wallpaperService)
+ interactiveInstance.updateComplicationData(
+ mapOf(123 to rangedValueComplicationBuilder().build())
+ )
+
+ interactiveInstance.overrideComplicationData(
+ mapOf(
+ 123 to
+ ShortTextComplicationData.Builder(
+ PlainComplicationText.Builder("TEST").build(),
+ ComplicationText.EMPTY
+ )
+ .build()
+ )
+ )
+
+ interactiveInstance.renderWatchFaceToBitmap(
+ RenderParameters(DrawMode.INTERACTIVE, WatchFaceLayer.ALL_WATCH_FACE_LAYERS, null),
+ Instant.ofEpochMilli(1234567),
+ null,
+ null
+ )
+ assertThat(wallpaperService.lastComplicationType).isEqualTo(ComplicationType.SHORT_TEXT)
+ }
+
+ @Test
+ @RequiresApi(Build.VERSION_CODES.O_MR1)
+ fun clearComplicationDataOverride() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
+ return
+ }
+ val wallpaperService =
+ TestComplicationProviderDefaultsWatchFaceService(context, surfaceHolder)
+ val interactiveInstance = getOrCreateTestSubject(wallpaperService)
+ interactiveInstance.updateComplicationData(
+ mapOf(123 to rangedValueComplicationBuilder().build())
+ )
+ interactiveInstance.overrideComplicationData(
+ mapOf(
+ 123 to
+ ShortTextComplicationData.Builder(
+ PlainComplicationText.Builder("TEST").build(),
+ ComplicationText.EMPTY
+ )
+ .build(),
+ )
+ )
+
+ interactiveInstance.clearComplicationDataOverride()
+
+ interactiveInstance.renderWatchFaceToBitmap(
+ RenderParameters(DrawMode.INTERACTIVE, WatchFaceLayer.ALL_WATCH_FACE_LAYERS, null),
+ Instant.ofEpochMilli(1234567),
+ null,
+ null
+ )
+ assertThat(wallpaperService.lastComplicationType).isEqualTo(ComplicationType.RANGED_VALUE)
+ }
}
@RunWith(AndroidJUnit4::class)
diff --git a/wear/watchface/watchface-client/src/main/java/androidx/wear/watchface/client/InteractiveWatchFaceClient.kt b/wear/watchface/watchface-client/src/main/java/androidx/wear/watchface/client/InteractiveWatchFaceClient.kt
index 6112bfe..44bd7b7 100644
--- a/wear/watchface/watchface-client/src/main/java/androidx/wear/watchface/client/InteractiveWatchFaceClient.kt
+++ b/wear/watchface/watchface-client/src/main/java/androidx/wear/watchface/client/InteractiveWatchFaceClient.kt
@@ -141,6 +141,23 @@
public fun updateComplicationData(slotIdToComplicationData: Map<Int, ComplicationData>)
/**
+ * Sets override complications which are displayed until [clearComplicationDataOverride] is
+ * called. For editors this is more efficient than repeatedly calling [renderWatchFaceToBitmap]
+ * with complication data.
+ *
+ * While there are overrides [updateComplicationData] has no effect until
+ * [clearComplicationDataOverride] is called.
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ public fun overrideComplicationData(slotIdToComplicationData: Map<Int, ComplicationData>) {}
+
+ /**
+ * Clears any overrides set by [overrideComplicationData].
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ public fun clearComplicationDataOverride() {}
+
+ /**
* Renders the watchface to a shared memory backed [Bitmap] with the given settings. Note this
* will be fairly slow since either software canvas or glReadPixels will be invoked.
*
@@ -433,6 +450,7 @@
private var lastWatchFaceColors: WatchFaceColors? = null
private var disconnectReason: Int? = null
private var closed = false
+ private var overrideSlotIdToComplicationData = HashMap<Int, ComplicationData>()
private val iWatchFaceListener =
object : IWatchfaceListener.Stub() {
@@ -505,6 +523,31 @@
)
}
+ override fun overrideComplicationData(slotIdToComplicationData: Map<Int, ComplicationData>) {
+ if (iInteractiveWatchFace.apiVersion >= 11) {
+ iInteractiveWatchFace.overrideComplicationData(
+ slotIdToComplicationData.map {
+ IdAndComplicationDataWireFormat(
+ it.key,
+ it.value.asWireComplicationData()
+ )
+ }
+ )
+ } else {
+ for ((id, complicationData) in slotIdToComplicationData) {
+ overrideSlotIdToComplicationData[id] = complicationData
+ }
+ }
+ }
+
+ override fun clearComplicationDataOverride() {
+ if (iInteractiveWatchFace.apiVersion >= 11) {
+ iInteractiveWatchFace.clearComplicationDataOverride()
+ } else {
+ overrideSlotIdToComplicationData.clear()
+ }
+ }
+
@RequiresApi(27)
override fun renderWatchFaceToBitmap(
renderParameters: RenderParameters,
@@ -519,7 +562,11 @@
renderParameters.toWireFormat(),
instant.toEpochMilli(),
userStyle?.toWireFormat(),
- idAndComplicationData?.map {
+ if (iInteractiveWatchFace.apiVersion >= 11) {
+ idAndComplicationData
+ } else {
+ mergeWithOverrideComplicationData(idAndComplicationData)
+ }?.map {
IdAndComplicationDataWireFormat(
it.key,
it.value.asWireComplicationData()
@@ -530,6 +577,27 @@
)
}
+ private fun mergeWithOverrideComplicationData(
+ idAndComplicationData: Map<Int, ComplicationData>?
+ ): Map<Int, ComplicationData>? {
+ if (overrideSlotIdToComplicationData.isEmpty()) {
+ return idAndComplicationData
+ }
+
+ if (idAndComplicationData.isNullOrEmpty()) {
+ return overrideSlotIdToComplicationData
+ }
+
+ val merged = HashMap(overrideSlotIdToComplicationData)
+ for ((id, complicationData) in idAndComplicationData) {
+ if (merged.contains(id)) {
+ continue
+ }
+ merged[id] = complicationData
+ }
+ return merged
+ }
+
override val isRemoteWatchFaceViewHostSupported = iInteractiveWatchFace.apiVersion >= 9
@RequiresApi(Build.VERSION_CODES.R)
diff --git a/wear/watchface/watchface-data/src/main/aidl/androidx/wear/watchface/control/IInteractiveWatchFace.aidl b/wear/watchface/watchface-data/src/main/aidl/androidx/wear/watchface/control/IInteractiveWatchFace.aidl
index c06fdab8..7e61727 100644
--- a/wear/watchface/watchface-data/src/main/aidl/androidx/wear/watchface/control/IInteractiveWatchFace.aidl
+++ b/wear/watchface/watchface-data/src/main/aidl/androidx/wear/watchface/control/IInteractiveWatchFace.aidl
@@ -37,12 +37,12 @@
interface IInteractiveWatchFace {
// IMPORTANT NOTE: All methods must be given an explicit transaction id that must never change
// in the future to remain binary backwards compatible.
- // Next Id: 25
+ // Next Id: 28
/**
* API version number. This should be incremented every time a new method is added.
*/
- const int API_VERSION = 10;
+ const int API_VERSION = 11;
/** Indicates a "down" touch event on the watch face. */
const int TAP_TYPE_DOWN = 0;
@@ -243,4 +243,22 @@
* @since API version 10.
*/
UserStyleFlavorsWireFormat getUserStyleFlavors() = 25;
+
+ /**
+ * Send override ComplicationData to be used until clearComplicationDataOverride is called.
+ * While overrides, any calls to updateComplicationData are deferred until
+ * clearComplicationDataOverride is called.
+ *
+ * @since API version 11.
+ */
+ oneway void overrideComplicationData(
+ in List<IdAndComplicationDataWireFormat> complicationData) = 26;
+
+ /**
+ * Clears any complicaton data set by overrideComplicationData, and activates any complications
+ * set by updateComplicationData.
+ *
+ * @since API version 11.
+ */
+ oneway void clearComplicationDataOverride() = 27;
}
diff --git a/wear/watchface/watchface/src/main/java/androidx/wear/watchface/control/InteractiveWatchFaceImpl.kt b/wear/watchface/watchface/src/main/java/androidx/wear/watchface/control/InteractiveWatchFaceImpl.kt
index 3c7ec5c..7cd7f68 100644
--- a/wear/watchface/watchface/src/main/java/androidx/wear/watchface/control/InteractiveWatchFaceImpl.kt
+++ b/wear/watchface/watchface/src/main/java/androidx/wear/watchface/control/InteractiveWatchFaceImpl.kt
@@ -24,6 +24,7 @@
import androidx.annotation.RequiresApi
import androidx.wear.watchface.TapEvent
import androidx.wear.watchface.WatchFaceService
+import androidx.wear.watchface.complications.data.toApiComplicationData
import androidx.wear.watchface.control.data.WatchFaceRenderParams
import androidx.wear.watchface.data.IdAndComplicationDataWireFormat
import androidx.wear.watchface.data.IdAndComplicationStateWireFormat
@@ -292,6 +293,22 @@
}
}
+ override fun overrideComplicationData(
+ complicationDatumWireFormats: List<IdAndComplicationDataWireFormat>
+ ): Unit = aidlMethod(TAG, "overrideComplicationData") {
+ engine?.overrideComplications(
+ complicationDatumWireFormats.associateBy(
+ { it.id },
+ { it.complicationData.toApiComplicationData() }
+ )
+ )
+ }
+
+ override fun clearComplicationDataOverride(): Unit =
+ aidlMethod(TAG, "overrideComplicationData") {
+ engine?.removeAnyComplicationOverrides()
+ }
+
fun onDestroy() {
// Note this is almost certainly called on the ui thread, from release() above.
runBlocking {