Fix 1.16.3: tight circle selection #5
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.16.3-tight-select — 2026-08-05
|
||||||
|
|
||||||
|
- Fix: circling a short heading no longer steals the large text block above it
|
||||||
|
(prefer a11y nodes whose center sits in the circle / high coverage)
|
||||||
|
- Fix: stub headline no longer turns `1. Circle…` into a lone `1.`
|
||||||
|
- Fix: do not OCR-merge when a11y already has useful text (short labels were
|
||||||
|
polluted by neighboring OCR)
|
||||||
|
- Smoke now asserts the heading case (not only email → Copy)
|
||||||
|
|
||||||
## 1.16.2-circle-copy — 2026-08-05
|
## 1.16.2-circle-copy — 2026-08-05
|
||||||
|
|
||||||
- More user-facing strings say **Circle** (panel, empty state, Integrations label, Vikunja hint)
|
- More user-facing strings say **Circle** (panel, empty state, Integrations label, Vikunja hint)
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.example.crkl"
|
applicationId = "com.example.crkl"
|
||||||
minSdk = 27
|
minSdk = 27
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 19
|
versionCode = 20
|
||||||
versionName = "1.16.2-circle-copy"
|
versionName = "1.16.3-tight-select"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|||||||
@@ -256,8 +256,10 @@ class CrklAccessibilityService : AccessibilityService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OCR when a11y is thin, or always merge for image-like sparse text.
|
// OCR only when a11y found nothing useful. Short labels like
|
||||||
val needOcr = a11y.isEmpty || a11y.text.length < 40
|
// "How to circle" are real hits — OCR-merge used to re-pull the
|
||||||
|
// neighboring Ship demo block from the selection bitmap.
|
||||||
|
val needOcr = a11y.isEmpty || !ContentCapture.hasUsefulText(a11y.text)
|
||||||
val merged = if (needOcr) {
|
val merged = if (needOcr) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
val debug = IntegrationSettings(this@CrklAccessibilityService).showDebugMeta
|
val debug = IntegrationSettings(this@CrklAccessibilityService).showDebugMeta
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ object LocalAssistStub {
|
|||||||
title = "Circled text",
|
title = "Circled text",
|
||||||
meta = "",
|
meta = "",
|
||||||
body = buildString {
|
body = buildString {
|
||||||
if (summary.isNotBlank()) {
|
// Headline only when it shortens a longer blob — never a lone "1." marker.
|
||||||
|
if (summary.length >= 12 && summary.length < preview.length) {
|
||||||
appendLine(summary)
|
appendLine(summary)
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine("—")
|
appendLine("—")
|
||||||
@@ -187,16 +188,18 @@ object LocalAssistStub {
|
|||||||
|
|
||||||
private fun heuristicSummary(text: String): String {
|
private fun heuristicSummary(text: String): String {
|
||||||
val firstLine = text.lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty()
|
val firstLine = text.lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty()
|
||||||
|
// Do not treat "1." / "2." list markers as sentence terminators.
|
||||||
val sentence = firstLine
|
val sentence = firstLine
|
||||||
.split(Regex("(?<=[.!?])\\s+"))
|
.split(Regex("(?<=[A-Za-z][.!?])\\s+"))
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
?.trim()
|
?.trim()
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
|
|
||||||
val head = when {
|
val head = when {
|
||||||
sentence.isNotEmpty() && sentence.length <= 160 -> sentence
|
sentence.length in 12..160 -> sentence
|
||||||
firstLine.length <= 160 -> firstLine
|
firstLine.length in 12..160 -> firstLine
|
||||||
else -> firstLine.take(157) + "…"
|
firstLine.length > 160 -> firstLine.take(157) + "…"
|
||||||
|
else -> ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return head
|
return head
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import android.view.accessibility.AccessibilityNodeInfo
|
|||||||
import android.view.accessibility.AccessibilityWindowInfo
|
import android.view.accessibility.AccessibilityWindowInfo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls readable text from accessibility nodes that intersect a screen-space selection.
|
* Pulls readable text from accessibility nodes that fit a screen-space selection.
|
||||||
*
|
*
|
||||||
* Prefers the accessibility node tree over MediaProjection so the first vertical
|
* Prefers the accessibility node tree over MediaProjection so the first vertical
|
||||||
* slice stays permission-light and fully on-device.
|
* slice stays permission-light and fully on-device.
|
||||||
@@ -33,8 +33,8 @@ object RegionContentExtractor {
|
|||||||
selection: RectF
|
selection: RectF
|
||||||
): ExtractionResult {
|
): ExtractionResult {
|
||||||
val selectionRect = Rect().also { selection.round(it) }
|
val selectionRect = Rect().also { selection.round(it) }
|
||||||
val snippets = linkedSetOf<String>()
|
val candidates = mutableListOf<Pair<String, SelectionOverlap.Score>>()
|
||||||
var nodesHit = 0
|
var nodesVisited = 0
|
||||||
var packageName: String? = null
|
var packageName: String? = null
|
||||||
|
|
||||||
val roots = mutableListOf<AccessibilityNodeInfo>()
|
val roots = mutableListOf<AccessibilityNodeInfo>()
|
||||||
@@ -53,61 +53,70 @@ object RegionContentExtractor {
|
|||||||
packageName = root.packageName?.toString()
|
packageName = root.packageName?.toString()
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
nodesHit += collectIntersectingText(root, selectionRect, snippets)
|
nodesVisited += collectCandidates(root, selectionRect, candidates)
|
||||||
} finally {
|
} finally {
|
||||||
// Only recycle roots we obtained from windows; caller owns rootFallback.
|
// Only recycle roots we obtained from windows; caller owns rootFallback.
|
||||||
if (root !== rootFallback) {
|
if (root !== rootFallback) {
|
||||||
root.recycle()
|
root.recycle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (snippets.isNotEmpty() || nodesHit >= MAX_NODES) break
|
if (candidates.isNotEmpty() || nodesVisited >= MAX_NODES) break
|
||||||
}
|
}
|
||||||
|
|
||||||
val joined = snippets
|
val picked = SelectionOverlap.pickBest(candidates)
|
||||||
|
val joined = picked
|
||||||
|
.distinct()
|
||||||
.joinToString(separator = "\n")
|
.joinToString(separator = "\n")
|
||||||
.trim()
|
.trim()
|
||||||
.take(MAX_CHARS)
|
.take(MAX_CHARS)
|
||||||
|
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"extract: nodes=$nodesHit snippets=${snippets.size} chars=${joined.length} pkg=$packageName"
|
"extract: visited=$nodesVisited candidates=${candidates.size} " +
|
||||||
|
"picked=${picked.size} chars=${joined.length} pkg=$packageName " +
|
||||||
|
"preview=${joined.take(100).replace('\n', '|')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return ExtractionResult(
|
return ExtractionResult(
|
||||||
text = joined,
|
text = joined,
|
||||||
nodeCount = nodesHit,
|
nodeCount = picked.size,
|
||||||
bounds = RectF(selection),
|
bounds = RectF(selection),
|
||||||
packageName = packageName
|
packageName = packageName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun collectIntersectingText(
|
private fun collectCandidates(
|
||||||
node: AccessibilityNodeInfo,
|
node: AccessibilityNodeInfo,
|
||||||
selection: Rect,
|
selection: Rect,
|
||||||
out: MutableSet<String>,
|
out: MutableList<Pair<String, SelectionOverlap.Score>>,
|
||||||
depth: Int = 0
|
depth: Int = 0
|
||||||
): Int {
|
): Int {
|
||||||
if (depth > 40 || out.size >= MAX_NODES) return 0
|
if (depth > 40 || out.size >= MAX_NODES) return 0
|
||||||
|
|
||||||
var hit = 0
|
var visited = 1
|
||||||
val bounds = Rect()
|
val bounds = Rect()
|
||||||
node.getBoundsInScreen(bounds)
|
node.getBoundsInScreen(bounds)
|
||||||
|
val score = SelectionOverlap.score(bounds, selection)
|
||||||
|
|
||||||
if (Rect.intersects(bounds, selection)) {
|
var childTextHits = 0
|
||||||
readableText(node)?.let { out.add(it) }
|
|
||||||
hit = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i in 0 until node.childCount) {
|
for (i in 0 until node.childCount) {
|
||||||
val child = node.getChild(i) ?: continue
|
val child = node.getChild(i) ?: continue
|
||||||
try {
|
try {
|
||||||
hit += collectIntersectingText(child, selection, out, depth + 1)
|
val before = out.size
|
||||||
|
visited += collectCandidates(child, selection, out, depth + 1)
|
||||||
|
if (out.size > before) childTextHits++
|
||||||
} finally {
|
} finally {
|
||||||
child.recycle()
|
child.recycle()
|
||||||
}
|
}
|
||||||
if (out.size >= MAX_NODES) break
|
if (out.size >= MAX_NODES) break
|
||||||
}
|
}
|
||||||
return hit
|
|
||||||
|
// Prefer leaf (or near-leaf) text — skip parents that only duplicate children.
|
||||||
|
if (score.include && childTextHits == 0) {
|
||||||
|
readableText(node)?.let { out.add(it to score) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return visited
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readableText(node: AccessibilityNodeInfo): String? {
|
private fun readableText(node: AccessibilityNodeInfo): String? {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.example.crkl.vision
|
||||||
|
|
||||||
|
import android.graphics.Rect
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scores how well an a11y node fits a circle's screen bounds.
|
||||||
|
*
|
||||||
|
* Loose [Rect.intersects] alone pulls in large TextViews that only graze the
|
||||||
|
* selection (e.g. circling "How to circle" also hits the Ship demo block above).
|
||||||
|
*
|
||||||
|
* Geometry uses raw ints so JVM unit tests do not need a mocked [Rect].
|
||||||
|
*/
|
||||||
|
object SelectionOverlap {
|
||||||
|
|
||||||
|
data class Score(
|
||||||
|
val include: Boolean,
|
||||||
|
/** Higher = tighter match. */
|
||||||
|
val rank: Float,
|
||||||
|
val centerInSelection: Boolean,
|
||||||
|
/** Fraction of the node covered by the selection (0..1). */
|
||||||
|
val coverage: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
fun score(nodeBounds: Rect, selection: Rect): Score =
|
||||||
|
score(
|
||||||
|
nodeLeft = nodeBounds.left,
|
||||||
|
nodeTop = nodeBounds.top,
|
||||||
|
nodeRight = nodeBounds.right,
|
||||||
|
nodeBottom = nodeBounds.bottom,
|
||||||
|
selLeft = selection.left,
|
||||||
|
selTop = selection.top,
|
||||||
|
selRight = selection.right,
|
||||||
|
selBottom = selection.bottom
|
||||||
|
)
|
||||||
|
|
||||||
|
fun score(
|
||||||
|
nodeLeft: Int,
|
||||||
|
nodeTop: Int,
|
||||||
|
nodeRight: Int,
|
||||||
|
nodeBottom: Int,
|
||||||
|
selLeft: Int,
|
||||||
|
selTop: Int,
|
||||||
|
selRight: Int,
|
||||||
|
selBottom: Int
|
||||||
|
): Score {
|
||||||
|
val nodeW = nodeRight - nodeLeft
|
||||||
|
val nodeH = nodeBottom - nodeTop
|
||||||
|
val selW = selRight - selLeft
|
||||||
|
val selH = selBottom - selTop
|
||||||
|
if (nodeW <= 0 || nodeH <= 0 || selW <= 0 || selH <= 0) {
|
||||||
|
return Score(include = false, rank = 0f, centerInSelection = false, coverage = 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
val interLeft = maxOf(nodeLeft, selLeft)
|
||||||
|
val interTop = maxOf(nodeTop, selTop)
|
||||||
|
val interRight = minOf(nodeRight, selRight)
|
||||||
|
val interBottom = minOf(nodeBottom, selBottom)
|
||||||
|
val interW = interRight - interLeft
|
||||||
|
val interH = interBottom - interTop
|
||||||
|
if (interW <= 0 || interH <= 0) {
|
||||||
|
return Score(include = false, rank = 0f, centerInSelection = false, coverage = 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
val overlap = interW.toLong() * interH.toLong()
|
||||||
|
val nodeArea = nodeW.toLong() * nodeH.toLong()
|
||||||
|
val coverage = (overlap.toFloat() / nodeArea.toFloat()).coerceIn(0f, 1f)
|
||||||
|
val cx = (nodeLeft + nodeRight) / 2
|
||||||
|
val cy = (nodeTop + nodeBottom) / 2
|
||||||
|
val centerIn = cx in selLeft until selRight && cy in selTop until selBottom
|
||||||
|
|
||||||
|
// Prefer nodes whose center sits in the circle; otherwise require most of
|
||||||
|
// the node to lie inside (partial paragraph circle still works).
|
||||||
|
val include = centerIn || coverage >= 0.45f
|
||||||
|
val rank = (if (centerIn) 2f else 0f) + coverage +
|
||||||
|
(1f / (1f + nodeArea / 50_000f))
|
||||||
|
|
||||||
|
return Score(
|
||||||
|
include = include,
|
||||||
|
rank = rank,
|
||||||
|
centerInSelection = centerIn,
|
||||||
|
coverage = coverage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From scored hits, keep center-in nodes when any exist; else high-coverage ones.
|
||||||
|
*/
|
||||||
|
fun <T> pickBest(candidates: List<Pair<T, Score>>): List<T> {
|
||||||
|
if (candidates.isEmpty()) return emptyList()
|
||||||
|
val included = candidates.filter { it.second.include }
|
||||||
|
val pool = when {
|
||||||
|
included.any { it.second.centerInSelection } ->
|
||||||
|
included.filter { it.second.centerInSelection }
|
||||||
|
included.isNotEmpty() -> included
|
||||||
|
else -> candidates.filter { it.second.coverage >= 0.25f }
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
.sortedByDescending { it.second.rank }
|
||||||
|
.map { it.first }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,21 @@ class LocalAssistStubTest {
|
|||||||
assertTrue(response.meta.isEmpty())
|
assertTrue(response.meta.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun respond_numberedList_doesNotUseLoneOneAsHeadline() {
|
||||||
|
val result = RegionContentExtractor.ExtractionResult(
|
||||||
|
text = "1. Circle the English sentence → tap Translate [Translate]\n" +
|
||||||
|
"2. Circle the paragraph → Copy, then Explain [Copy / Explain]",
|
||||||
|
nodeCount = 1,
|
||||||
|
bounds = android.graphics.RectF(),
|
||||||
|
packageName = "com.example.crkl"
|
||||||
|
)
|
||||||
|
val response = LocalAssistStub.respond(result)
|
||||||
|
assertTrue(response.body.contains("Circle the English"))
|
||||||
|
// Old bug: summary split on "1." and showed a lone "1." above the divider.
|
||||||
|
assertTrue(!response.body.trimStart().startsWith("1.\n"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun respond_ocrJunk_treatedAsEmpty() {
|
fun respond_ocrJunk_treatedAsEmpty() {
|
||||||
val result = RegionContentExtractor.ExtractionResult(
|
val result = RegionContentExtractor.ExtractionResult(
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.example.crkl.vision
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SelectionOverlapTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tightHeading_includesCenterNode_excludesGrazingBlock() {
|
||||||
|
// Circle bbox around a heading
|
||||||
|
val headScore = SelectionOverlap.score(
|
||||||
|
nodeLeft = 120, nodeTop = 410, nodeRight = 360, nodeBottom = 470,
|
||||||
|
selLeft = 100, selTop = 400, selRight = 400, selBottom = 480
|
||||||
|
)
|
||||||
|
// Large block above — only bottom edge grazes the selection
|
||||||
|
val blockScore = SelectionOverlap.score(
|
||||||
|
nodeLeft = 40, nodeTop = 100, nodeRight = 500, nodeBottom = 420,
|
||||||
|
selLeft = 100, selTop = 400, selRight = 400, selBottom = 480
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(headScore.include)
|
||||||
|
assertTrue(headScore.centerInSelection)
|
||||||
|
assertFalse(
|
||||||
|
"grazing ship-demo block must not steal the circle",
|
||||||
|
blockScore.include
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pickBest_prefersCenterInOverGrazing() {
|
||||||
|
val heading = "How to circle" to SelectionOverlap.score(
|
||||||
|
120, 410, 360, 470, 100, 400, 400, 480
|
||||||
|
)
|
||||||
|
val block = "1. Circle the English…" to SelectionOverlap.score(
|
||||||
|
40, 100, 500, 420, 100, 400, 400, 480
|
||||||
|
)
|
||||||
|
|
||||||
|
val picked = SelectionOverlap.pickBest(listOf(block, heading))
|
||||||
|
assertEquals(listOf("How to circle"), picked)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pickBest_multipleCenterNodes_keepsAllRanked() {
|
||||||
|
val a = "How to circle" to SelectionOverlap.score(
|
||||||
|
120, 410, 360, 470, 100, 390, 400, 520
|
||||||
|
)
|
||||||
|
val b = "1. Tap the floating C" to SelectionOverlap.score(
|
||||||
|
100, 480, 500, 560, 100, 390, 400, 520
|
||||||
|
)
|
||||||
|
val leak = "Ship demo blob" to SelectionOverlap.score(
|
||||||
|
40, 50, 500, 400, 100, 390, 400, 520
|
||||||
|
)
|
||||||
|
val picked = SelectionOverlap.pickBest(listOf(leak, b, a))
|
||||||
|
assertTrue(picked.contains("How to circle"))
|
||||||
|
assertFalse(picked.contains("Ship demo blob"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tinyCornerGraze_excluded() {
|
||||||
|
val score = SelectionOverlap.score(
|
||||||
|
nodeLeft = 0, nodeTop = 0, nodeRight = 1000, nodeBottom = 1000,
|
||||||
|
selLeft = 900, selTop = 900, selRight = 980, selBottom = 980
|
||||||
|
)
|
||||||
|
assertFalse(score.centerInSelection)
|
||||||
|
assertTrue(score.coverage < 0.45f)
|
||||||
|
assertFalse(score.include)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -30,5 +30,5 @@ Run during ship freeze. Bugs only — no new features.
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
_Date:_ 2026-08-05
|
_Date:_ 2026-08-05
|
||||||
_Issues:_ Emulator smoke (`make smoke`) passed on CrklEmulator: FAB → closed loop → extract (278+ chars) → **Copy** (`Copied … clipboard`) and earlier **Translate** (`English → Russian`). Use `make emulator` (launchd on macOS) if qemu dies under the agent sandbox.
|
_Issues:_ **1.16.3** fixes heading steal (Ship demo) + `1.` headline; smoke asserts “How to circle” + Copy.
|
||||||
|
|
||||||
|
|||||||
+94
-23
@@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Device/emulator smoke: unit tests already cover JVM; this hits the live overlay.
|
# Device/emulator smoke — catches the bugs that unit tests + "any extract" miss.
|
||||||
# Requires: adb device online, Circle Overlay enabled (or we enable it).
|
# Cases:
|
||||||
|
# 1) Tight circle on "How to circle" must NOT steal Ship demo block above
|
||||||
|
# 2) Circle email region → Copy chip works
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
@@ -11,7 +13,9 @@ PKG=com.example.crkl
|
|||||||
die() { echo "FAIL: $*" >&2; exit 1; }
|
die() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
pass() { echo "OK: $*"; }
|
pass() { echo "OK: $*"; }
|
||||||
|
|
||||||
"$ADB" devices | awk '/\tdevice$/{found=1} END{exit !found}' || die "no adb device"
|
# adb devices -l uses spaces, not tabs, between serial and state
|
||||||
|
"$ADB" devices | awk '/device( |$)/ && $1 !~ /List/{found=1} END{exit !found}' \
|
||||||
|
|| die "no adb device"
|
||||||
|
|
||||||
echo "== smoke Circle on $($ADB get-serialno) =="
|
echo "== smoke Circle on $($ADB get-serialno) =="
|
||||||
|
|
||||||
@@ -25,32 +29,31 @@ sleep 1.5
|
|||||||
"$ADB" shell am start -n "$PKG/.fixtures.TestFixturesActivity" >/dev/null
|
"$ADB" shell am start -n "$PKG/.fixtures.TestFixturesActivity" >/dev/null
|
||||||
sleep 1.5
|
sleep 1.5
|
||||||
|
|
||||||
|
enter_circle() {
|
||||||
"$ADB" logcat -c
|
"$ADB" logcat -c
|
||||||
# FAB: BOTTOM|END, ~964,2200 on 1080x2400 Pixel-7-ish
|
|
||||||
"$ADB" shell input tap 964 2200
|
"$ADB" shell input tap 964 2200
|
||||||
sleep 0.5
|
sleep 0.4
|
||||||
if ! "$ADB" logcat -d -s OverlayView:D | grep -q 'floating button clicked'; then
|
if "$ADB" logcat -d -s OverlayView:D | grep -q 'floating button clicked'; then
|
||||||
# sweep near known FAB frame
|
return 0
|
||||||
hit=0
|
fi
|
||||||
for y in $(seq 2140 20 2280); do
|
for y in $(seq 2140 20 2280); do
|
||||||
for x in $(seq 900 20 1030); do
|
for x in $(seq 900 20 1030); do
|
||||||
"$ADB" shell input tap "$x" "$y"
|
"$ADB" shell input tap "$x" "$y"
|
||||||
sleep 0.05
|
sleep 0.05
|
||||||
if "$ADB" logcat -d -s OverlayView:D | grep -q 'floating button clicked'; then
|
if "$ADB" logcat -d -s OverlayView:D | grep -q 'floating button clicked'; then
|
||||||
hit=1
|
return 0
|
||||||
break 2
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
[[ "$hit" = 1 ]] || die "FAB not clickable — is Circle Overlay ON?"
|
return 1
|
||||||
fi
|
}
|
||||||
pass "enter circle mode"
|
|
||||||
|
|
||||||
# Closed loop around fixtures email subject region
|
draw_loop() {
|
||||||
python3 - <<'PY'
|
local cx="$1" cy="$2" r="$3"
|
||||||
import math, subprocess, time
|
python3 - "$cx" "$cy" "$r" <<'PY'
|
||||||
|
import math, subprocess, sys, time
|
||||||
|
cx, cy, r = map(int, sys.argv[1:4])
|
||||||
adb = lambda *a: subprocess.check_call(["adb", *a])
|
adb = lambda *a: subprocess.check_call(["adb", *a])
|
||||||
cx, cy, r = 540, 1720, 160
|
|
||||||
n = 28
|
n = 28
|
||||||
pts = [
|
pts = [
|
||||||
(
|
(
|
||||||
@@ -66,16 +69,84 @@ adb("shell", "input", "motionevent", "UP", str(pts[-1][0]), str(pts[-1][1]))
|
|||||||
for _ in range(25):
|
for _ in range(25):
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
out = subprocess.check_output(["adb", "logcat", "-d"], text=True)
|
out = subprocess.check_output(["adb", "logcat", "-d"], text=True)
|
||||||
if "stub path" in out or "Selection ready" in out:
|
if "extract:" in out or "Selection ready" in out:
|
||||||
break
|
break
|
||||||
PY
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
"$ADB" logcat -d | grep -q 'Selection ready' || die "no selection"
|
close_panel() {
|
||||||
"$ADB" logcat -d | grep -qE 'extract:.*chars=[1-9]' || die "no text extracted"
|
"$ADB" shell input tap 980 1300 || true
|
||||||
pass "circle → extract"
|
sleep 0.3
|
||||||
|
"$ADB" shell input keyevent KEYCODE_BACK || true
|
||||||
|
sleep 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve live UI bounds so layout/scroll changes do not false-fail smoke.
|
||||||
|
"$ADB" shell uiautomator dump /sdcard/crkl-smoke.xml >/dev/null
|
||||||
|
"$ADB" pull /sdcard/crkl-smoke.xml /tmp/crkl-smoke.xml >/dev/null
|
||||||
|
eval "$(python3 <<'PY'
|
||||||
|
import re, sys
|
||||||
|
xml = open("/tmp/crkl-smoke.xml").read()
|
||||||
|
m = re.search(r'text="How to circle"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml)
|
||||||
|
if not m:
|
||||||
|
sys.exit("How to circle not on screen — open fixtures / scroll to top")
|
||||||
|
x1, y1, x2, y2 = map(int, m.groups())
|
||||||
|
cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
|
||||||
|
r = max(60, min(100, (x2 - x1) // 2 + 30, (y2 - y1) // 2 + 40))
|
||||||
|
print(f"HEAD_CX={cx}; HEAD_CY={cy}; HEAD_R={r}")
|
||||||
|
# Email fixture / subject area — prefer Subject line if present
|
||||||
|
m2 = re.search(r'text="[^"]*Subject: Q2[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml)
|
||||||
|
if not m2:
|
||||||
|
m2 = re.search(r'text="[^"]*Q2 planning moved[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml)
|
||||||
|
if m2:
|
||||||
|
a, b, c, d = map(int, m2.groups())
|
||||||
|
print(f"MAIL_CX={(a+c)//2}; MAIL_CY={(b+d)//2}; MAIL_R=160")
|
||||||
|
else:
|
||||||
|
print("MAIL_CX=540; MAIL_CY=1720; MAIL_R=160")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
# --- Case 1: heading must not steal Ship demo ---
|
||||||
|
enter_circle || die "FAB not clickable — is Circle Overlay ON?"
|
||||||
|
pass "enter circle mode"
|
||||||
|
draw_loop "$HEAD_CX" "$HEAD_CY" "$HEAD_R"
|
||||||
|
logs="$("$ADB" logcat -d)"
|
||||||
|
echo "$logs" | grep -q 'Selection ready' || die "case1: no selection"
|
||||||
|
echo "$logs" | grep -E 'extract:.*preview=' | grep -qi 'How to circle' \
|
||||||
|
|| die "case1: extract missing 'How to circle' (got wrong node?)"
|
||||||
|
if echo "$logs" | grep -E 'extract:.*preview=' | grep -qi 'English sentence'; then
|
||||||
|
die "case1: Ship demo block leaked into heading circle"
|
||||||
|
fi
|
||||||
|
pass "tight heading extract (no Ship demo leak)"
|
||||||
|
close_panel
|
||||||
|
|
||||||
|
# --- Case 2: email region → Copy ---
|
||||||
|
"$ADB" shell am start -n "$PKG/.fixtures.TestFixturesActivity" >/dev/null
|
||||||
|
sleep 1
|
||||||
|
# Re-dump in case scroll changed
|
||||||
|
"$ADB" shell uiautomator dump /sdcard/crkl-smoke.xml >/dev/null
|
||||||
|
"$ADB" pull /sdcard/crkl-smoke.xml /tmp/crkl-smoke.xml >/dev/null
|
||||||
|
eval "$(python3 <<'PY'
|
||||||
|
import re
|
||||||
|
xml = open("/tmp/crkl-smoke.xml").read()
|
||||||
|
m2 = re.search(r'text="[^"]*Subject: Q2[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml)
|
||||||
|
if not m2:
|
||||||
|
m2 = re.search(r'text="[^"]*Q2 planning moved[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml)
|
||||||
|
if m2:
|
||||||
|
a, b, c, d = map(int, m2.groups())
|
||||||
|
print(f"MAIL_CX={(a+c)//2}; MAIL_CY={(b+d)//2}; MAIL_R=160")
|
||||||
|
else:
|
||||||
|
print("MAIL_CX=540; MAIL_CY=1720; MAIL_R=160")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
enter_circle || die "FAB not clickable (case2)"
|
||||||
|
draw_loop "$MAIL_CX" "$MAIL_CY" "$MAIL_R"
|
||||||
|
logs="$("$ADB" logcat -d)"
|
||||||
|
echo "$logs" | grep -qE 'extract:.*chars=[1-9]' || die "case2: no text extracted"
|
||||||
|
pass "email region extract"
|
||||||
|
|
||||||
"$ADB" logcat -c
|
"$ADB" logcat -c
|
||||||
# Copy is second VIP chip (~x=320, y=2185 on this skin)
|
|
||||||
"$ADB" shell input tap 320 2185
|
"$ADB" shell input tap 320 2185
|
||||||
sleep 1
|
sleep 1
|
||||||
"$ADB" logcat -d | grep -q 'action kind=COPY' || die "Copy chip not triggered"
|
"$ADB" logcat -d | grep -q 'action kind=COPY' || die "Copy chip not triggered"
|
||||||
@@ -83,4 +154,4 @@ sleep 1
|
|||||||
pass "Copy chip"
|
pass "Copy chip"
|
||||||
|
|
||||||
echo "== smoke PASSED =="
|
echo "== smoke PASSED =="
|
||||||
echo "Tip: full dogfood is docs/dogfood.md (Translate / Explain / Share / Vikunja / real app)."
|
echo "Tip: full dogfood is docs/dogfood.md"
|
||||||
|
|||||||
Reference in New Issue
Block a user