Merge "Upgrade to Gradle 8.8-rc1" into androidx-main
diff --git a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/utils/Constants.kt b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/utils/Constants.kt
index c82ead6..d184d2f 100644
--- a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/utils/Constants.kt
+++ b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/utils/Constants.kt
@@ -20,7 +20,7 @@
// Minimum AGP version required
internal val MIN_AGP_VERSION_REQUIRED_INCLUSIVE = AndroidPluginVersion(8, 0, 0)
-internal val MAX_AGP_VERSION_RECOMMENDED_EXCLUSIVE = AndroidPluginVersion(8, 5, 0).alpha(1)
+internal val MAX_AGP_VERSION_RECOMMENDED_EXCLUSIVE = AndroidPluginVersion(8, 6, 0).alpha(1)
// Prefix for the build type baseline profile
internal const val BUILD_TYPE_BASELINE_PROFILE_PREFIX = "nonMinified"
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingBoundsTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingBoundsTest.kt
new file mode 100644
index 0000000..d0e5b20
--- /dev/null
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingBoundsTest.kt
@@ -0,0 +1,190 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.foundation.text
+
+import android.view.inputmethod.CursorAnchorInfo
+import android.view.inputmethod.ExtractedText
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.safeContentPadding
+import androidx.compose.foundation.setFocusableContent
+import androidx.compose.foundation.text.handwriting.HandwritingBoundsVerticalOffset
+import androidx.compose.foundation.text.handwriting.isStylusHandwritingSupported
+import androidx.compose.foundation.text.input.InputMethodInterceptor
+import androidx.compose.foundation.text.input.internal.InputMethodManager
+import androidx.compose.foundation.text.input.internal.inputMethodManagerFactory
+import androidx.compose.foundation.text.matchers.isZero
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsFocused
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.requestFocus
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class CoreTextFieldHandwritingBoundsTest {
+ @get:Rule
+ val rule = createComposeRule()
+ private val inputMethodInterceptor = InputMethodInterceptor(rule)
+
+ private val fakeImm = object : InputMethodManager {
+ private var stylusHandwritingStartCount = 0
+
+ fun expectStylusHandwriting(started: Boolean) {
+ if (started) {
+ assertThat(stylusHandwritingStartCount).isEqualTo(1)
+ stylusHandwritingStartCount = 0
+ } else {
+ assertThat(stylusHandwritingStartCount).isZero()
+ }
+ }
+
+ override fun isActive(): Boolean = true
+
+ override fun restartInput() {}
+
+ override fun showSoftInput() {}
+
+ override fun hideSoftInput() {}
+
+ override fun updateExtractedText(token: Int, extractedText: ExtractedText) {}
+
+ override fun updateSelection(
+ selectionStart: Int,
+ selectionEnd: Int,
+ compositionStart: Int,
+ compositionEnd: Int
+ ) {}
+
+ override fun updateCursorAnchorInfo(cursorAnchorInfo: CursorAnchorInfo) {}
+
+ override fun startStylusHandwriting() {
+ ++stylusHandwritingStartCount
+ }
+ }
+
+ @Before
+ fun setup() {
+ // Test is only meaningful when stylusHandwriting is supported.
+ assumeTrue(isStylusHandwritingSupported)
+ }
+
+ @Test
+ fun coreTextField_stylusPointerInEditorBounds_focusAndStartHandwriting() {
+ inputMethodManagerFactory = { fakeImm }
+
+ val editorTag1 = "CoreTextField1"
+ val editorTag2 = "CoreTextField2"
+
+ setContent {
+ Column(Modifier.safeContentPadding()) {
+ EditLine(Modifier.testTag(editorTag1))
+ EditLine(Modifier.testTag(editorTag2))
+ }
+ }
+
+ rule.onNodeWithTag(editorTag1).performStylusHandwriting()
+
+ rule.waitForIdle()
+
+ rule.onNodeWithTag(editorTag1).assertIsFocused()
+ fakeImm.expectStylusHandwriting(true)
+ }
+
+ @Test
+ fun coreTextField_stylusPointerInOverlappingArea_focusedEditorStartHandwriting() {
+ inputMethodManagerFactory = { fakeImm }
+
+ val editorTag1 = "CoreTextField1"
+ val editorTag2 = "CoreTextField2"
+ val spacerTag = "Spacer"
+
+ setContent {
+ Column(Modifier.safeContentPadding()) {
+ EditLine(Modifier.testTag(editorTag1))
+ Spacer(
+ modifier = Modifier.fillMaxWidth()
+ .height(HandwritingBoundsVerticalOffset)
+ .testTag(spacerTag)
+ )
+ EditLine(Modifier.testTag(editorTag2))
+ }
+ }
+
+ rule.onNodeWithTag(editorTag2).requestFocus()
+ rule.waitForIdle()
+
+ // Spacer's height equals to HandwritingBoundsVerticalPadding, both editor will receive the
+ // event.
+ rule.onNodeWithTag(spacerTag).performStylusHandwriting()
+ rule.waitForIdle()
+
+ // Assert that focus didn't change, handwriting is started on the focused editor 2.
+ rule.onNodeWithTag(editorTag2).assertIsFocused()
+ fakeImm.expectStylusHandwriting(true)
+
+ rule.onNodeWithTag(editorTag1).requestFocus()
+ rule.onNodeWithTag(spacerTag).performStylusHandwriting()
+ rule.waitForIdle()
+
+ // Now handwriting is performed on the focused editor 1.
+ rule.onNodeWithTag(editorTag1).assertIsFocused()
+ fakeImm.expectStylusHandwriting(true)
+ }
+
+ @Composable
+ fun EditLine(modifier: Modifier = Modifier) {
+ var value by remember { mutableStateOf(TextFieldValue()) }
+ CoreTextField(
+ value = value,
+ onValueChange = { value = it },
+ modifier = modifier
+ .fillMaxWidth()
+ // make the size of TextFields equal to padding, so that touch bounds of editors
+ // in the same column/row are overlapping.
+ .height(HandwritingBoundsVerticalOffset)
+ )
+ }
+
+ private fun setContent(
+ extraItemForInitialFocus: Boolean = true,
+ content: @Composable () -> Unit
+ ) {
+ rule.setFocusableContent(extraItemForInitialFocus) {
+ inputMethodInterceptor.Content {
+ content()
+ }
+ }
+ }
+}
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/HandwritingTestUtils.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/HandwritingTestUtils.kt
index 966c702..5888282 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/HandwritingTestUtils.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/HandwritingTestUtils.kt
@@ -101,11 +101,6 @@
}
private fun sendTouchEvent(action: Int) {
- val positionInScreen = run {
- val array = intArrayOf(0, 0)
- root.view.getLocationOnScreen(array)
- Offset(array[0].toFloat(), array[1].toFloat())
- }
val motionEvent = MotionEvent.obtain(
/* downTime = */ downTime,
/* eventTime = */ currentTime,
@@ -125,13 +120,13 @@
// test if it handles them properly (versus breaking here and we not knowing
// if Compose properly handles these values).
x = if (startOffset.isValid()) {
- positionInScreen.x + startOffset.x
+ startOffset.x
} else {
Float.NaN
}
y = if (startOffset.isValid()) {
- positionInScreen.y + startOffset.y
+ startOffset.y
} else {
Float.NaN
}
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/handwriting/HandwritingDetector.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/handwriting/HandwritingDetector.android.kt
index 6b69713..a6ac8fc 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/handwriting/HandwritingDetector.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/handwriting/HandwritingDetector.android.kt
@@ -21,7 +21,6 @@
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
-import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.PointerInputModifierNode
@@ -93,11 +92,9 @@
pointerInputNode.onCancelPointerInput()
}
- val pointerInputNode = delegate(SuspendingPointerInputModifierNode {
- detectStylusHandwriting {
- callback()
- composeImm.prepareStylusHandwritingDelegation()
- return@detectStylusHandwriting true
- }
+ val pointerInputNode = delegate(StylusHandwritingNode {
+ callback()
+ composeImm.prepareStylusHandwritingDelegation()
+ return@StylusHandwritingNode true
})
}
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 50eccc5..8bd9033 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
@@ -27,8 +27,7 @@
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
-import androidx.compose.foundation.text.handwriting.detectStylusHandwriting
-import androidx.compose.foundation.text.handwriting.isStylusHandwritingSupported
+import androidx.compose.foundation.text.handwriting.stylusHandwriting
import androidx.compose.foundation.text.input.internal.createLegacyPlatformTextInputServiceAdapter
import androidx.compose.foundation.text.input.internal.legacyTextInputAdapter
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
@@ -82,6 +81,7 @@
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
@@ -405,34 +405,6 @@
textDragObserver = manager.touchSelectionObserver,
)
.pointerHoverIcon(textPointerIcon)
- .then(
- if (isStylusHandwritingSupported && writeable) {
- Modifier.pointerInput(Unit) {
- detectStylusHandwriting {
- if (!state.hasFocus) {
- focusRequester.requestFocus()
- }
- // If this is a password field, we can't trigger handwriting.
- // The expected behavior is 1) request focus 2) show software keyboard.
- // Note: TextField will show software keyboard automatically when it
- // gain focus. 3) show a toast message telling that handwriting is not
- // supported for password fields. TODO(b/335294152)
- if (imeOptions.keyboardType != KeyboardType.Password) {
- // TextInputService is calling LegacyTextInputServiceAdapter under the
- // hood. And because it's a public API, startStylusHandwriting is added
- // to legacyTextInputServiceAdapter instead.
- // startStylusHandwriting may be called before the actual input
- // session starts when the editor is not focused, this is handled
- // internally by the LegacyTextInputServiceAdapter.
- legacyTextInputServiceAdapter.startStylusHandwriting()
- }
- true
- }
- }
- } else {
- Modifier
- }
- )
val drawModifier = Modifier.drawBehind {
state.layoutResult?.let { layoutResult ->
@@ -657,10 +629,32 @@
imeAction = imeOptions.imeAction,
)
+ val stylusHandwritingModifier = Modifier.stylusHandwriting(writeable) {
+ if (!state.hasFocus) {
+ focusRequester.requestFocus()
+ }
+ // If this is a password field, we can't trigger handwriting.
+ // The expected behavior is 1) request focus 2) show software keyboard.
+ // Note: TextField will show software keyboard automatically when it
+ // gain focus. 3) show a toast message telling that handwriting is not
+ // supported for password fields. TODO(b/335294152)
+ if (imeOptions.keyboardType != KeyboardType.Password) {
+ // TextInputService is calling LegacyTextInputServiceAdapter under the
+ // hood. And because it's a public API, startStylusHandwriting is added
+ // to legacyTextInputServiceAdapter instead.
+ // startStylusHandwriting may be called before the actual input
+ // session starts when the editor is not focused, this is handled
+ // internally by the LegacyTextInputServiceAdapter.
+ legacyTextInputServiceAdapter.startStylusHandwriting()
+ }
+ true
+ }
+
// Modifiers that should be applied to the outer text field container. Usually those include
// gesture and semantics modifiers.
val decorationBoxModifier = modifier
.legacyTextInputAdapter(legacyTextInputServiceAdapter, state, manager)
+ .then(stylusHandwritingModifier)
.then(focusModifier)
.interceptDPadAndMoveFocus(state, focusManager)
.previewKeyEventToDeselectOnBack(state, manager)
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/handwriting/StylusHandwriting.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/handwriting/StylusHandwriting.kt
index 3d4ba3b..381dbbf 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/handwriting/StylusHandwriting.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/handwriting/StylusHandwriting.kt
@@ -18,69 +18,194 @@
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.layout.padding
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusEventModifierNode
+import androidx.compose.ui.focus.FocusState
+import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
-import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.PointerType
+import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.node.DelegatingNode
+import androidx.compose.ui.node.LayoutModifierNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.PointerInputModifierNode
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.offset
import androidx.compose.ui.util.fastFirstOrNull
/**
- * A utility function that detects stylus movements and calls the [onHandwritingSlopExceeded] when
+ * A modifier that detects stylus movements and calls the [onHandwritingSlopExceeded] when
* it detects that stylus movement has exceeds the handwriting slop.
- * If [onHandwritingSlopExceeded] returns true, this method will consume the events and consider
+ * If [onHandwritingSlopExceeded] returns true, it will consume the events and consider
* that the handwriting has successfully started. Otherwise, it'll stop monitoring the current
* gesture.
+ * @param enabled whether this modifier is enabled, it's used for the case where the editor is
+ * readOnly or disabled.
+ * @param onHandwritingSlopExceeded the callback that's invoked when it detects stylus handwriting.
+ * The return value determines whether the handwriting is triggered or not. When it's true, this
+ * modifier will consume the pointer events.
*/
-internal suspend inline fun PointerInputScope.detectStylusHandwriting(
- crossinline onHandwritingSlopExceeded: () -> Boolean
-) {
- awaitEachGesture {
- val firstDown =
- awaitFirstDown(requireUnconsumed = true, pass = PointerEventPass.Initial)
+internal fun Modifier.stylusHandwriting(
+ enabled: Boolean,
+ onHandwritingSlopExceeded: () -> Boolean
+): Modifier = if (enabled && isStylusHandwritingSupported) {
+ this.then(StylusHandwritingElementWithNegativePadding(onHandwritingSlopExceeded))
+ .padding(
+ horizontal = HandwritingBoundsHorizontalOffset,
+ vertical = HandwritingBoundsVerticalOffset
+ )
+} else {
+ this
+}
- val isStylus =
- firstDown.type == PointerType.Stylus || firstDown.type == PointerType.Eraser
- if (!isStylus) {
- return@awaitEachGesture
+private data class StylusHandwritingElementWithNegativePadding(
+ val onHandwritingSlopExceeded: () -> Boolean
+) : ModifierNodeElement<StylusHandwritingNodeWithNegativePadding>() {
+ override fun create(): StylusHandwritingNodeWithNegativePadding {
+ return StylusHandwritingNodeWithNegativePadding(onHandwritingSlopExceeded)
+ }
+
+ override fun update(node: StylusHandwritingNodeWithNegativePadding) {
+ node.onHandwritingSlopExceeded = onHandwritingSlopExceeded
+ }
+
+ override fun InspectorInfo.inspectableProperties() {
+ name = "stylusHandwriting"
+ properties["onHandwritingSlopExceeded"] = onHandwritingSlopExceeded
+ }
+}
+
+/**
+ * A stylus handwriting node with negative padding. This node should be used in pair with a padding
+ * modifier. Together, they expands the touch bounds of the editor while keep its visual bounds the
+ * same.
+ * Note: this node is a temporary solution, ideally we don't need it.
+ */
+private class StylusHandwritingNodeWithNegativePadding(
+ onHandwritingSlopExceeded: () -> Boolean
+) : StylusHandwritingNode(onHandwritingSlopExceeded), LayoutModifierNode {
+ override fun MeasureScope.measure(
+ measurable: Measurable,
+ constraints: Constraints
+ ): MeasureResult {
+ val paddingVerticalPx = HandwritingBoundsVerticalOffset.roundToPx()
+ val paddingHorizontalPx = HandwritingBoundsHorizontalOffset.roundToPx()
+ val newConstraint = constraints.offset(
+ 2 * paddingHorizontalPx,
+ 2 * paddingVerticalPx
+ )
+ val placeable = measurable.measure(newConstraint)
+
+ val height = placeable.height - paddingVerticalPx * 2
+ val width = placeable.width - paddingHorizontalPx * 2
+ return layout(width, height) {
+ placeable.place(-paddingHorizontalPx, -paddingVerticalPx)
}
- // Await the touch slop before long press timeout.
- var exceedsTouchSlop: PointerInputChange? = null
- // The stylus move must exceeds touch slop before long press timeout.
- while (true) {
- val pointerEvent = awaitPointerEvent(pass = PointerEventPass.Main)
- // The tracked pointer is consumed or lifted, stop tracking.
- val change = pointerEvent.changes.fastFirstOrNull {
- !it.isConsumed && it.id == firstDown.id && it.pressed
- }
- if (change == null) {
- break
+ }
+
+ override fun sharePointerInputWithSiblings(): Boolean {
+ // Share events to siblings so that the expanded touch bounds won't block other elements
+ // surrounding the editor.
+ return true
+ }
+}
+
+internal open class StylusHandwritingNode(
+ var onHandwritingSlopExceeded: () -> Boolean
+) : DelegatingNode(), PointerInputModifierNode, FocusEventModifierNode {
+
+ private var focused = false
+
+ override fun onFocusEvent(focusState: FocusState) {
+ focused = focusState.isFocused
+ }
+
+ private val suspendingPointerInputModifierNode = delegate(SuspendingPointerInputModifierNode {
+ awaitEachGesture {
+ val firstDown =
+ awaitFirstDown(requireUnconsumed = true, pass = PointerEventPass.Initial)
+
+ val isStylus =
+ firstDown.type == PointerType.Stylus || firstDown.type == PointerType.Eraser
+ if (!isStylus) {
+ return@awaitEachGesture
}
- val time = change.uptimeMillis - firstDown.uptimeMillis
- if (time >= viewConfiguration.longPressTimeoutMillis) {
- break
+ val isInBounds = firstDown.position.x >= 0 && firstDown.position.x < size.width &&
+ firstDown.position.y >= 0 && firstDown.position.y < size.height
+
+ // If the editor is focused or the first down is within the editor's bounds, we
+ // await the initial pass. This prioritize the focused editor over unfocused
+ // editor.
+ val pass = if (focused || isInBounds) {
+ PointerEventPass.Initial
+ } else {
+ PointerEventPass.Main
}
- val offset = change.position - firstDown.position
- if (offset.getDistance() > viewConfiguration.handwritingSlop) {
- exceedsTouchSlop = change
- break
+ // Await the touch slop before long press timeout.
+ var exceedsTouchSlop: PointerInputChange? = null
+ // The stylus move must exceeds touch slop before long press timeout.
+ while (true) {
+ val pointerEvent = awaitPointerEvent(pass)
+ // The tracked pointer is consumed or lifted, stop tracking.
+ val change = pointerEvent.changes.fastFirstOrNull {
+ !it.isConsumed && it.id == firstDown.id && it.pressed
+ }
+ if (change == null) {
+ break
+ }
+
+ val time = change.uptimeMillis - firstDown.uptimeMillis
+ if (time >= viewConfiguration.longPressTimeoutMillis) {
+ break
+ }
+
+ val offset = change.position - firstDown.position
+ if (offset.getDistance() > viewConfiguration.handwritingSlop) {
+ exceedsTouchSlop = change
+ break
+ }
+ }
+
+ if (exceedsTouchSlop == null || !onHandwritingSlopExceeded.invoke()) {
+ return@awaitEachGesture
+ }
+ exceedsTouchSlop.consume()
+
+ // Consume the remaining changes of this pointer.
+ while (true) {
+ val pointerEvent = awaitPointerEvent(pass = PointerEventPass.Initial)
+ val pointerChange = pointerEvent.changes.fastFirstOrNull {
+ !it.isConsumed && it.id == firstDown.id && it.pressed
+ } ?: return@awaitEachGesture
+ pointerChange.consume()
}
}
+ })
- if (exceedsTouchSlop == null || !onHandwritingSlopExceeded.invoke()) {
- return@awaitEachGesture
- }
- exceedsTouchSlop.consume()
+ override fun onPointerEvent(
+ pointerEvent: PointerEvent,
+ pass: PointerEventPass,
+ bounds: IntSize
+ ) {
+ suspendingPointerInputModifierNode.onPointerEvent(pointerEvent, pass, bounds)
+ }
- // Consume the remaining changes of this pointer.
- while (true) {
- val pointerEvent = awaitPointerEvent(pass = PointerEventPass.Initial)
- val pointerChange = pointerEvent.changes.fastFirstOrNull {
- !it.isConsumed && it.id == firstDown.id && it.pressed
- } ?: return@awaitEachGesture
- pointerChange.consume()
- }
+ override fun onCancelPointerInput() {
+ suspendingPointerInputModifierNode.onCancelPointerInput()
+ }
+
+ fun resetPointerInputHandler() {
+ suspendingPointerInputModifierNode.resetPointerInputHandler()
}
}
@@ -89,3 +214,9 @@
* and NOT for checking whether the IME supports handwriting.
*/
internal expect val isStylusHandwritingSupported: Boolean
+
+/**
+ * The amount of the padding added to the handwriting bounds of an editor.
+ */
+internal val HandwritingBoundsVerticalOffset = 40.dp
+internal val HandwritingBoundsHorizontalOffset = 10.dp
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt
index eeddf40..5d02a03f 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt
@@ -29,7 +29,7 @@
import androidx.compose.foundation.text.Handle
import androidx.compose.foundation.text.KeyboardActionScope
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.foundation.text.handwriting.detectStylusHandwriting
+import androidx.compose.foundation.text.handwriting.StylusHandwritingNode
import androidx.compose.foundation.text.handwriting.isStylusHandwritingSupported
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.KeyboardActionHandler
@@ -225,41 +225,36 @@
detectTextFieldLongPressAndAfterDrag(requestFocus)
}
}
- // Note: when editable changes (enabled or readOnly changes) or keyboard type changes,
- // this pointerInputModifier is reset. And we don't need to worry about cancel or launch
- // the stylus handwriting detecting job.
- if (isStylusHandwritingSupported && editable) {
- launch(start = CoroutineStart.UNDISPATCHED) {
- detectStylusHandwriting {
- if (!isFocused) {
- requestFocus()
- }
- // If this is a password field, we can't trigger handwriting.
- // The expected behavior is 1) request focus 2) show software keyboard.
- // Note: TextField will show software keyboard automatically when it
- // gain focus. 3) show a toast message telling that handwriting is not
- // supported for password fields. TODO(b/335294152)
- if (keyboardOptions.keyboardType != KeyboardType.Password) {
- // Send the handwriting start signal to platform.
- // The editor should send the signal when it is focused or is about
- // to gain focus, Here are more details:
- // 1) if the editor already has an active input session, the
- // platform handwriting service should already listen to this flow
- // and it'll start handwriting right away.
- //
- // 2) if the editor is not focused, but it'll be focused and
- // create a new input session, one handwriting signal will be
- // replayed when the platform collect this flow. And the platform
- // should trigger handwriting accordingly.
- stylusHandwritingTrigger?.tryEmit(Unit)
- }
- return@detectStylusHandwriting true
- }
- }
- }
}
})
+ private val stylusHandwritingNode = delegate(StylusHandwritingNode {
+ if (!isFocused) {
+ requestFocus()
+ }
+
+ // If this is a password field, we can't trigger handwriting.
+ // The expected behavior is 1) request focus 2) show software keyboard.
+ // Note: TextField will show software keyboard automatically when it
+ // gain focus. 3) show a toast message telling that handwriting is not
+ // supported for password fields. TODO(b/335294152)
+ if (keyboardOptions.keyboardType != KeyboardType.Password) {
+ // Send the handwriting start signal to platform.
+ // The editor should send the signal when it is focused or is about
+ // to gain focus, Here are more details:
+ // 1) if the editor already has an active input session, the
+ // platform handwriting service should already listen to this flow
+ // and it'll start handwriting right away.
+ //
+ // 2) if the editor is not focused, but it'll be focused and
+ // create a new input session, one handwriting signal will be
+ // replayed when the platform collect this flow. And the platform
+ // should trigger handwriting accordingly.
+ stylusHandwritingTrigger?.tryEmit(Unit)
+ }
+ return@StylusHandwritingNode true
+ })
+
/**
* The last enter event that was submitted to [interactionSource] from [dragAndDropNode]. We
* need to keep a reference to this event to send a follow-up exit event.
@@ -458,6 +453,7 @@
if (textFieldSelectionState != previousTextFieldSelectionState) {
pointerInputNode.resetPointerInputHandler()
+ stylusHandwritingNode.resetPointerInputHandler()
if (isAttached) {
textFieldSelectionState.receiveContentConfiguration =
receiveContentConfigurationProvider
@@ -466,6 +462,7 @@
if (interactionSource != previousInteractionSource) {
pointerInputNode.resetPointerInputHandler()
+ stylusHandwritingNode.resetPointerInputHandler()
}
}
@@ -604,6 +601,7 @@
disposeInputSession()
textFieldState.collapseSelectionToMax()
}
+ stylusHandwritingNode.onFocusEvent(focusState)
}
override fun onAttach() {
@@ -625,10 +623,12 @@
pass: PointerEventPass,
bounds: IntSize
) {
+ stylusHandwritingNode.onPointerEvent(pointerEvent, pass, bounds)
pointerInputNode.onPointerEvent(pointerEvent, pass, bounds)
}
override fun onCancelPointerInput() {
+ stylusHandwritingNode.onCancelPointerInput()
pointerInputNode.onCancelPointerInput()
}
diff --git a/compose/material/material-icons-extended/build.gradle b/compose/material/material-icons-extended/build.gradle
index 324023b..aff828d 100644
--- a/compose/material/material-icons-extended/build.gradle
+++ b/compose/material/material-icons-extended/build.gradle
@@ -136,3 +136,13 @@
android {
namespace "androidx.compose.material.icons.extended"
}
+
+afterEvaluate {
+ // Workaround for b/337776938
+ tasks.named("lintAnalyzeDebugAndroidTest").configure {
+ it.dependsOn("generateTestFilesDebugAndroidTest")
+ }
+ tasks.named("generateDebugAndroidTestLintModel").configure {
+ it.dependsOn("generateTestFilesDebugAndroidTest")
+ }
+}
diff --git a/compose/ui/ui-text/lint-baseline.xml b/compose/ui/ui-text/lint-baseline.xml
index e34eca2..d8802c0 100644
--- a/compose/ui/ui-text/lint-baseline.xml
+++ b/compose/ui/ui-text/lint-baseline.xml
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
-<issues format="6" by="lint 8.3.0-beta01" type="baseline" client="gradle" dependencies="false" name="AGP (8.3.0-beta01)" variant="all" version="8.3.0-beta01">
+<issues format="6" by="lint 8.5.0-alpha03" type="baseline" client="gradle" dependencies="false" name="AGP (8.5.0-alpha03)" variant="all" version="8.5.0-alpha03">
<issue
id="NewApi"
@@ -12,7 +12,7 @@
<issue
id="NewApi"
- message="Cast from `BlendMode` to `Comparable` requires API level 29 (current min is 21)"
+ message="Implicit cast from `BlendMode` to `Comparable` requires API level 29 (current min is 21)"
errorLine1=" assertThat(paragraph.textPaint.blendMode).isEqualTo(BlendMode.SrcOver)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@@ -30,7 +30,7 @@
<issue
id="NewApi"
- message="Cast from `BlendMode` to `Comparable` requires API level 29 (current min is 21)"
+ message="Implicit cast from `BlendMode` to `Comparable` requires API level 29 (current min is 21)"
errorLine1=" assertThat(paragraph.textPaint.blendMode).isEqualTo(BlendMode.SrcOver)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@@ -48,7 +48,7 @@
<issue
id="NewApi"
- message="Cast from `BlendMode` to `Comparable` requires API level 29 (current min is 21)"
+ message="Implicit cast from `BlendMode` to `Comparable` requires API level 29 (current min is 21)"
errorLine1=" assertThat(textPaint.blendMode).isEqualTo(BlendMode.SrcOver)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
@@ -75,7 +75,7 @@
<issue
id="NewApi"
- message="Cast from `BlendMode` to `Comparable` requires API level 29 (current min is 21)"
+ message="Implicit cast from `BlendMode` to `Comparable` requires API level 29 (current min is 21)"
errorLine1=" assertThat(textPaint.blendMode).isEqualTo(BlendMode.DstOver)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/SharePointerInputWithSiblingTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/SharePointerInputWithSiblingTest.kt
new file mode 100644
index 0000000..1fac1b6
--- /dev/null
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/SharePointerInputWithSiblingTest.kt
@@ -0,0 +1,285 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.node
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.DrawerValue
+import androidx.compose.material.ModalDrawer
+import androidx.compose.material.rememberDrawerState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.PointerEvent
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class SharePointerInputWithSiblingTest {
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ @Test
+ fun Drawer_drawerContentSharePointerInput_cantClickContent() {
+ var box1Clicked = false
+ var box2Clicked = false
+
+ rule.setContent {
+ val drawerState = rememberDrawerState(DrawerValue.Open)
+
+ ModalDrawer(
+ drawerState = drawerState,
+ drawerContent = {
+ Box(Modifier
+ .fillMaxSize()
+ .testTag("box1")
+ .testPointerInput(sharePointerInputWithSibling = true) {
+ box1Clicked = true
+ }
+ )
+ },
+ content = {
+ Box(Modifier
+ .fillMaxSize()
+ .testTag("box2")
+ .testPointerInput {
+ box2Clicked = true
+ }
+ )
+ }
+ )
+ }
+
+ rule.onNodeWithTag("box1").performClick()
+ assertThat(box1Clicked).isTrue()
+ assertThat(box2Clicked).isFalse()
+ }
+
+ @Test
+ fun stackedBox_doSharePointer() {
+ var box1Clicked = false
+ var box2Clicked = false
+
+ rule.setContent {
+ Box(Modifier.size(50.dp)) {
+ Box(Modifier
+ .fillMaxSize()
+ .testTag("box1")
+ .testPointerInput {
+ box1Clicked = true
+ }
+ )
+
+ Box(Modifier
+ .fillMaxSize()
+ .testTag("box2")
+ .testPointerInput(sharePointerInputWithSibling = true) {
+ box2Clicked = true
+ }
+ )
+ }
+ }
+
+ rule.onNodeWithTag("box2").performClick()
+ assertThat(box1Clicked).isTrue()
+ assertThat(box2Clicked).isTrue()
+ }
+
+ @Test
+ fun stackedBox_parentDisallowShare_doSharePointerWithSibling() {
+ var box1Clicked = false
+ var box2Clicked = false
+
+ rule.setContent {
+ Box(Modifier
+ .size(50.dp)
+ .testPointerInput(sharePointerInputWithSibling = false)
+ ) {
+ Box(Modifier
+ .fillMaxSize()
+ .testTag("box1")
+ .testPointerInput {
+ box1Clicked = true
+ }
+ )
+
+ Box(Modifier
+ .fillMaxSize()
+ .testTag("box2")
+ .testPointerInput(sharePointerInputWithSibling = true) {
+ box2Clicked = true
+ }
+ )
+ }
+ }
+
+ rule.onNodeWithTag("box2").performClick()
+ assertThat(box1Clicked).isTrue()
+ assertThat(box2Clicked).isTrue()
+ }
+
+ @Test
+ fun stackedBox_doSharePointerWithCousin() {
+ var box1Clicked = false
+ var box2Clicked = false
+
+ rule.setContent {
+ Box(Modifier.size(50.dp)) {
+ Box(Modifier
+ .fillMaxSize()
+ .testTag("box1")
+ .testPointerInput {
+ box1Clicked = true
+ }
+ )
+
+ Box(Modifier.fillMaxSize()) {
+ Box(Modifier
+ .fillMaxSize()
+ .testTag("box2")
+ .testPointerInput(sharePointerInputWithSibling = true) {
+ box2Clicked = true
+ }
+ )
+ }
+ }
+ }
+
+ rule.onNodeWithTag("box2").performClick()
+ assertThat(box1Clicked).isTrue()
+ assertThat(box2Clicked).isTrue()
+ }
+
+ @Test
+ fun stackedBox_parentDisallowShare_notSharePointerWithCousin() {
+ var box1Clicked = false
+ var box2Clicked = false
+
+ rule.setContent {
+ Box(Modifier.size(50.dp)) {
+ Box(Modifier.fillMaxSize()
+ .testTag("box1")
+ .testPointerInput {
+ box1Clicked = true
+ }
+ )
+
+ Box(Modifier.fillMaxSize()
+ .testPointerInput(sharePointerInputWithSibling = false)
+ ) {
+ Box(Modifier.fillMaxSize()
+ .testTag("box2")
+ .testPointerInput(sharePointerInputWithSibling = true) {
+ box2Clicked = true
+ }
+ )
+ }
+ }
+ }
+
+ rule.onNodeWithTag("box2").performClick()
+ assertThat(box1Clicked).isFalse()
+ assertThat(box2Clicked).isTrue()
+ }
+
+ @Test
+ fun stackedBox_doSharePointer_untilFirstBoxDisallowShare() {
+ var box1Clicked = false
+ var box2Clicked = false
+ var box3Clicked = false
+
+ rule.setContent {
+ Box(Modifier.size(50.dp)) {
+ Box(Modifier.fillMaxSize()
+ .testTag("box1")
+ .testPointerInput {
+ box1Clicked = true
+ }
+ )
+
+ Box(Modifier.fillMaxSize()
+ .testTag("box2")
+ .testPointerInput(sharePointerInputWithSibling = false) {
+ box2Clicked = true
+ }
+ )
+
+ Box(Modifier.fillMaxSize()
+ .testTag("box3")
+ .testPointerInput(sharePointerInputWithSibling = true) {
+ box3Clicked = true
+ }
+ )
+ }
+ }
+
+ rule.onNodeWithTag("box3").performClick()
+ assertThat(box1Clicked).isFalse()
+ assertThat(box2Clicked).isTrue()
+ assertThat(box3Clicked).isTrue()
+ }
+}
+
+private fun Modifier.testPointerInput(
+ sharePointerInputWithSibling: Boolean = false,
+ onPointerEvent: () -> Unit = {}
+): Modifier = this.then(TestPointerInputElement(sharePointerInputWithSibling, onPointerEvent))
+
+private data class TestPointerInputElement(
+ val sharePointerInputWithSibling: Boolean,
+ val onPointerEvent: () -> Unit
+) : ModifierNodeElement<TestPointerInputNode>() {
+ override fun create(): TestPointerInputNode {
+ return TestPointerInputNode(sharePointerInputWithSibling, onPointerEvent)
+ }
+
+ override fun update(node: TestPointerInputNode) {
+ node.sharePointerInputWithSibling = sharePointerInputWithSibling
+ node.onPointerEvent = onPointerEvent
+ }
+}
+
+private class TestPointerInputNode(
+ var sharePointerInputWithSibling: Boolean,
+ var onPointerEvent: () -> Unit
+) : Modifier.Node(), PointerInputModifierNode {
+ override fun onPointerEvent(
+ pointerEvent: PointerEvent,
+ pass: PointerEventPass,
+ bounds: IntSize
+ ) {
+ onPointerEvent.invoke()
+ }
+
+ override fun onCancelPointerInput() { }
+
+ override fun sharePointerInputWithSiblings(): Boolean {
+ return sharePointerInputWithSibling
+ }
+}
diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/HitTestSharePointerInputWithSiblingTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/HitTestSharePointerInputWithSiblingTest.kt
new file mode 100644
index 0000000..36b2ec8
--- /dev/null
+++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/HitTestSharePointerInputWithSiblingTest.kt
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.node
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.PointerEvent
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.unit.IntSize
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class HitTestSharePointerInputWithSiblingTest {
+ @Test
+ fun hitTest_sharePointerWithSibling() {
+ val pointerInputModifier1 = FakePointerInputModifierNode(sharePointerWithSiblings = true)
+ val pointerInputModifier2 = FakePointerInputModifierNode(sharePointerWithSiblings = true)
+
+ val outerNode = LayoutNode(0, 0, 1, 1) {
+ childNode(0, 0, 1, 1, pointerInputModifier1.toModifier())
+ childNode(0, 0, 1, 1, pointerInputModifier2.toModifier())
+ }
+
+ val hit = mutableListOf<Modifier.Node>()
+
+ outerNode.hitTest(Offset(0f, 0f), hit)
+
+ assertThat(hit).isEqualTo(listOf(pointerInputModifier2, pointerInputModifier1))
+ }
+
+ @Test
+ fun hitTest_sharePointerWithSibling_utilFirstNodeNotSharing() {
+ val pointerInputModifier1 = FakePointerInputModifierNode(sharePointerWithSiblings = true)
+ val pointerInputModifier2 = FakePointerInputModifierNode(sharePointerWithSiblings = false)
+ val pointerInputModifier3 = FakePointerInputModifierNode(sharePointerWithSiblings = true)
+
+ val outerNode = LayoutNode(0, 0, 1, 1) {
+ childNode(0, 0, 1, 1, pointerInputModifier1.toModifier())
+ childNode(0, 0, 1, 1, pointerInputModifier2.toModifier())
+ childNode(0, 0, 1, 1, pointerInputModifier3.toModifier())
+ }
+
+ val hit = mutableListOf<Modifier.Node>()
+
+ outerNode.hitTest(Offset(0f, 0f), hit)
+
+ assertThat(hit).isEqualTo(listOf(pointerInputModifier3, pointerInputModifier2))
+ }
+
+ @Test
+ fun hitTest_sharePointerWithSibling_whenParentDisallowShare() {
+ val pointerInputModifier1 = FakePointerInputModifierNode(sharePointerWithSiblings = false)
+ val pointerInputModifier2 = FakePointerInputModifierNode(sharePointerWithSiblings = true)
+ val pointerInputModifier3 = FakePointerInputModifierNode(sharePointerWithSiblings = true)
+
+ val outerNode = LayoutNode(0, 0, 1, 1) {
+ childNode(0, 0, 1, 1, pointerInputModifier1.toModifier()) {
+ childNode(0, 0, 1, 1, pointerInputModifier2.toModifier())
+ childNode(0, 0, 1, 1, pointerInputModifier3.toModifier())
+ }
+ }
+
+ val hit = mutableListOf<Modifier.Node>()
+
+ outerNode.hitTest(Offset(0f, 0f), hit)
+
+ // The parent node doesn't share pointer events, the two children can still share events.
+ assertThat(hit).isEqualTo(
+ listOf(pointerInputModifier1, pointerInputModifier3, pointerInputModifier2)
+ )
+ }
+
+ @Test
+ fun hitTest_sharePointerWithSiblingTrue_shareWithCousin() {
+ val pointerInputModifier1 = FakePointerInputModifierNode(sharePointerWithSiblings = true)
+ val pointerInputModifier2 = FakePointerInputModifierNode(sharePointerWithSiblings = true)
+
+ val outerNode = LayoutNode(0, 0, 1, 1) {
+ childNode(0, 0, 1, 1, pointerInputModifier1.toModifier())
+ childNode(0, 0, 1, 1) {
+ childNode(0, 0, 1, 1, pointerInputModifier2.toModifier())
+ }
+ }
+
+ val hit = mutableListOf<Modifier.Node>()
+
+ outerNode.hitTest(Offset(0f, 0f), hit)
+
+ assertThat(hit).isEqualTo(listOf(pointerInputModifier2, pointerInputModifier1))
+ }
+
+ @Test
+ fun hitTest_parentDisallowShare_notShareWithCousin() {
+ val pointerInputModifier1 = FakePointerInputModifierNode(sharePointerWithSiblings = true)
+ val pointerInputModifier2 = FakePointerInputModifierNode(sharePointerWithSiblings = false)
+ val pointerInputModifier3 = FakePointerInputModifierNode(sharePointerWithSiblings = true)
+
+ val outerNode = LayoutNode(0, 0, 1, 1) {
+ childNode(0, 0, 1, 1, pointerInputModifier1.toModifier())
+ childNode(0, 0, 1, 1, pointerInputModifier2.toModifier()) {
+ childNode(0, 0, 1, 1, pointerInputModifier3.toModifier())
+ }
+ }
+
+ val hit = mutableListOf<Modifier.Node>()
+
+ outerNode.hitTest(Offset(0f, 0f), hit)
+
+ // PointerInputModifier1 can't receive events because pointerInputModifier2 doesn't share.
+ assertThat(hit).isEqualTo(listOf(pointerInputModifier2, pointerInputModifier3))
+ }
+
+ @Test
+ fun hitTest_sharePointerWithCousin_untilFirstNodeNotSharing() {
+ val pointerInputModifier1 = FakePointerInputModifierNode(sharePointerWithSiblings = true)
+ val pointerInputModifier2 = FakePointerInputModifierNode(sharePointerWithSiblings = false)
+ val pointerInputModifier3 = FakePointerInputModifierNode(sharePointerWithSiblings = true)
+
+ val outerNode = LayoutNode(0, 0, 1, 1) {
+ childNode(0, 0, 1, 1, pointerInputModifier1.toModifier())
+ childNode(0, 0, 1, 1) {
+ childNode(0, 0, 1, 1, pointerInputModifier2.toModifier())
+ }
+ childNode(0, 0, 1, 1) {
+ childNode(0, 0, 1, 1, pointerInputModifier3.toModifier())
+ }
+ }
+
+ val hit = mutableListOf<Modifier.Node>()
+
+ outerNode.hitTest(Offset(0f, 0f), hit)
+
+ assertThat(hit).isEqualTo(listOf(pointerInputModifier3, pointerInputModifier2))
+ }
+}
+
+private fun LayoutNode(
+ left: Int,
+ top: Int,
+ right: Int,
+ bottom: Int,
+ block: LayoutNode.() -> Unit
+): LayoutNode {
+ val root = LayoutNode(left, top, right, bottom).apply {
+ attach(MockOwner())
+ }
+
+ block.invoke(root)
+ return root
+}
+
+private fun LayoutNode.childNode(
+ left: Int,
+ top: Int,
+ right: Int,
+ bottom: Int,
+ modifier: Modifier = Modifier,
+ block: LayoutNode.() -> Unit = {}
+): LayoutNode {
+ val layoutNode = LayoutNode(left, top, right, bottom, modifier)
+ add(layoutNode)
+ layoutNode.onNodePlaced()
+ block.invoke(layoutNode)
+ return layoutNode
+}
+
+private fun FakePointerInputModifierNode.toModifier(): Modifier {
+ return object : ModifierNodeElement<FakePointerInputModifierNode>() {
+ override fun create(): FakePointerInputModifierNode = this@toModifier
+
+ override fun update(node: FakePointerInputModifierNode) { }
+
+ override fun hashCode(): Int {
+ return if ([email protected]) 1 else 0
+ }
+
+ override fun equals(other: Any?): Boolean {
+ return [email protected]
+ }
+ }
+}
+
+private class FakePointerInputModifierNode(
+ var sharePointerWithSiblings: Boolean = false
+) : Modifier.Node(), PointerInputModifierNode {
+ override fun onPointerEvent(
+ pointerEvent: PointerEvent,
+ pass: PointerEventPass,
+ bounds: IntSize
+ ) {}
+
+ override fun onCancelPointerInput() {}
+
+ override fun sharePointerInputWithSiblings(): Boolean = this.sharePointerWithSiblings
+}
+
+private fun LayoutNode.hitTest(
+ pointerPosition: Offset,
+ hitPointerInputFilters: MutableList<Modifier.Node>,
+ isTouchEvent: Boolean = false
+) {
+ val hitTestResult = HitTestResult()
+ hitTest(pointerPosition, hitTestResult, isTouchEvent)
+ hitPointerInputFilters.addAll(hitTestResult)
+}
+
+private fun LayoutNode.onNodePlaced() = measurePassDelegate.onNodePlaced()
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatableNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatableNode.kt
index 91c2d87..45c7b33 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatableNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatableNode.kt
@@ -190,11 +190,21 @@
internal inline fun DelegatableNode.visitLocalDescendants(
mask: Int,
block: (Modifier.Node) -> Unit
+) = visitLocalDescendants(
+ mask = mask,
+ includeSelf = false,
+ block = block
+)
+
+internal inline fun DelegatableNode.visitLocalDescendants(
+ mask: Int,
+ includeSelf: Boolean = false,
+ block: (Modifier.Node) -> Unit
) {
checkPrecondition(node.isAttached) { "visitLocalDescendants called on an unattached node" }
val self = node
if (self.aggregateChildKindSet and mask == 0) return
- var next = self.child
+ var next = if (includeSelf) self else self.child
while (next != null) {
if (next.kindSet and mask != 0) {
block(next)
@@ -217,6 +227,13 @@
}
}
+internal inline fun <reified T> DelegatableNode.visitSelfAndLocalDescendants(
+ type: NodeKind<T>,
+ block: (T) -> Unit
+) = visitLocalDescendants(mask = type.mask, includeSelf = true) {
+ it.dispatchForKind(type, block)
+}
+
internal inline fun <reified T> DelegatableNode.visitLocalDescendants(
type: NodeKind<T>,
block: (T) -> Unit
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/HitTestResult.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/HitTestResult.kt
index c41e989..0203f66 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/HitTestResult.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/HitTestResult.kt
@@ -40,6 +40,8 @@
override var size: Int = 0
private set
+ var shouldSharePointerInputWithSibling = true
+
/**
* `true` when there has been a direct hit within touch bounds ([hit] called) or
* `false` otherwise.
@@ -95,6 +97,9 @@
*/
fun hit(node: Modifier.Node, isInLayer: Boolean, childHitTest: () -> Unit) {
hitInMinimumTouchTarget(node, -1f, isInLayer, childHitTest)
+ if (node.coordinator?.shouldSharePointerInputWithSiblings() == false) {
+ shouldSharePointerInputWithSibling = false
+ }
}
/**
@@ -238,6 +243,7 @@
fun clear() {
hitDepth = -1
resizeToHitDepth()
+ shouldSharePointerInputWithSibling = true
}
private inner class HitTestResultIterator(
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/InnerNodeCoordinator.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/InnerNodeCoordinator.kt
index 26d852c..3b79baa 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/InnerNodeCoordinator.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/InnerNodeCoordinator.kt
@@ -240,9 +240,7 @@
val continueHitTest: Boolean
if (!wasHit) {
continueHitTest = true
- } else if (
- child.outerCoordinator.shouldSharePointerInputWithSiblings()
- ) {
+ } else if (hitTestResult.shouldSharePointerInputWithSibling) {
hitTestResult.acceptHits()
continueHitTest = true
} else {
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 8a6b586..c3aa5ec 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
@@ -1215,7 +1215,10 @@
val start = headNode(Nodes.PointerInput.includeSelfInTraversal) ?: return false
if (start.isAttached) {
- start.visitLocalDescendants(Nodes.PointerInput) {
+ // We have to check both the self and local descendants, because the `start` can also
+ // be a `PointerInputModifierNode` (when the first modifier node on the LayoutNode is
+ // a `PointerInputModifierNode`).
+ start.visitSelfAndLocalDescendants(Nodes.PointerInput) {
if (it.sharePointerInputWithSiblings()) return true
}
}
diff --git a/development/update_studio.sh b/development/update_studio.sh
index ef74a284..324322f 100755
--- a/development/update_studio.sh
+++ b/development/update_studio.sh
@@ -7,8 +7,8 @@
# Versions that the user should update when running this script
echo Getting Studio version and link
-AGP_VERSION=${1:-8.4.0-alpha12}
-STUDIO_VERSION_STRING=${2:-"Android Studio Jellyfish | 2023.3.1 Canary 12"}
+AGP_VERSION=${1:-8.5.0-alpha06}
+STUDIO_VERSION_STRING=${2:-"Android Studio Koala | 2024.1.1 Canary 6"}
# Get studio version number from version name
STUDIO_IFRAME_LINK=`curl "https://developer.android.com/studio/archive.html" | grep "<iframe " | sed "s/.* src=\"\([^\"]*\)\".*/\1/g"`
diff --git a/gradle.properties b/gradle.properties
index 7a42aa7..68fc0ca 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -21,6 +21,7 @@
# fullsdk-linux/**/package.xml -> b/291331139
org.gradle.configuration-cache.inputs.unsafe.ignore.file-system-checks=**/prebuilts/fullsdk-linux;**/prebuilts/fullsdk-linux/platforms/android-*/package.xml
+android.javaCompile.suppressSourceTargetDeprecationWarning=true
android.lint.baselineOmitLineNumbers=true
android.lint.printStackTrace=true
android.builder.sdkDownload=false
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 9c01869..da3f63c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,13 +2,13 @@
# -----------------------------------------------------------------------------
# All of the following should be updated in sync.
# -----------------------------------------------------------------------------
-androidGradlePlugin = "8.4.0-alpha12"
+androidGradlePlugin = "8.5.0-alpha06"
# NOTE: When updating the lint version we also need to update the `api` version
# supported by `IssueRegistry`'s.' For e.g. r.android.com/1331903
-androidLint = "31.4.0-alpha12"
+androidLint = "31.5.0-alpha06"
# Once you have a chosen version of AGP to upgrade to, go to
# https://developer.android.com/studio/archive and find the matching version of Studio.
-androidStudio = "2023.3.1.12"
+androidStudio = "2024.1.1.4"
# -----------------------------------------------------------------------------
androidGradlePluginMin = "7.0.4"
diff --git a/kruth/kruth/api/current.ignore b/kruth/kruth/api/current.ignore
index 2fc7dd5..5589f1d 100644
--- a/kruth/kruth/api/current.ignore
+++ b/kruth/kruth/api/current.ignore
@@ -73,8 +73,6 @@
Removed class androidx.kruth.PrimitiveDoubleArraySubject.DoubleArrayAsIterable
RemovedClass: androidx.kruth.PrimitiveFloatArraySubject.FloatArrayAsIterable:
Removed class androidx.kruth.PrimitiveFloatArraySubject.FloatArrayAsIterable
-RemovedClass: androidx.kruth.TableSubject:
- Removed class androidx.kruth.TableSubject
RemovedClass: androidx.kruth.Truth:
Removed class androidx.kruth.Truth
RemovedClass: androidx.kruth.TruthJUnit:
diff --git a/kruth/kruth/api/current.txt b/kruth/kruth/api/current.txt
index 857e856..26a1b93 100644
--- a/kruth/kruth/api/current.txt
+++ b/kruth/kruth/api/current.txt
@@ -201,6 +201,7 @@
method public static <T> androidx.kruth.GuavaOptionalSubject<T> assertThat(com.google.common.base.Optional<T>? actual);
method public static <K, V> androidx.kruth.MultimapSubject<K,V> assertThat(com.google.common.collect.Multimap<K,V> actual);
method public static <T> androidx.kruth.MultisetSubject<T> assertThat(com.google.common.collect.Multiset<T> actual);
+ method public static <R, C, V> androidx.kruth.TableSubject<R,C,V> assertThat(com.google.common.collect.Table<R,C,V> actual);
method public static androidx.kruth.ClassSubject assertThat(Class<?> actual);
method public static androidx.kruth.BigDecimalSubject assertThat(java.math.BigDecimal actual);
}
@@ -418,6 +419,21 @@
method public SubjectT createSubject(androidx.kruth.FailureMetadata metadata, ActualT? actual);
}
+ public final class TableSubject<R, C, V> extends androidx.kruth.Subject<com.google.common.collect.Table<R,C,V>> {
+ method public void contains(R rowKey, C columnKey);
+ method public void containsCell(com.google.common.collect.Table.Cell<R,C,V>? cell);
+ method public void containsCell(R rowKey, C colKey, V value);
+ method public void containsColumn(C columnKey);
+ method public void containsRow(R rowKey);
+ method public void containsValue(V value);
+ method public void doesNotContain(R rowKey, C columnKey);
+ method public void doesNotContainCell(com.google.common.collect.Table.Cell<R,C,V>? cell);
+ method public void doesNotContainCell(R rowKey, C colKey, V value);
+ method public void hasSize(int expectedSize);
+ method public void isEmpty();
+ method public void isNotEmpty();
+ }
+
public class ThrowableSubject<T extends java.lang.Throwable> extends androidx.kruth.Subject<T> {
ctor protected ThrowableSubject(androidx.kruth.FailureMetadata metadata, T? actual);
method public final androidx.kruth.ThrowableSubject<java.lang.Throwable> hasCauseThat();
diff --git a/kruth/kruth/api/restricted_current.ignore b/kruth/kruth/api/restricted_current.ignore
index 2fc7dd5..5589f1d 100644
--- a/kruth/kruth/api/restricted_current.ignore
+++ b/kruth/kruth/api/restricted_current.ignore
@@ -73,8 +73,6 @@
Removed class androidx.kruth.PrimitiveDoubleArraySubject.DoubleArrayAsIterable
RemovedClass: androidx.kruth.PrimitiveFloatArraySubject.FloatArrayAsIterable:
Removed class androidx.kruth.PrimitiveFloatArraySubject.FloatArrayAsIterable
-RemovedClass: androidx.kruth.TableSubject:
- Removed class androidx.kruth.TableSubject
RemovedClass: androidx.kruth.Truth:
Removed class androidx.kruth.Truth
RemovedClass: androidx.kruth.TruthJUnit:
diff --git a/kruth/kruth/api/restricted_current.txt b/kruth/kruth/api/restricted_current.txt
index 90ec259..4d8c416 100644
--- a/kruth/kruth/api/restricted_current.txt
+++ b/kruth/kruth/api/restricted_current.txt
@@ -201,6 +201,7 @@
method public static <T> androidx.kruth.GuavaOptionalSubject<T> assertThat(com.google.common.base.Optional<T>? actual);
method public static <K, V> androidx.kruth.MultimapSubject<K,V> assertThat(com.google.common.collect.Multimap<K,V> actual);
method public static <T> androidx.kruth.MultisetSubject<T> assertThat(com.google.common.collect.Multiset<T> actual);
+ method public static <R, C, V> androidx.kruth.TableSubject<R,C,V> assertThat(com.google.common.collect.Table<R,C,V> actual);
method public static androidx.kruth.ClassSubject assertThat(Class<?> actual);
method public static androidx.kruth.BigDecimalSubject assertThat(java.math.BigDecimal actual);
}
@@ -419,6 +420,21 @@
method public SubjectT createSubject(androidx.kruth.FailureMetadata metadata, ActualT? actual);
}
+ public final class TableSubject<R, C, V> extends androidx.kruth.Subject<com.google.common.collect.Table<R,C,V>> {
+ method public void contains(R rowKey, C columnKey);
+ method public void containsCell(com.google.common.collect.Table.Cell<R,C,V>? cell);
+ method public void containsCell(R rowKey, C colKey, V value);
+ method public void containsColumn(C columnKey);
+ method public void containsRow(R rowKey);
+ method public void containsValue(V value);
+ method public void doesNotContain(R rowKey, C columnKey);
+ method public void doesNotContainCell(com.google.common.collect.Table.Cell<R,C,V>? cell);
+ method public void doesNotContainCell(R rowKey, C colKey, V value);
+ method public void hasSize(int expectedSize);
+ method public void isEmpty();
+ method public void isNotEmpty();
+ }
+
public class ThrowableSubject<T extends java.lang.Throwable> extends androidx.kruth.Subject<T> {
ctor protected ThrowableSubject(androidx.kruth.FailureMetadata metadata, T? actual);
method public final androidx.kruth.ThrowableSubject<java.lang.Throwable> hasCauseThat();
diff --git a/kruth/kruth/src/jvmMain/kotlin/androidx/kruth/Kruth.jvm.kt b/kruth/kruth/src/jvmMain/kotlin/androidx/kruth/Kruth.jvm.kt
index ea1fbc7..9b8c123 100644
--- a/kruth/kruth/src/jvmMain/kotlin/androidx/kruth/Kruth.jvm.kt
+++ b/kruth/kruth/src/jvmMain/kotlin/androidx/kruth/Kruth.jvm.kt
@@ -19,6 +19,7 @@
import com.google.common.base.Optional
import com.google.common.collect.Multimap
import com.google.common.collect.Multiset
+import com.google.common.collect.Table
import java.math.BigDecimal
fun assertThat(actual: Class<*>): ClassSubject =
@@ -35,3 +36,6 @@
fun <K, V> assertThat(actual: Multimap<K, V>): MultimapSubject<K, V> =
MultimapSubject(actual = actual)
+
+fun <R, C, V> assertThat(actual: Table<R, C, V>): TableSubject<R, C, V> =
+ TableSubject(actual = actual)
diff --git a/kruth/kruth/src/jvmMain/kotlin/androidx/kruth/PlatformStandardSubjectBuilder.jvm.kt b/kruth/kruth/src/jvmMain/kotlin/androidx/kruth/PlatformStandardSubjectBuilder.jvm.kt
index 891e0a2..e4030e6 100644
--- a/kruth/kruth/src/jvmMain/kotlin/androidx/kruth/PlatformStandardSubjectBuilder.jvm.kt
+++ b/kruth/kruth/src/jvmMain/kotlin/androidx/kruth/PlatformStandardSubjectBuilder.jvm.kt
@@ -19,6 +19,7 @@
import com.google.common.base.Optional
import com.google.common.collect.Multimap
import com.google.common.collect.Multiset
+import com.google.common.collect.Table
import java.math.BigDecimal
internal actual interface PlatformStandardSubjectBuilder {
@@ -28,6 +29,7 @@
fun that(actual: BigDecimal): BigDecimalSubject
fun <T> that(actual: Multiset<T>): MultisetSubject<T>
fun <K, V> that(actual: Multimap<K, V>): MultimapSubject<K, V>
+ fun <R, C, V> that(actual: Table<R, C, V>): TableSubject<R, C, V>
}
internal actual class PlatformStandardSubjectBuilderImpl actual constructor(
@@ -48,4 +50,7 @@
override fun <K, V> that(actual: Multimap<K, V>): MultimapSubject<K, V> =
MultimapSubject(actual = actual, metadata = metadata)
+
+ override fun <R, C, V> that(actual: Table<R, C, V>): TableSubject<R, C, V> =
+ TableSubject(actual = actual, metadata = metadata)
}
diff --git a/kruth/kruth/src/jvmMain/kotlin/androidx/kruth/TableSubject.jvm.kt b/kruth/kruth/src/jvmMain/kotlin/androidx/kruth/TableSubject.jvm.kt
new file mode 100644
index 0000000..80f873e
--- /dev/null
+++ b/kruth/kruth/src/jvmMain/kotlin/androidx/kruth/TableSubject.jvm.kt
@@ -0,0 +1,134 @@
+/*
+ * 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.kruth
+
+import androidx.kruth.Fact.Companion.fact
+import androidx.kruth.Fact.Companion.simpleFact
+import com.google.common.collect.Table
+import com.google.common.collect.Table.Cell
+import com.google.common.collect.Tables.immutableCell
+
+class TableSubject<R, C, V> internal constructor(
+ actual: Table<R, C, V>,
+ metadata: FailureMetadata = FailureMetadata(),
+) : Subject<Table<R, C, V>>(actual, metadata, typeDescriptionOverride = null) {
+
+ /** Fails if the table is not empty. */
+ fun isEmpty() {
+ requireNonNull(actual)
+
+ if (!actual.isEmpty) {
+ failWithActual(simpleFact("expected to be empty"))
+ }
+ }
+
+ /** Fails if the table is empty. */
+ fun isNotEmpty() {
+ requireNonNull(actual)
+
+ if (actual.isEmpty) {
+ failWithoutActual(simpleFact("expected not to be empty"))
+ }
+ }
+
+ /** Fails if the table does not have the given size. */
+ fun hasSize(expectedSize: Int) {
+ require(expectedSize >= 0) { "expectedSize($expectedSize) must be >= 0" }
+ requireNonNull(actual)
+
+ check("size()").that(actual.size()).isEqualTo(expectedSize)
+ }
+
+ /** Fails if the table does not contain a mapping for the given row key and column key. */
+ fun contains(rowKey: R, columnKey: C) {
+ requireNonNull(actual)
+
+ if (!actual.contains(rowKey, columnKey)) {
+ failWithActual(
+ simpleFact("expected to contain mapping for row-column key pair"),
+ fact("row key", rowKey),
+ fact("column key", columnKey),
+ )
+ }
+ }
+
+ /** Fails if the table contains a mapping for the given row key and column key. */
+ fun doesNotContain(rowKey: R, columnKey: C) {
+ requireNonNull(actual)
+
+ if (actual.contains(rowKey, columnKey)) {
+ failWithoutActual(
+ simpleFact("expected not to contain mapping for row-column key pair"),
+ fact("row key", rowKey),
+ fact("column key", columnKey),
+ fact("but contained value", actual[rowKey, columnKey]),
+ fact("full contents", actual),
+ )
+ }
+ }
+
+ /** Fails if the table does not contain the given cell. */
+ fun containsCell(rowKey: R, colKey: C, value: V) {
+ containsCell(immutableCell(rowKey, colKey, value))
+ }
+
+ /** Fails if the table does not contain the given cell. */
+ fun containsCell(cell: Cell<R, C, V>?) {
+ requireNonNull(cell)
+ requireNonNull(actual)
+
+ checkNoNeedToDisplayBothValues("cellSet()")
+ .that(actual.cellSet())
+ .contains(cell)
+ }
+
+ /** Fails if the table contains the given cell. */
+ fun doesNotContainCell(rowKey: R, colKey: C, value: V) {
+ doesNotContainCell(immutableCell(rowKey, colKey, value))
+ }
+
+ /** Fails if the table contains the given cell. */
+ fun doesNotContainCell(cell: Cell<R, C, V>?) {
+ requireNonNull(cell)
+ requireNonNull(actual)
+
+ checkNoNeedToDisplayBothValues("cellSet()")
+ .that(actual.cellSet())
+ .doesNotContain(cell)
+ }
+
+ /** Fails if the table does not contain the given row key. */
+ fun containsRow(rowKey: R) {
+ requireNonNull(actual)
+
+ check("rowKeySet()").that(actual.rowKeySet()).contains(rowKey)
+ }
+
+ /** Fails if the table does not contain the given column key. */
+ fun containsColumn(columnKey: C) {
+ requireNonNull(actual)
+
+ check("columnKeySet()").that(actual.columnKeySet()).contains(columnKey)
+ }
+
+ /** Fails if the table does not contain the given value. */
+ fun containsValue(value: V) {
+ requireNonNull(actual)
+
+ check("values()").that(actual.values()).contains(value)
+ }
+}
diff --git a/kruth/kruth/src/jvmTest/kotlin/androidx/kruth/TableSubjectTest.jvm.kt b/kruth/kruth/src/jvmTest/kotlin/androidx/kruth/TableSubjectTest.jvm.kt
new file mode 100644
index 0000000..a2a1157
--- /dev/null
+++ b/kruth/kruth/src/jvmTest/kotlin/androidx/kruth/TableSubjectTest.jvm.kt
@@ -0,0 +1,138 @@
+/*
+ * 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.kruth
+
+import com.google.common.collect.ImmutableTable
+import com.google.common.collect.Tables.immutableCell
+import kotlin.test.Test
+import kotlin.test.assertFailsWith
+
+class TableSubjectTest {
+
+ @Test
+ fun tableIsEmpty() {
+ val table = ImmutableTable.of<String, String, String>()
+ assertThat(table).isEmpty()
+ }
+
+ @Test
+ fun tableIsEmptyWithFailure() {
+ val table = ImmutableTable.of(1, 5, 7)
+ assertFailsWith<AssertionError> {
+ assertThat(table).isEmpty()
+ }
+ }
+
+ @Test
+ fun tableIsNotEmpty() {
+ val table = ImmutableTable.of(1, 5, 7)
+ assertThat(table).isNotEmpty()
+ }
+
+ @Test
+ fun tableIsNotEmptyWithFailure() {
+ val table = ImmutableTable.of<Int, Int, Int>()
+ assertFailsWith<AssertionError> {
+ assertThat(table).isNotEmpty()
+ }
+ }
+
+ @Test
+ fun hasSize() {
+ assertThat(ImmutableTable.of(1, 2, 3)).hasSize(1)
+ }
+
+ @Test
+ fun hasSizeZero() {
+ assertThat(ImmutableTable.of<Any, Any, Any>()).hasSize(0)
+ }
+
+ @Test
+ fun hasSizeNegative() {
+ assertFailsWith<IllegalArgumentException> {
+ assertThat(ImmutableTable.of(1, 2, 3)).hasSize(-1)
+ }
+ }
+
+ @Test
+ fun contains() {
+ val table = ImmutableTable.of("row", "col", "val")
+ assertThat(table).contains("row", "col")
+ }
+
+ @Test
+ fun containsFailure() {
+ val table = ImmutableTable.of("row", "col", "val")
+
+ assertFailsWith<AssertionError> {
+ assertThat(table).contains("row", "otherCol")
+ }
+ }
+
+ @Test
+ fun doesNotContain() {
+ val table = ImmutableTable.of("row", "col", "val")
+ assertThat(table).doesNotContain("row", "row")
+ assertThat(table).doesNotContain("col", "row")
+ assertThat(table).doesNotContain("col", "col")
+ assertThat(table).doesNotContain(null, null)
+ }
+
+ @Test
+ fun doesNotContainFailure() {
+ val table = ImmutableTable.of("row", "col", "val")
+ assertFailsWith<AssertionError> {
+ assertThat(table).doesNotContain("row", "col")
+ }
+ }
+
+ @Test
+ fun containsCell() {
+ val table = ImmutableTable.of("row", "col", "val")
+ assertThat(table).containsCell("row", "col", "val")
+ assertThat(table).containsCell(immutableCell("row", "col", "val"))
+ }
+
+ @Test
+ fun containsCellFailure() {
+ val table = ImmutableTable.of("row", "col", "val")
+ assertFailsWith<AssertionError> {
+ assertThat(table).containsCell("row", "row", "val")
+ }
+ }
+
+ @Test
+ fun doesNotContainCell() {
+ val table = ImmutableTable.of("row", "col", "val")
+ assertThat(table).doesNotContainCell("row", "row", "val")
+ assertThat(table).doesNotContainCell("col", "row", "val")
+ assertThat(table).doesNotContainCell("col", "col", "val")
+ assertThat(table).doesNotContainCell(null, null, null)
+ assertThat(table).doesNotContainCell(immutableCell("row", "row", "val"))
+ assertThat(table).doesNotContainCell(immutableCell("col", "row", "val"))
+ assertThat(table).doesNotContainCell(immutableCell("col", "col", "val"))
+ assertThat(table).doesNotContainCell(immutableCell(null, null, null))
+ }
+
+ @Test
+ fun doesNotContainCellFailure() {
+ val table = ImmutableTable.of("row", "col", "val")
+ assertFailsWith<AssertionError> {
+ assertThat(table).doesNotContainCell("row", "col", "val")
+ }
+ }
+}
diff --git a/lint-checks/src/main/java/androidx/build/lint/ClassVerificationFailureDetector.kt b/lint-checks/src/main/java/androidx/build/lint/ClassVerificationFailureDetector.kt
index 6057cc1..9a36c5f 100644
--- a/lint-checks/src/main/java/androidx/build/lint/ClassVerificationFailureDetector.kt
+++ b/lint-checks/src/main/java/androidx/build/lint/ClassVerificationFailureDetector.kt
@@ -487,7 +487,7 @@
// Builtin R8 desugaring, such as rewriting compare calls (see b/36390874)
if (owner.startsWith("java.") &&
- DesugaredMethodLookup.isDesugared(owner, name, desc, context.sourceSetType)) {
+ DesugaredMethodLookup.isDesugaredMethod(owner, name, desc, context.sourceSetType)) {
return
}
@@ -573,7 +573,7 @@
api: Int
): LintFix? {
val callPsi = call.sourcePsi ?: return null
- if (isKotlin(callPsi)) {
+ if (isKotlin(callPsi.language)) {
// We only support Java right now.
return null
}
diff --git a/lint-checks/src/main/java/androidx/build/lint/MissingJvmDefaultWithCompatibilityDetector.kt b/lint-checks/src/main/java/androidx/build/lint/MissingJvmDefaultWithCompatibilityDetector.kt
index a9f7fbd..a773acc 100644
--- a/lint-checks/src/main/java/androidx/build/lint/MissingJvmDefaultWithCompatibilityDetector.kt
+++ b/lint-checks/src/main/java/androidx/build/lint/MissingJvmDefaultWithCompatibilityDetector.kt
@@ -59,7 +59,7 @@
return
}
- if (!isKotlin(node)) return
+ if (!isKotlin(node.language)) return
if (!node.isInterface) return
if (node.annotatedWithAnyOf(
// If the interface is not stable, it doesn't need the annotation
diff --git a/lint-checks/src/main/java/androidx/build/lint/NullabilityAnnotationsDetector.kt b/lint-checks/src/main/java/androidx/build/lint/NullabilityAnnotationsDetector.kt
index 6ed71ee..bf0227e 100644
--- a/lint-checks/src/main/java/androidx/build/lint/NullabilityAnnotationsDetector.kt
+++ b/lint-checks/src/main/java/androidx/build/lint/NullabilityAnnotationsDetector.kt
@@ -40,7 +40,8 @@
private inner class AnnotationChecker(val context: JavaContext) : UElementHandler() {
override fun visitAnnotation(node: UAnnotation) {
- if (isJava(node.sourcePsi)) {
+ val element = node.sourcePsi
+ if (element != null && isJava(element.language)) {
checkForAnnotation(node, "NotNull", "NonNull")
checkForAnnotation(node, "Nullable", "Nullable")
}
diff --git a/lint-checks/src/main/java/androidx/build/lint/RestrictToDetector.kt b/lint-checks/src/main/java/androidx/build/lint/RestrictToDetector.kt
index 36cbf9cd..9eaf45c 100644
--- a/lint-checks/src/main/java/androidx/build/lint/RestrictToDetector.kt
+++ b/lint-checks/src/main/java/androidx/build/lint/RestrictToDetector.kt
@@ -86,7 +86,9 @@
// here, but that points to impl classes in its hierarchy which leads to
// class loading trouble.
val sourcePsi = element.sourcePsi
- if (isKotlin(sourcePsi) && sourcePsi?.parent?.toString() == "CONSTRUCTOR_CALLEE") {
+ if (sourcePsi != null &&
+ isKotlin(sourcePsi.language) &&
+ sourcePsi.parent?.toString() == "CONSTRUCTOR_CALLEE") {
return
}
}