Adds urlspan to support link semantics in Android (#162419)

<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->

fixes https://github.com/flutter/flutter/issues/102535

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
chunhtai 2025-02-04 10:59:47 -08:00 committed by GitHub
parent 950b5ac7b0
commit 0e1df622a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 139 additions and 5 deletions

View File

@ -24,6 +24,7 @@ import android.text.SpannableString;
import android.text.TextUtils;
import android.text.style.LocaleSpan;
import android.text.style.TtsSpan;
import android.text.style.URLSpan;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowInsets;
@ -748,7 +749,7 @@ public class AccessibilityBridge extends AccessibilityNodeProvider {
result.addAction(AccessibilityNodeInfo.ACTION_SET_TEXT);
}
if (semanticsNode.hasFlag(Flag.IS_BUTTON) || semanticsNode.hasFlag(Flag.IS_LINK)) {
if (semanticsNode.hasFlag(Flag.IS_BUTTON)) {
result.setClassName("android.widget.Button");
}
if (semanticsNode.hasFlag(Flag.IS_IMAGE)) {
@ -2262,6 +2263,7 @@ public class AccessibilityBridge extends AccessibilityNodeProvider {
private enum StringAttributeType {
SPELLOUT,
LOCALE,
URL
}
private static class StringAttribute {
@ -2276,6 +2278,10 @@ public class AccessibilityBridge extends AccessibilityNodeProvider {
String locale;
}
private static class UrlStringAttribute extends StringAttribute {
String url;
}
/**
* Flutter {@code SemanticsNode} represented in Java/Android.
*
@ -2329,6 +2335,9 @@ public class AccessibilityBridge extends AccessibilityNodeProvider {
// API level >= 28; otherwise, this is attached to the end of content description.
@Nullable private String tooltip;
// The Url the widget's points to.
@Nullable private String linkUrl;
// The id of the sibling node that is before this node in traversal
// order.
//
@ -2540,6 +2549,9 @@ public class AccessibilityBridge extends AccessibilityNodeProvider {
stringIndex = buffer.getInt();
tooltip = stringIndex == -1 ? null : strings[stringIndex];
stringIndex = buffer.getInt();
linkUrl = stringIndex == -1 ? null : strings[stringIndex];
textDirection = TextDirection.fromInt(buffer.getInt());
left = buffer.getFloat();
@ -2832,7 +2844,21 @@ public class AccessibilityBridge extends AccessibilityNodeProvider {
}
private CharSequence getLabel() {
return createSpannableString(label, labelAttributes);
List<StringAttribute> attributes = labelAttributes;
if (linkUrl != null && linkUrl.length() > 0) {
if (attributes == null) {
attributes = new ArrayList<StringAttribute>();
} else {
attributes = new ArrayList<StringAttribute>(attributes);
}
UrlStringAttribute uriStringAttribute = new UrlStringAttribute();
uriStringAttribute.start = 0;
uriStringAttribute.end = label.length();
uriStringAttribute.url = linkUrl;
uriStringAttribute.type = StringAttributeType.URL;
attributes.add(uriStringAttribute);
}
return createSpannableString(label, attributes);
}
private CharSequence getHint() {
@ -2891,6 +2917,13 @@ public class AccessibilityBridge extends AccessibilityNodeProvider {
spannableString.setSpan(localeSpan, attribute.start, attribute.end, 0);
break;
}
case URL:
{
UrlStringAttribute uriAttribute = (UrlStringAttribute) attribute;
final URLSpan urlSpan = new URLSpan(uriAttribute.url);
spannableString.setSpan(urlSpan, attribute.start, attribute.end, 0);
break;
}
}
}
}

View File

@ -44,7 +44,7 @@ PlatformViewAndroidDelegate::PlatformViewAndroidDelegate(
void PlatformViewAndroidDelegate::UpdateSemantics(
const flutter::SemanticsNodeUpdates& update,
const flutter::CustomAccessibilityActionUpdates& actions) {
constexpr size_t kBytesPerNode = 48 * sizeof(int32_t);
constexpr size_t kBytesPerNode = 49 * sizeof(int32_t);
constexpr size_t kBytesPerChild = sizeof(int32_t);
constexpr size_t kBytesPerCustomAction = sizeof(int32_t);
constexpr size_t kBytesPerAction = 4 * sizeof(int32_t);
@ -165,6 +165,13 @@ void PlatformViewAndroidDelegate::UpdateSemantics(
strings.push_back(node.tooltip);
}
if (node.linkUrl.empty()) {
buffer_int32[position++] = -1;
} else {
buffer_int32[position++] = strings.size();
strings.push_back(node.linkUrl);
}
buffer_int32[position++] = node.textDirection;
buffer_float32[position++] = node.rect.left();
buffer_float32[position++] = node.rect.top();

View File

@ -23,7 +23,7 @@ TEST(PlatformViewShell, UpdateSemanticsDoesFlutterViewUpdateSemantics) {
node0.tooltip = "tooltip";
update.insert(std::make_pair(0, node0));
std::vector<uint8_t> expected_buffer(192);
std::vector<uint8_t> expected_buffer(196);
std::vector<std::vector<uint8_t>> expected_string_attribute_args(0);
size_t position = 0;
int32_t* buffer_int32 = reinterpret_cast<int32_t*>(&expected_buffer[0]);
@ -57,6 +57,71 @@ TEST(PlatformViewShell, UpdateSemanticsDoesFlutterViewUpdateSemantics) {
buffer_int32[position++] = -1; // node0.hintAttributes
buffer_int32[position++] = expected_strings.size(); // node0.tooltip
expected_strings.push_back(node0.tooltip);
buffer_int32[position++] = -1; // node0.linkUrl
buffer_int32[position++] = node0.textDirection;
buffer_float32[position++] = node0.rect.left();
buffer_float32[position++] = node0.rect.top();
buffer_float32[position++] = node0.rect.right();
buffer_float32[position++] = node0.rect.bottom();
node0.transform.getColMajor(&buffer_float32[position]);
position += 16;
buffer_int32[position++] = 0; // node0.childrenInTraversalOrder.size();
buffer_int32[position++] = 0; // node0.customAccessibilityActions.size();
EXPECT_CALL(*jni_mock,
FlutterViewUpdateSemantics(expected_buffer, expected_strings,
expected_string_attribute_args));
// Creates empty custom actions.
flutter::CustomAccessibilityActionUpdates actions;
delegate->UpdateSemantics(update, actions);
}
TEST(PlatformViewShell, UpdateSemanticsDoesUpdatelinkUrl) {
auto jni_mock = std::make_shared<JNIMock>();
auto delegate = std::make_unique<PlatformViewAndroidDelegate>(jni_mock);
flutter::SemanticsNodeUpdates update;
flutter::SemanticsNode node0;
node0.id = 0;
node0.identifier = "identifier";
node0.label = "label";
node0.linkUrl = "url";
update.insert(std::make_pair(0, node0));
std::vector<uint8_t> expected_buffer(196);
std::vector<std::vector<uint8_t>> expected_string_attribute_args(0);
size_t position = 0;
int32_t* buffer_int32 = reinterpret_cast<int32_t*>(&expected_buffer[0]);
float* buffer_float32 = reinterpret_cast<float*>(&expected_buffer[0]);
std::vector<std::string> expected_strings;
buffer_int32[position++] = node0.id;
buffer_int32[position++] = node0.flags;
buffer_int32[position++] = node0.actions;
buffer_int32[position++] = node0.maxValueLength;
buffer_int32[position++] = node0.currentValueLength;
buffer_int32[position++] = node0.textSelectionBase;
buffer_int32[position++] = node0.textSelectionExtent;
buffer_int32[position++] = node0.platformViewId;
buffer_int32[position++] = node0.scrollChildren;
buffer_int32[position++] = node0.scrollIndex;
buffer_float32[position++] = static_cast<float>(node0.scrollPosition);
buffer_float32[position++] = static_cast<float>(node0.scrollExtentMax);
buffer_float32[position++] = static_cast<float>(node0.scrollExtentMin);
buffer_int32[position++] = expected_strings.size(); // node0.identifier
expected_strings.push_back(node0.identifier);
buffer_int32[position++] = expected_strings.size(); // node0.label
expected_strings.push_back(node0.label);
buffer_int32[position++] = -1; // node0.labelAttributes
buffer_int32[position++] = -1; // node0.value
buffer_int32[position++] = -1; // node0.valueAttributes
buffer_int32[position++] = -1; // node0.increasedValue
buffer_int32[position++] = -1; // node0.increasedValueAttributes
buffer_int32[position++] = -1; // node0.decreasedValue
buffer_int32[position++] = -1; // node0.decreasedValueAttributes
buffer_int32[position++] = -1; // node0.hint
buffer_int32[position++] = -1; // node0.hintAttributes
buffer_int32[position++] = -1; // node0.tooltip
buffer_int32[position++] = expected_strings.size(); // node0.tooltip
expected_strings.push_back(node0.linkUrl);
buffer_int32[position++] = node0.textDirection;
buffer_float32[position++] = node0.rect.left();
buffer_float32[position++] = node0.rect.top();
@ -100,7 +165,7 @@ TEST(PlatformViewShell,
node0.hintAttributes.push_back(locale_attribute);
update.insert(std::make_pair(0, node0));
std::vector<uint8_t> expected_buffer(224);
std::vector<uint8_t> expected_buffer(228);
std::vector<std::vector<uint8_t>> expected_string_attribute_args;
size_t position = 0;
int32_t* buffer_int32 = reinterpret_cast<int32_t*>(&expected_buffer[0]);
@ -145,6 +210,7 @@ TEST(PlatformViewShell,
expected_string_attribute_args.push_back(
{locale_attribute->locale.begin(), locale_attribute->locale.end()});
buffer_int32[position++] = -1; // node0.tooltip
buffer_int32[position++] = -1; // node0.linkUrl
buffer_int32[position++] = node0.textDirection;
buffer_float32[position++] = node0.rect.left();
buffer_float32[position++] = node0.rect.top();

View File

@ -37,6 +37,7 @@ import android.text.SpannableString;
import android.text.SpannedString;
import android.text.style.LocaleSpan;
import android.text.style.TtsSpan;
import android.text.style.URLSpan;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewParent;
@ -193,6 +194,26 @@ public class AccessibilityBridgeTest {
assertEquals(nodeInfo.getText(), null);
}
@Test
public void itCreatesURLSpanForlinkURL() {
AccessibilityBridge accessibilityBridge = setUpBridge();
TestSemanticsNode testSemanticsNode = new TestSemanticsNode();
testSemanticsNode.label = "Hello";
testSemanticsNode.linkUrl = "https://flutter.dev";
testSemanticsNode.addFlag(AccessibilityBridge.Flag.IS_LINK);
TestSemanticsUpdate testSemanticsUpdate = testSemanticsNode.toUpdate();
testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge);
AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0);
SpannableString actual = (SpannableString) nodeInfo.getContentDescription();
assertEquals(actual.toString(), "Hello");
Object[] objectSpans = actual.getSpans(0, actual.length(), Object.class);
assertEquals(objectSpans.length, 1);
URLSpan span = (URLSpan) objectSpans[0];
assertEquals(span.getURL(), "https://flutter.dev");
}
@Test
public void itUnfocusesPlatformViewWhenPlatformViewGoesAway() {
AccessibilityViewEmbedder mockViewEmbedder = mock(AccessibilityViewEmbedder.class);
@ -2311,6 +2332,7 @@ public class AccessibilityBridgeTest {
String hint = null;
List<TestStringAttribute> hintAttributes;
String tooltip = null;
String linkUrl = null;
int textDirection = 0;
float left = 0.0f;
float top = 0.0f;
@ -2374,6 +2396,12 @@ public class AccessibilityBridgeTest {
strings.add(tooltip);
bytes.putInt(strings.size() - 1);
}
if (linkUrl == null) {
bytes.putInt(-1);
} else {
strings.add(linkUrl);
bytes.putInt(strings.size() - 1);
}
bytes.putInt(textDirection);
bytes.putFloat(left);
bytes.putFloat(top);