diff --git a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java
index 802cd38bb1..c5c1da5249 100644
--- a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java
+++ b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java
@@ -43,6 +43,9 @@ static void appendNode(
// (#2063 empty-fill verification).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
appendAttribute(xml, "hint-showing", Boolean.toString(node.isShowingHintText()));
+ // The hint itself, whether or not the field is showing it: `text` holds the hint only while
+ // the field is empty, so a filled field's placeholder is readable nowhere else.
+ appendNonEmptyAttribute(xml, "hint", node.getHintText());
}
appendAttribute(xml, "editable", Boolean.toString(node.isEditable()));
// Accessibility selection offsets, not a measurement of the value's length. Read-only
diff --git a/packages/kernel/src/snapshot.ts b/packages/kernel/src/snapshot.ts
index eace66eba8..b6b817433d 100644
--- a/packages/kernel/src/snapshot.ts
+++ b/packages/kernel/src/snapshot.ts
@@ -246,6 +246,11 @@ export type RawSnapshotNode = {
editable?: boolean;
password?: boolean;
hintShowing?: boolean;
+ /**
+ * Placeholder text of a text field (the Android hint), whether or not the field is showing it.
+ * Absent when the field has none or the producer did not read it.
+ */
+ placeholder?: string;
/** Accessibility selection offsets, never a character count or proof of value equality. */
selectionStart?: number;
selectionEnd?: number;
diff --git a/packages/platform-android/src/__tests__/ui-hierarchy-placeholder.test.ts b/packages/platform-android/src/__tests__/ui-hierarchy-placeholder.test.ts
new file mode 100644
index 0000000000..c72a64418b
--- /dev/null
+++ b/packages/platform-android/src/__tests__/ui-hierarchy-placeholder.test.ts
@@ -0,0 +1,53 @@
+import { expect, test } from 'vitest';
+import { buildUiHierarchySnapshot, parseUiHierarchyTree } from '../ui-hierarchy.ts';
+
+// A form: an empty field shows its hint (and reports the hint as its text), a filled field no longer
+// shows it but still has one, and a label has none.
+const PLACEHOLDER_XML = `
+
+
+
+
+
+`;
+
+function nodesById(raw: boolean, interactiveOnly = false) {
+ const { nodes } = buildUiHierarchySnapshot(parseUiHierarchyTree(PLACEHOLDER_XML), undefined, {
+ raw,
+ interactiveOnly,
+ });
+ return (identifier: string) => nodes.find((node) => node.identifier === identifier);
+}
+
+test.each([
+ { raw: false, interactiveOnly: false },
+ { raw: true, interactiveOnly: false },
+ { raw: true, interactiveOnly: true },
+])(
+ 'the placeholder reaches snapshot nodes whether or not the field shows it (raw=$raw, -i=$interactiveOnly)',
+ ({ raw, interactiveOnly }) => {
+ const byId = nodesById(raw, interactiveOnly);
+ expect(byId('name-input')?.placeholder).toBe('Key echo');
+ expect(byId('name-input')?.hintShowing).toBe(true);
+ expect(byId('email-input')?.placeholder).toBe('Email address');
+ expect(byId('email-input')?.hintShowing).toBe(false);
+ },
+);
+
+test('the value stays what the platform reported: the hint while showing, the text once filled', () => {
+ const byId = nodesById(false);
+ expect(byId('name-input')?.value).toBe('Key echo');
+ expect(byId('email-input')?.value).toBe('ada@example.com');
+});
+
+test('a node without a hint carries no placeholder key once serialized', () => {
+ const byId = nodesById(false);
+ expect(JSON.parse(JSON.stringify(byId('name-label')))).not.toHaveProperty('placeholder');
+});
diff --git a/packages/platform-android/src/ui-hierarchy-builder.ts b/packages/platform-android/src/ui-hierarchy-builder.ts
index a3aed983f0..e4f0ffaffb 100644
--- a/packages/platform-android/src/ui-hierarchy-builder.ts
+++ b/packages/platform-android/src/ui-hierarchy-builder.ts
@@ -359,6 +359,7 @@ function createAndroidRawSnapshotNode(
editable: node.editable,
password: node.password,
hintShowing: node.hintShowing,
+ placeholder: node.placeholder,
selectionStart: node.selectionStart,
selectionEnd: node.selectionEnd,
visibleToUser: node.visibleToUser,
diff --git a/packages/platform-android/src/ui-hierarchy-node.ts b/packages/platform-android/src/ui-hierarchy-node.ts
index 4ac4a47a1f..205c56ef4a 100644
--- a/packages/platform-android/src/ui-hierarchy-node.ts
+++ b/packages/platform-android/src/ui-hierarchy-node.ts
@@ -22,6 +22,7 @@ export type AndroidUiHierarchy = {
editable?: boolean;
password?: boolean;
hintShowing?: boolean;
+ placeholder?: string;
selectionStart?: number;
selectionEnd?: number;
// Two independent facts, never collapsed, and never undefined: the helper omits false attributes
diff --git a/packages/platform-android/src/ui-hierarchy.ts b/packages/platform-android/src/ui-hierarchy.ts
index 006502d9b3..7317d057ba 100644
--- a/packages/platform-android/src/ui-hierarchy.ts
+++ b/packages/platform-android/src/ui-hierarchy.ts
@@ -48,6 +48,8 @@ export type AndroidUiNodeMetadata = {
* `getText()` returns the hint on modern Android). Absent in raw uiautomator dumps.
*/
hintShowing?: boolean;
+ /** Helper-only: the field's hint text (API 26 or later), whether or not it is showing. */
+ hint?: string;
scrollable?: boolean;
canScrollForward?: boolean;
canScrollBackward?: boolean;
@@ -168,6 +170,7 @@ function readNodeAttributes(node: string): Omit {
...optionalNumberAttr('selectionStart', 'selection-start'),
...optionalNumberAttr('selectionEnd', 'selection-end'),
...optionalBoolAttr('hintShowing', 'hint-showing'),
+ ...optionalStringAttr('hint', 'hint'),
...optionalBoolAttr('visibleToUser', 'visible-to-user'),
...optionalBoolAttr('selected', 'selected'),
...optionalBoolAttr('heading', 'heading'),
@@ -331,6 +334,7 @@ function normalizeAndroidUiHierarchyNode(
editable: attrs.editable,
password: attrs.password,
hintShowing: attrs.hintShowing,
+ placeholder: attrs.hint,
selectionStart: attrs.selectionStart,
selectionEnd: attrs.selectionEnd,
visibleToUser: attrs.visibleToUser,
diff --git a/src/commands/capture/runtime/snapshot-unchanged.test.ts b/src/commands/capture/runtime/snapshot-unchanged.test.ts
index c0ef723b71..a965b9b943 100644
--- a/src/commands/capture/runtime/snapshot-unchanged.test.ts
+++ b/src/commands/capture/runtime/snapshot-unchanged.test.ts
@@ -73,6 +73,7 @@ test.each>([
{ enabled: false },
{ selected: true },
{ focused: true },
+ { placeholder: 'Key echo' },
{ hittable: false },
{ bundleId: 'com.example.app' },
{ appName: 'Example' },
diff --git a/src/commands/capture/runtime/snapshot-unchanged.ts b/src/commands/capture/runtime/snapshot-unchanged.ts
index 1ec07b70ee..da581313bc 100644
--- a/src/commands/capture/runtime/snapshot-unchanged.ts
+++ b/src/commands/capture/runtime/snapshot-unchanged.ts
@@ -114,6 +114,7 @@ const PRESENTATION_SCALAR_FIELDS = {
focused: true,
heading: true,
roleDescription: true,
+ placeholder: true,
hittable: true,
bundleId: true,
appName: true,
diff --git a/src/daemon/__tests__/response-views.test.ts b/src/daemon/__tests__/response-views.test.ts
index 73f7fde8ed..392a7f25ca 100644
--- a/src/daemon/__tests__/response-views.test.ts
+++ b/src/daemon/__tests__/response-views.test.ts
@@ -320,6 +320,7 @@ test('attrs digest keeps explicit false/zero/empty field facts; unavailable ones
editable: false,
password: false,
hintShowing: false,
+ placeholder: 'Key echo',
selectionStart: 0,
selectionEnd: 0,
};
diff --git a/src/daemon/response-views.ts b/src/daemon/response-views.ts
index 2a21a34a12..2454c8c59a 100644
--- a/src/daemon/response-views.ts
+++ b/src/daemon/response-views.ts
@@ -120,6 +120,7 @@ const SELECTOR_DIGEST_NODE_FIELDS = [
'editable',
'password',
'hintShowing',
+ 'placeholder',
'selectionStart',
'selectionEnd',
'hittable',
diff --git a/website/docs/docs/snapshots.md b/website/docs/docs/snapshots.md
index f9ef3f34e0..99b08b5bf1 100644
--- a/website/docs/docs/snapshots.md
+++ b/website/docs/docs/snapshots.md
@@ -117,10 +117,10 @@ the strategy owns which tiers it may use.
## Android node metadata
Android snapshot nodes and `get attrs` (including the digest response) carry the native
-`selected`, `heading`, `roleDescription`, `editable`, `password`, `hintShowing`, `selectionStart`,
-and `selectionEnd` facts whenever the accessibility tree reports them. Explicit `false` and `0` are
-kept; an absent field means the fact was unavailable, not false. `hintShowing` needs Android API 26
-or later, `heading` API 28 or later.
+`selected`, `heading`, `roleDescription`, `editable`, `password`, `hintShowing`, `placeholder`,
+`selectionStart`, and `selectionEnd` facts whenever the accessibility tree reports them. Explicit
+`false` and `0` are kept; an absent field means the fact was unavailable, not false. `hintShowing`
+and `placeholder` need Android API 26 or later, `heading` API 28 or later.
- `selected` is the accessibility selected state an app sets on a control — the active bottom-tab or
segmented-control item, or the chosen row of a list. Android reports it explicitly as `true` or
@@ -135,6 +135,9 @@ or later, `heading` API 28 or later.
- `value: ""` is an explicitly empty accessibility text; a missing `value` means no text was
reported. The text of an empty field is its hint on modern Android, so check `hintShowing`
before reading `value` as the entered contents.
+- `placeholder` is the field's hint text itself, present whether the field is empty or filled: an
+ empty field shows it (`hintShowing: true`, and `value` repeats it), a filled field no longer does.
+ A field without a hint omits it.
- `selectionStart`/`selectionEnd` are accessibility selection offsets. They are independent of
`editable` (read-only selectable text exposes them too), they are not a character count, and
they do not prove that a masked or secure value equals expected text.