Ship Circle 1.16.2: overlay assist, brand, and smoke tooling

VIP loop (circle → extract → Translate/Copy/Explain/Share/Vikunja),
lasso branding, ship docs, unit tests, and make smoke / launchd emu helpers.
This commit is contained in:
2026-08-05 12:26:51 -04:00
parent 80870d6adb
commit cbed90db21
116 changed files with 7564 additions and 715 deletions
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Smoke Crkl circle flows (integrations need tokens configured separately).
set -euo pipefail
ADB="${ANDROID_HOME:-/opt/homebrew/share/android-commandlinetools}/platform-tools/adb"
export ADB_BIN="$ADB"
SHOTS="$(cd "$(dirname "$0")/.." && pwd)/docs/demo-shots/workflows"
mkdir -p "$SHOTS"
tap() { "$ADB" shell input tap "$1" "$2"; }
swipe() { "$ADB" shell input swipe "$1" "$2" "$3" "$4" "$5"; }
shot() { "$ADB" exec-out screencap -p > "$SHOTS/$1.png"; echo " shot $1"; }
log_clear() { "$ADB" logcat -c || true; }
log_has() { "$ADB" logcat -d 2>/dev/null | grep -E "$1" >/dev/null; }
enable_a11y() {
"$ADB" shell settings put secure enabled_accessibility_services \
com.example.crkl/com.example.crkl.accessibility.CrklAccessibilityService
"$ADB" shell settings put secure accessibility_enabled 1
sleep 2
}
circle() {
python3 - "$1" "$2" "$3" "$4" <<'PY'
import math, subprocess, sys, os, time
ADB=os.environ["ADB_BIN"]
cx,cy,rx,ry=map(float,sys.argv[1:5])
n=36
pts=[(cx+rx*math.cos(2*math.pi*i/n), cy+ry*math.sin(2*math.pi*i/n)) for i in range(n+1)]
def me(a,x,y):
subprocess.check_call([ADB,"shell","input","motionevent",a,str(int(x)),str(int(y))])
me("DOWN",*pts[0]); time.sleep(0.02)
for p in pts[1:]:
me("MOVE",*p); time.sleep(0.01)
me("UP",*pts[-1])
PY
}
pass_n=0; fail_n=0
ok() { echo "PASS $1"; pass_n=$((pass_n+1)); }
bad() { echo "FAIL $1"; fail_n=$((fail_n+1)); }
echo "== prep =="
enable_a11y
"$ADB" shell am start -n com.example.crkl/.MainActivity >/dev/null
sleep 1
shot "00-home"
echo "== email extract =="
log_clear
"$ADB" shell am start -n com.example.crkl/.fixtures.TestFixturesActivity >/dev/null
enable_a11y
swipe 540 1600 540 900 300; sleep 0.4
tap 964 2200; sleep 1
circle 540 1700 400 350
sleep 3
shot "01-email"
log_has "Selection ready|Floating button clicked" && ok "email" || bad "email"
tap 900 1600; sleep 0.3
echo "== audio =="
log_clear
for _ in 1 2; do swipe 540 1900 540 500 300; sleep 0.2; done
tap 964 2200; sleep 1
circle 540 1750 400 280
sleep 6
shot "03-audio"
log_has "MediaAssistPipeline|audio_grocery" && ok "audio" || bad "audio"
echo "== mail inbox fixture =="
log_clear
"$ADB" shell am start -n com.example.crkl/.fixtures.TestFixturesActivity >/dev/null
enable_a11y
sleep 1
# Open mail inbox card if present; otherwise circle fixtures screen
tap 540 900; sleep 0.5
tap 964 2200; sleep 1
circle 540 1100 450 550
sleep 3
shot "05-mail-fixture"
log_has "Selection ready|Floating button clicked" && ok "mail-fixture" || bad "mail-fixture"
echo "== integrations screen =="
"$ADB" shell am start -n com.example.crkl/.IntegrationsActivity >/dev/null
sleep 1
shot "08-integrations"
ok "integrations-ui"
echo
echo "== results: $pass_n passed, $fail_n failed =="
echo "Configure Vikunja token + calendar permission in Integrations for live todo/calendar."
exit 0
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Optional lab bridge: Crkl emulator → host `gog` CLI (Gmail send).
make gog-bridge
# Integrations → Prefer gog + URL http://10.0.2.2:8765
Endpoints:
GET /health
POST /v1/gmail/send JSON: {to, subject, body}
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HOST = os.environ.get("CRKL_GOG_BRIDGE_HOST", "0.0.0.0")
PORT = int(os.environ.get("CRKL_GOG_BRIDGE_PORT", "8765"))
GOG = os.environ.get("GOG_BIN") or shutil.which("gog") or "gog"
ACCOUNT = os.environ.get("GOG_ACCOUNT", "")
def run_gog(args: list[str]) -> tuple[int, str, str]:
cmd = [GOG, *args]
if ACCOUNT:
cmd[1:1] = ["-a", ACCOUNT]
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
sys.stderr.write("gog-bridge: " + (fmt % args) + "\n")
def _json(self, code: int, payload: dict) -> None:
raw = json.dumps(payload).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self) -> None:
if self.path.startswith("/health"):
code, out, err = run_gog(["auth", "list", "-j"])
accounts: list = []
if code == 0 and out:
try:
parsed = json.loads(out)
raw = parsed.get("accounts") if isinstance(parsed, dict) else parsed
if isinstance(raw, list):
accounts = raw
except json.JSONDecodeError:
accounts = []
ok = code == 0 and len(accounts) > 0
labels = []
for a in accounts[:5]:
if isinstance(a, dict):
labels.append(str(a.get("email") or a.get("account") or a))
else:
labels.append(str(a))
msg = (
"gog ok: " + ", ".join(labels)
if ok
else "No gog tokens. Run: gog auth add (or tunnel Hermes:8765)"
)
self._json(200 if ok else 503, {"ok": ok, "message": msg, "accounts": len(accounts)})
return
self._json(404, {"ok": False, "message": "not found"})
def do_POST(self) -> None:
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length) if length else b"{}"
try:
body = json.loads(raw.decode() or "{}")
except json.JSONDecodeError:
self._json(400, {"ok": False, "message": "invalid json"})
return
if self.path.startswith("/v1/gmail/send"):
to = (body.get("to") or "").strip()
subject = (body.get("subject") or "").strip()
text = body.get("body") or ""
if not to or not subject:
self._json(400, {"ok": False, "message": "to and subject required"})
return
args = ["gmail", "send", "--to", to, "--subject", subject, "--body", text]
if body.get("account"):
args = ["-a", body["account"], *args]
code, out, err = run_gog(args)
if code != 0:
self._json(502, {"ok": False, "message": err or out or "send failed"})
return
self._json(200, {"ok": True, "message": f"Sent via gog → {to}\n{out}".strip()})
return
self._json(404, {"ok": False, "message": "not found"})
def main() -> None:
print(f"Crkl gog bridge on http://{HOST}:{PORT} (gog={GOG})")
print("Emulator URL: http://10.0.2.2:8765")
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Device/emulator smoke: unit tests already cover JVM; this hits the live overlay.
# Requires: adb device online, Circle Overlay enabled (or we enable it).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ADB="${ADB:-adb}"
COMP='com.example.crkl/com.example.crkl.accessibility.CrklAccessibilityService'
PKG=com.example.crkl
die() { echo "FAIL: $*" >&2; exit 1; }
pass() { echo "OK: $*"; }
"$ADB" devices | awk '/\tdevice$/{found=1} END{exit !found}' || die "no adb device"
echo "== smoke Circle on $($ADB get-serialno) =="
"$ADB" shell settings put secure accessibility_enabled 1
"$ADB" shell settings put secure enabled_accessibility_services "$COMP"
"$ADB" shell am force-stop "$PKG" >/dev/null 2>&1 || true
sleep 1
"$ADB" shell settings put secure enabled_accessibility_services "$COMP"
"$ADB" shell settings put secure accessibility_enabled 1
sleep 1.5
"$ADB" shell am start -n "$PKG/.fixtures.TestFixturesActivity" >/dev/null
sleep 1.5
"$ADB" logcat -c
# FAB: BOTTOM|END, ~964,2200 on 1080x2400 Pixel-7-ish
"$ADB" shell input tap 964 2200
sleep 0.5
if ! "$ADB" logcat -d -s OverlayView:D | grep -q 'floating button clicked'; then
# sweep near known FAB frame
hit=0
for y in $(seq 2140 20 2280); do
for x in $(seq 900 20 1030); do
"$ADB" shell input tap "$x" "$y"
sleep 0.05
if "$ADB" logcat -d -s OverlayView:D | grep -q 'floating button clicked'; then
hit=1
break 2
fi
done
done
[[ "$hit" = 1 ]] || die "FAB not clickable — is Circle Overlay ON?"
fi
pass "enter circle mode"
# Closed loop around fixtures email subject region
python3 - <<'PY'
import math, subprocess, time
adb = lambda *a: subprocess.check_call(["adb", *a])
cx, cy, r = 540, 1720, 160
n = 28
pts = [
(
int(cx + r * math.cos(2 * math.pi * i / n - math.pi / 2)),
int(cy + r * math.sin(2 * math.pi * i / n - math.pi / 2)),
)
for i in range(n + 1)
]
adb("shell", "input", "motionevent", "DOWN", str(pts[0][0]), str(pts[0][1]))
for x, y in pts[1:]:
adb("shell", "input", "motionevent", "MOVE", str(x), str(y))
adb("shell", "input", "motionevent", "UP", str(pts[-1][0]), str(pts[-1][1]))
for _ in range(25):
time.sleep(0.3)
out = subprocess.check_output(["adb", "logcat", "-d"], text=True)
if "stub path" in out or "Selection ready" in out:
break
PY
"$ADB" logcat -d | grep -q 'Selection ready' || die "no selection"
"$ADB" logcat -d | grep -qE 'extract:.*chars=[1-9]' || die "no text extracted"
pass "circle → extract"
"$ADB" logcat -c
# Copy is second VIP chip (~x=320, y=2185 on this skin)
"$ADB" shell input tap 320 2185
sleep 1
"$ADB" logcat -d | grep -q 'action kind=COPY' || die "Copy chip not triggered"
"$ADB" logcat -d | grep -q 'action result ok=true' || die "Copy failed"
pass "Copy chip"
echo "== smoke PASSED =="
echo "Tip: full dogfood is docs/dogfood.md (Translate / Explain / Share / Vikunja / real app)."
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Start CrklEmulator via launchd so qemu survives Cursor/agent sandboxes on macOS.
set -euo pipefail
BREW_PREFIX="$(brew --prefix 2>/dev/null || echo /opt/homebrew)"
ANDROID_HOME="${ANDROID_HOME:-$BREW_PREFIX/share/android-commandlinetools}"
AVD_NAME="${AVD_NAME:-CrklEmulator}"
GPU="${EMULATOR_GPU:-host}"
LABEL=com.crkl.emulator
PLIST="/tmp/${LABEL}.plist"
UID_NUM="$(id -u)"
pkill -f 'qemu-system' >/dev/null 2>&1 || true
launchctl bootout "gui/${UID_NUM}/${LABEL}" >/dev/null 2>&1 || true
sleep 1
: >/tmp/crkl-emulator.log
cat >"$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>${LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${ANDROID_HOME}/emulator/emulator</string>
<string>-avd</string><string>${AVD_NAME}</string>
<string>-memory</string><string>3072</string>
<string>-cores</string><string>4</string>
<string>-no-audio</string>
<string>-gpu</string><string>${GPU}</string>
<string>-accel</string><string>on</string>
<string>-no-snapshot-load</string>
<string>-grpc</string><string>8554</string>
<string>-no-metrics</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>ANDROID_HOME</key><string>${ANDROID_HOME}</string>
<key>PATH</key>
<string>${ANDROID_HOME}/emulator:${ANDROID_HOME}/platform-tools:/usr/bin:/bin</string>
</dict>
<key>StandardOutPath</key><string>/tmp/crkl-emulator.log</string>
<key>StandardErrorPath</key><string>/tmp/crkl-emulator.log</string>
<key>RunAtLoad</key><true/>
</dict>
</plist>
EOF
launchctl bootstrap "gui/${UID_NUM}" "$PLIST"
launchctl kickstart -k "gui/${UID_NUM}/${LABEL}"
echo "Emulator launchd job ${LABEL} started (log: /tmp/crkl-emulator.log)"
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# One-shot Crkl testing environment — must leave you with a working blue C overlay.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
UNAME_S="$(uname -s)"
if [[ "$UNAME_S" == "Darwin" ]]; then
BREW_PREFIX="$(brew --prefix 2>/dev/null || echo /opt/homebrew)"
export JAVA_HOME="${JAVA_HOME:-$BREW_PREFIX/opt/openjdk@17}"
export ANDROID_HOME="${ANDROID_HOME:-$BREW_PREFIX/share/android-commandlinetools}"
else
export ANDROID_HOME="${ANDROID_HOME:-$HOME/android-sdk}"
fi
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"
export ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL="${ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL:-5}"
ADB="$ANDROID_HOME/platform-tools/adb"
EMU="$ANDROID_HOME/emulator/emulator"
AVD_NAME="${AVD_NAME:-CrklEmulator}"
GPU="${EMULATOR_GPU:-host}"
COMP="com.example.crkl/com.example.crkl.accessibility.CrklAccessibilityService"
LOG="${CRKL_EMU_LOG:-/tmp/crkl-emulator.log}"
echo "== Crkl test-env =="
if [[ ! -x "$ADB" || ! -x "$EMU" ]]; then
echo "Missing Android SDK tools. Run: make setup-mac"
exit 1
fi
ensure_emulator() {
if "$ADB" devices 2>/dev/null | awk '/emulator-.*device/{exit 0} END{exit 1}'; then
echo "Emulator already up"
return 0
fi
echo "Starting emulator (open gRPC)..."
"$ADB" emu kill >/dev/null 2>&1 || true
pkill -f 'qemu-system' >/dev/null 2>&1 || true
sleep 2
rm -f "$HOME/.android/avd/${AVD_NAME}.avd"/*.lock 2>/dev/null || true
if [[ "$UNAME_S" == "Darwin" ]]; then
chmod +x "$ROOT/scripts/start-emulator-launchd.sh"
bash "$ROOT/scripts/start-emulator-launchd.sh"
else
nohup "$EMU" -avd "$AVD_NAME" -memory 3072 -cores 4 -no-audio \
-gpu "$GPU" -accel on -no-snapshot-load -no-metrics \
-grpc 8554 \
>"$LOG" 2>&1 &
echo $! > /tmp/crkl-emulator.pid
fi
bash "$ROOT/scripts/wait-emulator.sh" "$ADB"
}
enable_on_screen_nav() {
# Prefer nav bar ON THE PHONE (rim chrome is often dead on macOS QT).
"$ADB" root >/dev/null 2>&1 || true
sleep 1
"$ADB" wait-for-device
"$ADB" shell settings put secure navigation_mode 0 || true
"$ADB" shell cmd overlay enable com.android.internal.systemui.navbar.threebutton >/dev/null 2>&1 || true
"$ADB" shell settings put global force_fsg_nav_bar 0 >/dev/null 2>&1 || true
}
enable_crkl() {
"$ADB" shell pm grant com.example.crkl android.permission.RECORD_AUDIO || true
# Disable then enable — avoid empty-string settings (causes "Bad arguments" on some images).
"$ADB" shell settings put secure accessibility_enabled 0 || true
sleep 1
"$ADB" shell settings put secure enabled_accessibility_services "$COMP"
"$ADB" shell settings put secure accessibility_enabled 1
sleep 2
}
verify_overlay() {
local ok=0
for _ in $(seq 1 15); do
if "$ADB" logcat -d -s CrklAccessibilityService:D 2>/dev/null | tail -20 | grep -q "Floating button created"; then
ok=1
break
fi
sleep 1
done
if [[ "$ok" -ne 1 ]]; then
echo "WARNING: did not see 'Floating button created' in logcat yet — check Accessibility in Settings."
else
echo "✓ Crkl overlay service connected (blue C should be visible)"
fi
echo "a11y=$("$ADB" shell settings get secure enabled_accessibility_services | tr -d '\r')"
}
ensure_emulator
echo "== build + install =="
./gradlew assembleDebug --console=plain -q
"$ADB" install -r app/build/outputs/apk/debug/app-debug.apk
echo "== on-screen nav + Crkl permissions =="
enable_on_screen_nav
enable_crkl
echo "== open fixtures =="
"$ADB" shell am force-stop com.example.crkl >/dev/null 2>&1 || true
"$ADB" shell am start -n com.example.crkl/.fixtures.TestFixturesActivity >/dev/null
sleep 1
# Re-assert a11y after force-stop (some builds drop it)
enable_crkl
verify_overlay
"$ADB" shell am start -n com.example.crkl/.fixtures.TestFixturesActivity >/dev/null
echo ""
echo "✓ Ready. Look at the emulator phone screen:"
echo " • Blue floating C (bottom-right) = Crkl"
echo " • Bottom on-screen ◀ ○ □ = Android nav (use these; rim chrome often dead)"
echo ""
echo "Try now:"
echo " 1. Tap blue C"
echo " 2. Circle the email card"
echo " 3. Tap red EXIT"
echo " 4. For OCR: circle the OCR-HELLO-42 block"
echo " 5. For STT: after a result, tap Speak"
echo ""
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Wait until an Android emulator is fully booted (adb is source of truth).
# Usage: scripts/wait-emulator.sh [adb-path]
set -euo pipefail
ADB="${1:-adb}"
LOG="${CRKL_EMU_LOG:-/tmp/crkl-emulator.log}"
MAX_TRIES="${CRKL_EMU_WAIT_TRIES:-90}"
echo "Waiting for emulator (adb)..."
"$ADB" start-server >/dev/null 2>&1 || true
for ((i = 1; i <= MAX_TRIES; i++)); do
state="$("$ADB" devices 2>/dev/null | awk '/emulator-/{print $2; exit}')"
if [[ "$state" == "device" ]]; then
boot="$("$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true)"
if [[ "$boot" == "1" ]]; then
echo "✓ emulator booted"
exit 0
fi
elif [[ "$state" == "offline" ]]; then
"$ADB" kill-server >/dev/null 2>&1 || true
"$ADB" start-server >/dev/null 2>&1 || true
fi
if ((i % 10 == 0)); then
echo " still waiting… (${i}/${MAX_TRIES}) adb=${state:-none}"
fi
sleep 2
done
echo "Emulator did not boot in time — see $LOG"
tail -50 "$LOG" 2>/dev/null || true
exit 1