diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..82c3c3d
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,9 @@
+# Copy to .env (gitignored) for local notes — do NOT commit secrets.
+# Paste token into the app: Crkl → Integrations.
+
+VIKUNJA_URL=https://todo.levkin.ca
+VIKUNJA_TOKEN=
+VIKUNJA_PROJECT_ID=13
+
+# Optional default mailto recipient (“email me the list”)
+CRKL_EMAIL_TO=
diff --git a/.gitignore b/.gitignore
index 1ec7254..a12f488 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,9 @@ build/
# Local configuration
local.properties
+.env
+.env.*
+!.env.example
# OS files
.DS_Store
@@ -38,5 +41,8 @@ captures/
gradle-*.zip
gradle-*/
-# User-specific
-.android/
\ No newline at end of file
+# Models (pushed to device; never commit weights)
+*.task
+*.litertlm
+*.gguf
+models/
diff --git a/.notes/directory_structure.md b/.notes/directory_structure.md
index e4f015b..c8434d4 100644
--- a/.notes/directory_structure.md
+++ b/.notes/directory_structure.md
@@ -1,32 +1,29 @@
# Directory Structure
/app/src/main/kotlin/com/example/crkl/
- /accessibility/ # Accessibility overlay and services
- # - CrklAccessibilityService.kt (main service)
- # - OverlayView.kt (touch capture overlay)
- /gesture/ # Gesture tracking, region extraction (TODO)
- /model/ # STT/LLM wrappers, inference runners (TODO)
- /vision/ # Content classification and ML components (TODO)
- /agent/ # Dialogue state management (TODO)
- /ui/ # Jetpack Compose overlays and feedback UIs
- # - theme/ (Theme.kt, Type.kt)
- /privacy/ # Data/cache handling, controls (TODO)
- MainActivity.kt # Main activity for settings/permissions
-
-/app/src/main/res/
- /values/ # strings.xml, colors.xml, themes.xml
- /xml/ # accessibility_service_config.xml
- /mipmap-*/ # App launcher icons
-
-/app/src/test/ # Unit tests (TODO)
-/app/src/androidTest/ # Android instrumentation tests (TODO)
-
-/tests/ # Additional test utilities
-
-Root config files:
- - build.gradle.kts (root)
- - settings.gradle.kts
- - gradle.properties
- - local.properties
- - app/build.gradle.kts
+ /accessibility/
+ - CrklAccessibilityService.kt # overlay + selection + assist pipeline
+ - OverlayView.kt # FAB ↔ full-screen draw overlay
+ /vision/
+ - RegionContentExtractor.kt # a11y node text in selection bounds
+ /model/
+ - LocalLlm.kt # LLM interface
+ - MediaPipeLocalLlm.kt # MediaPipe tasks-genai backend
+ - ModelPaths.kt # on-device .task discovery
+ /agent/
+ - AssistEngine.kt # LLM or stub routing
+ - LocalAssistStub.kt # no-model fallback formatter
+ /gesture/ # (empty) richer gesture recognition later
+ /ui/
+ - ResultPanelView.kt # selection result card overlay
+ - theme/
+ /privacy/ # (empty)
+ /fixtures/
+ - FixtureCatalog.kt # email/image/video/audio test targets
+ - TestFixturesActivity.kt # scrollable playground for circle demos
+ MainActivity.kt
+/app/src/test/java/com/example/crkl/
+ /agent/AssistEngineTest.kt
+ /agent/LocalAssistStubTest.kt
+ /model/ModelPathsTest.kt
diff --git a/.notes/meeting_notes.md b/.notes/meeting_notes.md
index 5f6e035..6679aed 100644
--- a/.notes/meeting_notes.md
+++ b/.notes/meeting_notes.md
@@ -1,5 +1,37 @@
# Meeting Notes
+## 2026-07-28 - MediaPipe LLM path
+
+**Decision:** one backend only — MediaPipe `tasks-genai` + Gemma 3 1B `.task`.
+No STT/OCR/dialogue yet.
+
+**Shipped:**
+- `LocalLlm` + `MediaPipeLocalLlm`
+- `AssistEngine` (LLM → stub fallback with `make push-model` hint)
+- Model discovery at `/data/local/tmp/llm/crkl.task`
+- Makefile `push-model` / `model-status`
+- Warm-load on accessibility service connect
+
+**Ops note:** model weights stay off-git; push to device. High-end phones only for reliable inference.
+
+---
+
+## 2026-07-28 - Vertical slice: extract text
+
+**Unstuck decision:** stop boiling the ocean (STT + vision + dialogue + 5 LLM options).
+Ship one path end-to-end first.
+
+**Shipped:**
+- Circle selection reports screen-space bounds
+- `RegionContentExtractor` walks a11y windows/nodes intersecting the circle
+- `ResultPanelView` shows extracted text
+- `LocalAssistStub` formats a local response (explicitly not an LLM yet)
+
+**Still blocked for "real AI":** pick a single on-device LLM integration next.
+Do not start OCR / STT / dialogue memory until that loop works on a phone.
+
+---
+
## 2025-10-15 - Session 4: Testing & Makefile
**Completed:**
diff --git a/.notes/project_overview.md b/.notes/project_overview.md
index 9886220..5c99d9f 100644
--- a/.notes/project_overview.md
+++ b/.notes/project_overview.md
@@ -1,13 +1,16 @@
-# Crkl Project Overview
+# Project overview — Circle (crkl)
-Crkl is an on-device, privacy-first Android agent that allows users to circle or touch any element on the screen and get instant local-AI powered help: summarization, transcription, or suggestions based on the content type. All ML inference and data handling are strictly local.
+**Circle** is an on-device, privacy-first Android assist: circle on-screen text, then Translate / Copy / Explain / Share / Vikunja. Repo and package remain `crkl` / `com.example.crkl`.
+
+Historical notes below may still say “Crkl” as the original project name.
+
+Current working path: circle → accessibility text extract → MediaPipe LLM summary (stub if no `.task` model).
Major Modules:
-- Accessibility service overlay
-- Gesture/region processor
-- Content type detection
-- Local STT/LLM/Vision models
-- Dialogue agent with persistent context
-- Compose overlay UI
-- Privacy/data controls
+- Accessibility service overlay (working)
+- Region text extractor via a11y tree (working)
+- MediaPipe LocalLlm + AssistEngine (working; model pushed separately)
+- Gesture/region processor (basic closed-stroke only)
+- Content type detection / STT / vision OCR / dialogue (not started)
+- Privacy/data controls (not started)
diff --git a/.notes/task_list.md b/.notes/task_list.md
index b1989e3..748a33c 100644
--- a/.notes/task_list.md
+++ b/.notes/task_list.md
@@ -1,12 +1,23 @@
# Task List
-- [ ] Implement Accessibility Overlay Service
-- [ ] Develop Gesture-to-Region Processor
-- [ ] Integrate Content-Type Detector
-- [ ] Set up local Speech-to-Text (Vosk/PocketSphinx/DeepSpeech)
-- [ ] Integrate local LLM module (MLC Chat, SmolChat, etc.)
-- [ ] Build Compose Overlay UI
-- [ ] Implement Dialogue Memory
-- [ ] Build Privacy Controls
-- [ ] Write tests and documentation for all modules
+## Done (shipped through 1.16.2)
+- [x] Accessibility overlay + floating C / QS tile / a11y button
+- [x] Closed-stroke selection → a11y + OCR extract
+- [x] Result bottom sheet: Translate / Copy / Explain / Share / Vikunja
+- [x] MediaPipe on-device LLM path + stub fallback
+- [x] Fixtures VIP demo + dogfood / DEMO docs
+- [x] Circle brand (display name + lasso logo)
+- [x] `make test` + `make smoke` (emulator)
+## Next (keep narrow — post-freeze)
+- [ ] Stream tokens into the result panel (partial updates)
+- [ ] Better gesture quality (ellipse fit, reject scribble, undo)
+- [ ] Tap-to-select nearest text node (no full circle required)
+- [ ] Record 45s demo per `docs/DEMO.md`
+
+## Later
+- [ ] Offline STT (Vosk)
+- [ ] Dialogue memory across selections
+- [ ] Privacy controls / clear cache UI
+- [ ] Migrate MediaPipe → LiteRT-LM when ready
+- [ ] Broader device OEM testing
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b56f390..eb78b06 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,4 +1,7 @@
-# Technical Architecture
+# Technical Architecture — Circle (crkl)
+
+Product: circle on-screen text → on-device assist → Translate / Copy / Explain / Share / Vikunja.
+Code lives under `com.example.crkl`; user-facing name is **Circle**.
## Layered System Modules
@@ -76,7 +79,7 @@ Local-only data handling, cache and session controls.
- Session cache controls
- Privacy settings
- Data retention policies
-- No network call enforcement
+- Optional outbound integrations only when configured: Vikunja HTTPS (user token), mailto intent, device CalendarContract. Core circle → OCR → LLM stays on-device. No host bridges.
## Dataflow Diagram
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..ae682aa
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,52 @@
+# Changelog
+
+## 1.16.2-circle-copy — 2026-08-05
+
+- More user-facing strings say **Circle** (panel, empty state, Integrations label, Vikunja hint)
+- Docs pass: README, PHONE_SETUP, shortcuts, dogfood, marketing, RELEASE, docs/README index
+- `docs/RELEASE.md` install/tag checklist
+- `make smoke` + macOS launchd emulator helper (agent sandbox was killing qemu)
+- Emulator smoke verified: FAB → circle → extract → Copy
+
+## 1.16.1-lasso — 2026-08-05
+
+- Launcher + QS tile use **logo 2** (teal lasso + ink C on paper)
+
+## 1.16.0-circle-brand — 2026-08-05
+
+- Display name **Circle**; Accessibility service **Circle Overlay**
+- Launcher adaptive icon: ink + teal ring + white C (logo concept 1)
+- Brand colors aligned (teal primary, ink background)
+
+## 1.15.0-ship — 2026-08-05
+
+Ship freeze candidate for the Circle Assist VIP loop.
+
+### Week 1 (usability)
+- Clean result panel (debug meta optional)
+- Accessibility-off banner; honest empty / image-only copy
+- Ink + teal flow UI; bottom sheet; five chips
+- QS **Circle** tile + Accessibility button shortcuts
+
+### Week 1 (finish)
+- Fixtures VIP demo script (`FixtureCatalog.demoScript`, grocery list fixture)
+- Integrations: Vikunja + Translate first; appearance / advanced collapsed
+
+### Week 2 (ship story)
+- `docs/DEMO.md`, `docs/dogfood.md`, `docs/non-goals.md`, `docs/marketing.md`
+- Three logo concepts generated for brand pick
+
+### Not in this release
+See `docs/non-goals.md` (Lens, screenshot SystemUI, gog-required, calendar create, …).
+
+## 1.14.0-shortcuts
+Quick Settings tile, Accessibility button, shortcuts docs.
+
+## 1.13.0-flow
+Seamless overlay + bottom sheet UI.
+
+## 1.12.0-usability
+A11y banner, hide debug meta, empty states.
+
+## 1.11.0-circle-assist
+Copy, Explain, Share polish, onboarding.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3972aff..a40a417 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,33 +1,30 @@
# Contribution Guide
-Thank you for your interest in contributing to Crkl! This document provides guidelines and information for contributors.
+Thank you for contributing to **Circle** (repo `crkl`).
## Getting Started
### Prerequisites
- Android Studio (latest stable version)
-- JDK 11 or higher
+- JDK 17 (see Makefile / Homebrew `openjdk@17`)
- Git
-- Basic knowledge of Kotlin and Android development
-- Familiarity with Jetpack Compose
+- Kotlin + Jetpack Compose familiarity
### Development Setup
1. **Clone the repository:**
```bash
- git clone https://github.com/yourusername/crkl.git
+ git clone https://git.levkin.ca/ilia/crkl.git
cd crkl
```
-2. **Open in Android Studio:**
- - Open Android Studio
- - Select "Open an Existing Project"
- - Navigate to the cloned directory
+2. **Open in Android Studio** (or use `./gradlew` + Makefile).
-3. **Review project documentation:**
- - Read `README.md` for project overview
- - Review `ARCHITECTURE.md` for technical details
+3. **Review documentation:**
+ - [README.md](README.md) — overview
+ - [docs/](docs/) — ship, shortcuts, brand
+ - [ARCHITECTURE.md](ARCHITECTURE.md) — technical layers
- Check `.notes/` directory for current project context
- Review `CURSOR_SUPPORT.md` if using Cursor editor
@@ -197,5 +194,5 @@ Contributors will be recognized in:
- Release notes
- Project documentation
-Thank you for contributing to Crkl!
+Thank you for contributing to Circle!
diff --git a/CURSOR_SUPPORT.md b/CURSOR_SUPPORT.md
index 9359506..c4c60d6 100644
--- a/CURSOR_SUPPORT.md
+++ b/CURSOR_SUPPORT.md
@@ -166,9 +166,10 @@ Before submitting any code:
## PROJECT STATUS
-**Current Phase:** Initial Setup
-**Next Steps:** Begin Milestone 1 POC implementation
-**Priority:** Accessibility Service overlay and gesture recognition
+**Current Phase:** Milestone 1 complete enough to demo (gesture → text → local LLM/stub)
+**Working:** Overlay, selection, a11y extract, MediaPipe LLM when model present, stub fallback
+**Next Steps:** Token streaming in result panel; optional tap-to-select; first-run model setup UI
+**Priority:** Validate on a real phone with `make push-model` before adding OCR/STT
## USEFUL REFERENCES
diff --git a/Makefile b/Makefile
index ca4f4f2..ce1f826 100644
--- a/Makefile
+++ b/Makefile
@@ -1,128 +1,228 @@
-# Crkl - Physical Phone Development
-.PHONY: help setup-sdk build install run stop logs clean uninstall devices check-device
+# Crkl - local Android build / test / emulator
+.PHONY: help doctor setup-mac setup-sdk setup-emulator build test install run stop logs clean uninstall \
+ devices check-device push-model model-status emulator emulator-stop emulator-list wait-emulator demo \
+ fixtures home back recents test-env integrations gog-bridge smoke
-ANDROID_HOME ?= $(HOME)/android-sdk
-ADB := $(ANDROID_HOME)/platform-tools/adb
+UNAME_S := $(shell uname -s)
GRADLEW := ./gradlew
APK := app/build/outputs/apk/debug/app-debug.apk
+MODEL_DEVICE_PATH := /data/local/tmp/llm/crkl.task
+MODEL ?=
+AVD_NAME ?= CrklEmulator
+
+ifeq ($(UNAME_S),Darwin)
+ BREW_PREFIX := $(shell brew --prefix 2>/dev/null || echo /opt/homebrew)
+ JAVA_HOME ?= $(BREW_PREFIX)/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
+ ifeq ($(wildcard $(JAVA_HOME)/bin/java),)
+ JAVA_HOME := $(BREW_PREFIX)/opt/openjdk@17
+ endif
+ ANDROID_HOME ?= $(BREW_PREFIX)/share/android-commandlinetools
+ AVD_ABI := google_apis;arm64-v8a
+ EMULATOR_GPU ?= host
+else
+ JAVA_HOME ?= $(JAVA_HOME)
+ ANDROID_HOME ?= $(HOME)/android-sdk
+ AVD_ABI := google_apis;x86_64
+ EMULATOR_GPU ?= swiftshader_indirect
+endif
+
+export JAVA_HOME
+export ANDROID_HOME
+export ANDROID_SDK_ROOT := $(ANDROID_HOME)
+
+ADB := $(ANDROID_HOME)/platform-tools/adb
+EMULATOR := $(ANDROID_HOME)/emulator/emulator
+SDKMANAGER := $(shell \
+ if [ -x "$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager" ]; then \
+ echo "$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager"; \
+ elif command -v sdkmanager >/dev/null 2>&1; then command -v sdkmanager; \
+ else echo sdkmanager; fi)
+AVDMANAGER := $(shell \
+ if [ -x "$(ANDROID_HOME)/cmdline-tools/latest/bin/avdmanager" ]; then \
+ echo "$(ANDROID_HOME)/cmdline-tools/latest/bin/avdmanager"; \
+ elif command -v avdmanager >/dev/null 2>&1; then command -v avdmanager; \
+ else echo avdmanager; fi)
+
+PATH := $(JAVA_HOME)/bin:$(ANDROID_HOME)/platform-tools:$(ANDROID_HOME)/emulator:$(ANDROID_HOME)/cmdline-tools/latest/bin:$(PATH)
+export PATH
help: ## Show available commands
- @echo "Crkl - Android Development Commands"
+ @echo "Crkl - Android Development Commands ($(UNAME_S))"
@echo ""
- @echo "Setup Commands:"
- @echo " make setup-sdk - Install Android SDK and tools"
- @echo " make setup-emulator - Create and start Android emulator"
- @echo " make check-device - Check if device/emulator is connected"
- @echo " make devices - List connected devices/emulators"
+ @echo "First-time on this Mac:"
+ @echo " make setup-mac - brew JDK17 + Android SDK + AVD + local.properties"
+ @echo " make doctor - verify toolchain"
+ @echo " make test - run JVM unit tests (no phone needed)"
+ @echo " make smoke - on-device: FAB → circle → extract → Copy"
+ @echo " make test-env - ONE SHOT: fix chrome, install, open fixtures playground"
+ @echo " make demo - build + boot emulator + install + launch"
+ @echo " make fixtures - open email/image/video/audio test screen"
+ @echo " make integrations - Vikunja token + email To + calendar permission"
@echo ""
- @echo "Emulator Commands:"
- @echo " make emulator - Start Android emulator"
- @echo " make emulator-stop - Stop emulator"
- @echo " make emulator-list - List available emulators"
+ @echo "Emulator chrome ◀○□ broken? test-env restarts with -grpc (fixes JWT lock)."
+ @echo " Always click the phone screen once before using rim buttons."
@echo ""
- @echo "Development Commands:"
- @echo " make build - Build APK"
- @echo " make install - Install to device/emulator"
- @echo " make run - Launch app"
- @echo " make logs - Watch app logs"
- @echo " make stop - Stop app"
- @echo " make clean - Clean build"
- @echo " make uninstall - Remove app"
+ @echo "Everyday:"
+ @echo " make build / test / install / run / logs / stop"
+ @echo " make emulator / emulator-stop / check-device"
+ @echo " make push-model MODEL=/path/to/model.task"
+ @echo ""
+ @echo "ANDROID_HOME=$(ANDROID_HOME)"
+ @echo "JAVA_HOME=$(JAVA_HOME)"
@echo ""
-setup-sdk: ## Install Android SDK and tools
- @echo "Setting up Android SDK..."
- @mkdir -p $(ANDROID_HOME)
- @cd $(ANDROID_HOME) && \
- if [ ! -f cmdline-tools/latest/bin/sdkmanager ]; then \
- echo "Downloading Android SDK command-line tools..."; \
- wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip; \
- unzip -q commandlinetools-linux-11076708_latest.zip; \
- mkdir -p cmdline-tools/latest; \
- mv cmdline-tools/* cmdline-tools/latest/ 2>/dev/null || true; \
- rm commandlinetools-linux-11076708_latest.zip; \
- fi
- @echo "Accepting SDK licenses..."
- @export ANDROID_HOME=$(ANDROID_HOME) && \
- export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH && \
- yes | sdkmanager --licenses > /dev/null 2>&1
- @echo "Installing platform tools and build tools..."
- @export ANDROID_HOME=$(ANDROID_HOME) && \
- export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH && \
- sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" > /dev/null 2>&1
- @echo "✓ Android SDK setup complete!"
- @echo "Add to your ~/.bashrc or ~/.zshrc:"
- @echo " export ANDROID_HOME=$(ANDROID_HOME)"
- @echo " export PATH=\$$ANDROID_HOME/platform-tools:\$$PATH"
+doctor: ## Verify JDK + Android SDK are usable here
+ @echo "== Java =="
+ @java -version
+ @echo "== ANDROID_HOME =="
+ @echo "$(ANDROID_HOME)"
+ @test -d "$(ANDROID_HOME)/platforms/android-34" || (echo "Missing platforms;android-34 — run make setup-mac" && exit 1)
+ @test -x "$(ADB)" || (echo "Missing adb — run make setup-mac" && exit 1)
+ @$(ADB) version | head -1
+ @echo "== local.properties =="
+ @test -f local.properties && cat local.properties || (echo "missing local.properties — run make setup-mac" && exit 1)
+ @echo "== AVD =="
+ @$(AVDMANAGER) list avd 2>/dev/null | sed -n '1,20p' || true
+ @echo "✓ doctor OK"
-check-device: ## Check if device is connected
- @echo "Checking for connected devices..."
- @export ANDROID_HOME=$(ANDROID_HOME) && \
- export PATH=$(ANDROID_HOME)/platform-tools:$$PATH && \
- adb devices
+setup-mac: ## Install toolchain on macOS (Homebrew)
+ @test "$(UNAME_S)" = "Darwin" || (echo "setup-mac is for macOS only; use setup-sdk on Linux" && exit 1)
+ @echo "Installing OpenJDK 17 + Android command-line tools via Homebrew..."
+ @brew list openjdk@17 >/dev/null 2>&1 || brew install openjdk@17
+ @brew list --cask android-commandlinetools >/dev/null 2>&1 || brew install --cask android-commandlinetools
+ @$(MAKE) setup-sdk
+ @$(MAKE) setup-emulator
+ @$(MAKE) doctor
-devices: check-device ## List connected devices (alias for check-device)
+setup-sdk: ## Install Android SDK packages + write local.properties
+ @echo "Accepting SDK licenses + installing build packages..."
+ @yes | $(SDKMANAGER) --licenses >/dev/null 2>&1 || true
+ @$(SDKMANAGER) \
+ "platform-tools" \
+ "platforms;android-34" \
+ "build-tools;34.0.0" \
+ "emulator" \
+ "system-images;android-34;$(AVD_ABI)"
+ @printf 'sdk.dir=%s\n' "$(ANDROID_HOME)" > local.properties
+ @echo "✓ wrote local.properties"
+ @echo "✓ Android SDK ready at $(ANDROID_HOME)"
-setup-emulator: ## Create and start Android emulator
- @echo "Setting up Android emulator..."
- @export ANDROID_HOME=$(ANDROID_HOME) && \
- export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH && \
- sdkmanager "system-images;android-34;google_apis;x86_64" > /dev/null 2>&1
- @export ANDROID_HOME=$(ANDROID_HOME) && \
- export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH && \
- avdmanager create avd -n "CrklEmulator" -k "system-images;android-34;google_apis;x86_64" -d "pixel_7" --force
- @echo "✓ Emulator created. Run 'make emulator' to start it."
+setup-emulator: ## Create CrklEmulator AVD
+ @echo "Creating AVD $(AVD_NAME) ($(AVD_ABI))..."
+ @echo no | $(AVDMANAGER) create avd -n "$(AVD_NAME)" \
+ -k "system-images;android-34;$(AVD_ABI)" \
+ -d "pixel_7" --force || true
+ @echo "✓ AVD ready. Start with: make emulator"
-emulator: ## Start Android emulator
- @echo "Starting Android emulator with 4GB RAM..."
- @export ANDROID_HOME=$(ANDROID_HOME) && \
- export PATH=$(ANDROID_HOME)/emulator:$(ANDROID_HOME)/platform-tools:$$PATH && \
- emulator -avd CrklEmulator -memory 4096 -cores 4 -no-audio -gpu swiftshader_indirect -no-metrics &
+check-device: ## List connected devices/emulators
+ @$(ADB) devices -l
+
+devices: check-device
+
+emulator: ## Start CrklEmulator (open gRPC so rim ◀○□ chrome works)
+ @if $(ADB) devices 2>/dev/null | awk '/emulator-/{exit 0} END{exit 1}'; then \
+ echo "Emulator already connected:"; $(ADB) devices -l; \
+ echo "Tip: if rim buttons dead, run: make test-env (restarts with -grpc)"; \
+ else \
+ echo "Starting $(AVD_NAME) (gpu=$(EMULATOR_GPU), grpc open)..."; \
+ if [ "$(UNAME_S)" = "Darwin" ]; then \
+ chmod +x scripts/start-emulator-launchd.sh; \
+ bash scripts/start-emulator-launchd.sh; \
+ else \
+ nohup $(EMULATOR) -avd "$(AVD_NAME)" -memory 3072 -cores 4 -no-audio \
+ -gpu $(EMULATOR_GPU) -accel on -no-snapshot-load -no-metrics \
+ -grpc 8554 \
+ >/tmp/crkl-emulator.log 2>&1 & echo $$! > /tmp/crkl-emulator.pid; \
+ echo "Emulator pid $$(cat /tmp/crkl-emulator.pid) (log: /tmp/crkl-emulator.log)"; \
+ fi; \
+ fi
+ @echo "Next: make wait-emulator && make install run OR make test-env"
+
+wait-emulator: ## Block until emulator is fully booted
+ @bash scripts/wait-emulator.sh "$(ADB)"
emulator-stop: ## Stop emulator
- @echo "Stopping emulator..."
- @export ANDROID_HOME=$(ANDROID_HOME) && \
- export PATH=$(ANDROID_HOME)/platform-tools:$$PATH && \
- adb emu kill
+ @$(ADB) emu kill 2>/dev/null || true
+ @pkill -f 'qemu-system' 2>/dev/null || true
+ @rm -f /tmp/crkl-emulator.pid
+ @echo "✓ emulator stop requested"
-emulator-list: ## List available emulators
- @echo "Available emulators:"
- @export ANDROID_HOME=$(ANDROID_HOME) && \
- export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH && \
- avdmanager list avd
+emulator-list: ## List AVDs
+ @$(AVDMANAGER) list avd
-build: ## Build APK
+test: ## Run JVM unit tests
+ @echo "Running unit tests..."
+ @$(GRADLEW) test --console=plain
+
+smoke: ## On-device smoke (FAB → circle → extract → Copy). Needs adb device.
+ @chmod +x scripts/smoke-circle.sh
+ @ADB="$(ADB)" bash scripts/smoke-circle.sh
+
+build: ## Build debug APK
@echo "Building Crkl..."
- @$(GRADLEW) assembleDebug
+ @$(GRADLEW) assembleDebug --console=plain
-install: build ## Install to phone
- @echo "Installing to phone..."
- @export ANDROID_HOME=$(ANDROID_HOME) && \
- export PATH=$(ANDROID_HOME)/platform-tools:$$PATH && \
- $(ADB) install -r $(APK)
+install: build ## Install APK to connected device/emulator
+ @$(ADB) wait-for-device
+ @$(ADB) install -r $(APK)
@echo "✓ Installed"
-run: ## Launch app
- @echo "Launching Crkl..."
- @export ANDROID_HOME=$(ANDROID_HOME) && \
- export PATH=$(ANDROID_HOME)/platform-tools:$$PATH && \
- $(ADB) shell am start -n com.example.crkl/.MainActivity
+run: ## Launch MainActivity
+ @$(ADB) shell am start -n com.example.crkl/.MainActivity
-logs: ## Watch app logs
- @echo "Watching logs (Ctrl+C to stop)..."
- @export ANDROID_HOME=$(ANDROID_HOME) && \
- export PATH=$(ANDROID_HOME)/platform-tools:$$PATH && \
- $(ADB) logcat -s CrklAccessibilityService:D OverlayView:D AndroidRuntime:E
+fixtures: ## Open test fixtures (email/image/video/audio) on device/emulator
+ @$(ADB) wait-for-device
+ @$(ADB) shell am start -n com.example.crkl/.fixtures.TestFixturesActivity
+ @echo "✓ Fixtures open. Tap blue C on the phone (not rim ○)."
-stop: ## Stop app
- @export ANDROID_HOME=$(ANDROID_HOME) && \
- export PATH=$(ANDROID_HOME)/platform-tools:$$PATH && \
- $(ADB) shell am force-stop com.example.crkl
+test-env: ## ONE SHOT testing playground (chrome fix + install + fixtures)
+ @chmod +x scripts/test-env.sh
+ @bash scripts/test-env.sh
-clean: ## Clean build
+integrations: ## Open Integrations settings on device/emulator
+ @$(ADB) wait-for-device
+ @$(ADB) shell am start -n com.example.crkl/.IntegrationsActivity
+
+gog-bridge: ## Optional lab: host gog → Gmail (emu uses http://10.0.2.2:8765)
+ @chmod +x scripts/gog-bridge.py
+ @python3 scripts/gog-bridge.py
+
+home: ## Emulator/device Home (adb) — fallback if rim buttons still fail
+ @$(ADB) shell input keyevent KEYCODE_HOME
+
+back: ## Emulator/device Back (adb)
+ @$(ADB) shell input keyevent KEYCODE_BACK
+
+recents: ## Emulator/device Recents (adb)
+ @$(ADB) shell input keyevent KEYCODE_APP_SWITCH
+
+demo: build emulator wait-emulator install run ## Full local demo on emulator
+ @echo "✓ demo launched. Enable Accessibility, then: make fixtures"
+ @echo " Nav if side buttons fail: make home | make back | make recents"
+ @echo " make logs"
+
+logs: ## Watch Crkl logcat
+ @$(ADB) logcat -s CrklAccessibilityService:D OverlayView:D MediaPipeLocalLlm:D AssistEngine:D RegionContentExtractor:D AndroidRuntime:E
+
+stop: ## Force-stop app
+ @$(ADB) shell am force-stop com.example.crkl
+
+clean: ## Clean Gradle build
@$(GRADLEW) clean
-uninstall: ## Remove app
- @export ANDROID_HOME=$(ANDROID_HOME) && \
- export PATH=$(ANDROID_HOME)/platform-tools:$$PATH && \
- $(ADB) uninstall com.example.crkl
+uninstall: ## Uninstall app
+ @$(ADB) uninstall com.example.crkl
+
+push-model: check-device ## Push MediaPipe .task model
+ @if [ -z "$(MODEL)" ]; then \
+ echo "Usage: make push-model MODEL=/path/to/gemma-3-1b-it-int4.task"; \
+ exit 1; \
+ fi
+ @test -f "$(MODEL)" || (echo "Model file not found: $(MODEL)" && exit 1)
+ @echo "Pushing $(MODEL) → $(MODEL_DEVICE_PATH)"
+ @$(ADB) shell mkdir -p /data/local/tmp/llm
+ @$(ADB) push "$(MODEL)" $(MODEL_DEVICE_PATH)
+ @echo "✓ Model on device. Toggle Crkl accessibility service to reload."
+
+model-status: check-device ## Show on-device model file
+ @$(ADB) shell "ls -lh $(MODEL_DEVICE_PATH) /data/local/tmp/llm/ 2>/dev/null || echo 'No model at $(MODEL_DEVICE_PATH)'"
diff --git a/PHONE_SETUP.md b/PHONE_SETUP.md
index 33f5764..aef2d7a 100644
--- a/PHONE_SETUP.md
+++ b/PHONE_SETUP.md
@@ -1,143 +1,122 @@
-# Physical Phone Setup Guide
+# Physical Phone Setup Guide — Circle
## Prerequisites
-1. **Android phone** (Android 8.1 or higher)
+1. **Android phone** (API 27+ / Android 8.1+)
2. **USB cable**
-3. **Android SDK installed** on your Linux machine
+3. **Android SDK** on your Mac/Linux machine
---
-## Step 1: Enable Developer Options on Phone
+## Step 1: Enable Developer Options
-1. Open **Settings** on your phone
-2. Go to **About Phone**
-3. Tap **Build Number** 7 times
-4. You'll see "You are now a developer!"
+1. Settings → **About Phone**
+2. Tap **Build Number** 7 times
---
## Step 2: Enable USB Debugging
-1. Go back to **Settings**
-2. Open **Developer Options** (or **System → Developer Options**)
-3. Enable **USB Debugging**
-4. (Optional) Enable **Stay Awake** - keeps screen on while charging
+1. Settings → **Developer Options**
+2. Enable **USB Debugging**
+3. (Optional) **Stay Awake** while charging
---
-## Step 3: Connect Phone to Computer
+## Step 3: Connect Phone
-1. Plug phone into computer via USB
-2. On your phone, a popup will appear: **"Allow USB debugging?"**
-3. Tap **Allow** (check "Always allow from this computer")
+1. Plug in USB
+2. Allow USB debugging on the phone
---
## Step 4: Verify Connection
```bash
-cd /home/user/Documents/code/crkl
-export ANDROID_HOME=~/android-sdk
-export PATH=$ANDROID_HOME/platform-tools:$PATH
-
-# Check if phone is detected
adb devices
```
-You should see:
-```
-List of devices attached
-ABC123XYZ device
-```
+You should see `device` (not `unauthorized`).
---
-## Step 5: Build and Install Crkl
+## Step 5: Build and Install Circle
```bash
-# Build the app
-make build
-
-# Install to your phone
-make install
+cd ~/Documents/code/crkl
+./gradlew assembleDebug
+adb install -r app/build/outputs/apk/debug/app-debug.apk
+adb shell am start -n com.example.crkl/.MainActivity
+# or: make build && make install && make run
```
---
-## Step 6: Enable Accessibility Service
+## Step 6: Enable Circle Overlay
-### On Your Phone:
+1. Open the **Circle** app
+2. Tap **Enable Accessibility**
+3. Turn on **Circle Overlay**
+4. Accept the permission dialog
-1. **Open the Crkl app** (will show welcome screen)
-2. **Tap "Open Accessibility Settings"** button
-3. In Settings, find **"Crkl Overlay Service"**
-4. **Toggle it ON**
-5. Accept the permission dialog
-6. **Press back button** to return to home screen
+Re-enable after every reinstall.
---
## Step 7: Test It
-The overlay is now active system-wide!
+Tap the floating **C** (or QS **Circle**), draw a closed loop around text, use the chips.
-**Watch logs:**
```bash
make logs
+# or: adb logcat -s CrklAccessibilityService:D OverlayView:D AssistEngine:D
```
-**Touch your phone screen** anywhere - you should see:
-```
-OverlayView: Touch down at (X, Y)
-OverlayView: Touch up at (X, Y)
-```
-
-**Visual feedback:**
-- Cyan circles appear where you touch
-- Crosshair shows exact touch point
+VIP path: **Test fixtures** → [docs/DEMO.md](docs/DEMO.md).
---
## Quick Commands
```bash
-make build # Build APK
-make install # Install to phone
-make run # Launch app
-make logs # Watch logs
-make stop # Stop app
-make clean # Clean build
-make uninstall # Remove app
+make build / install / run / logs / stop / clean / uninstall
```
---
## Troubleshooting
-### "adb: command not found"
+### adb not found
```bash
-export ANDROID_HOME=~/android-sdk
+export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools # example
export PATH=$ANDROID_HOME/platform-tools:$PATH
```
-### "no devices/emulators found"
-- Check USB cable is connected
-- Check "USB Debugging" is enabled
-- Run `adb devices` and accept prompt on phone
+### no devices
+- USB debugging on; accept the prompt
+- Emulator flaky? Headless tips in [docs/RELEASE.md](docs/RELEASE.md)
-### "unauthorized"
-- Revoke USB debugging authorizations in Developer Options
-- Disconnect and reconnect USB
-- Accept the new prompt
+### unauthorized
+- Revoke USB debugging authorizations; reconnect
### Can't see overlay
-- Make sure you enabled "Crkl Overlay Service" in Accessibility Settings
-- Restart the app: `make stop && make run`
+- Enable **Circle Overlay** in Accessibility
+- `make stop && make run`
---
-## Done!
+## Shortcuts
-You're ready to develop. The app will now detect touches system-wide on your phone.
+See [docs/shortcuts.md](docs/shortcuts.md).
+1. Enable **Circle Overlay**
+2. Circle app → **Add Quick Settings tile**
+3. Swipe down → **Circle** → closed loop
+
+Optional: Accessibility shortcut → **Circle Overlay** (hold both volume keys).
+
+---
+
+## Done
+
+Enable Accessibility, then use the floating C or the Circle QS tile.
diff --git a/README.md b/README.md
index 359fc3f..fbb7e8a 100644
--- a/README.md
+++ b/README.md
@@ -1,97 +1,94 @@
-# Crkl - On-Device AI Assistant
+# Circle
-Privacy-first Android AI assistant that lets users circle or touch any element on their screen, then calls a fully *on-device* AI engine to transcribe, summarize, explain, or draft responses.
+Privacy-first Android assist: **circle on-screen text → Translate · Copy · Explain · Share · Vikunja**. On-device.
+
+Repo / Gradle module: **crkl** · Application id: `com.example.crkl` · Display name: **Circle**
+
+## Ship docs
+
+- [docs/DEMO.md](docs/DEMO.md) — 45s recording script
+- [docs/dogfood.md](docs/dogfood.md) — daily checklist
+- [docs/non-goals.md](docs/non-goals.md) — freeze list
+- [docs/marketing.md](docs/marketing.md) — blurb + posts
+- [docs/shortcuts.md](docs/shortcuts.md) — QS tile / a11y button / volume keys
+- [docs/RELEASE.md](docs/RELEASE.md) — install & tag
+- [docs/brand/](docs/brand/) — logo (shipped: **lasso #2**)
+- [CHANGELOG.md](CHANGELOG.md)
## Features
-- System-wide accessibility overlay
-- Touch/gesture detection
-- Local AI inference (no cloud calls)
-- Privacy: *No data leaves device*
-## Quick Start
+- **Circle → text → act** (a11y + on-device OCR)
+- Chips: **Translate · Copy · Explain · Share · Vikunja**
+- **Vikunja** HTTPS for “add to todo”
+- Optional mailto / lab gog bridge (Advanced in Integrations)
+- Device calendar read (Advanced)
+- Circle style (color, neon, stroke)
+- First-run onboarding; clean panel (debug meta optional)
+- Shortcuts: Quick Settings **Circle** tile, Accessibility button, volume-key a11y shortcut
+- Local LLM when a `.task` model is present; otherwise stub Explain / summary
+
+## Quick start
-### 1. Setup Development Environment
```bash
-# One-time setup (installs Android SDK and tools)
-./setup.sh
+./setup.sh # one-time SDK (if needed)
+make check-device
+./gradlew assembleDebug
+adb install -r app/build/outputs/apk/debug/app-debug.apk
+adb shell am start -n com.example.crkl/.MainActivity
```
-### 2. Connect Your Android Device
-See [PHONE_SETUP.md](PHONE_SETUP.md) for detailed setup instructions.
+Or: `make build` / `make install` / `make run` when Make targets are available.
+
+See [PHONE_SETUP.md](PHONE_SETUP.md) for USB debugging.
+
+### Enable Circle Overlay
+
+1. Open **Circle**
+2. Tap **Enable Accessibility** (or Accessibility settings)
+3. Turn on **Circle Overlay**
+4. Accept the permission dialog
+ Re-enable after every reinstall.
+
+### Try it
+
+Tap the floating **C**, or Quick Settings → **Circle**, then draw a closed loop around text.
+VIP path is on **Test fixtures** (see [docs/DEMO.md](docs/DEMO.md)).
+
+### Shortcuts
+
+[docs/shortcuts.md](docs/shortcuts.md) — QS tile, Accessibility button, hold both volume keys.
+Double-press power is **not** available to third-party apps.
+
+## Emulator note
+
+If the GUI emulator never appears in `adb devices`, use headless (see [docs/RELEASE.md](docs/RELEASE.md)):
-### 3. Build and Install
```bash
-make check-device # Check if device is connected
-make build # Build APK
-make install # Install to device
-make run # Launch app
+$ANDROID_HOME/emulator/emulator -avd CrklEmulator -no-window \
+ -gpu swiftshader_indirect -no-snapshot-load -grpc 8554 &
+adb wait-for-device
```
-### 3. Enable Accessibility Service
-1. Open Crkl app (shows welcome screen)
-2. Tap "Open Accessibility Settings"
-3. Find "Crkl Overlay Service" and toggle ON
-4. Accept permission dialog
-
-### 4. Test It
-```bash
-make logs # Watch logs
-```
-Touch your phone screen anywhere - you'll see touch detection logs and visual feedback (cyan circles).
-
-## Commands
-
-### Setup Commands
-```bash
-./setup.sh # One-time setup (installs Android SDK)
-make setup-sdk # Install Android SDK and tools
-make check-device # Check if device is connected
-```
-
-### Development Commands
-```bash
-make build # Build APK
-make install # Install to device
-make run # Launch app
-make logs # Watch app logs
-make stop # Stop app
-make clean # Clean build
-make uninstall # Remove app
-```
-
-### Help
-```bash
-make help # Show all available commands
-```
-
-## Project Structure
+## Project structure
```
crkl/
├── app/src/main/kotlin/com/example/crkl/
-│ ├── MainActivity.kt # Main app activity
-│ ├── accessibility/
-│ │ ├── CrklAccessibilityService.kt # System overlay service
-│ │ └── OverlayView.kt # Touch detection view
-│ └── ui/theme/ # App theming
-├── app/src/main/res/ # Resources
-├── Makefile # Build commands
-├── PHONE_SETUP.md # Phone setup guide
-└── README.md # This file
+│ ├── MainActivity.kt
+│ ├── accessibility/ # Overlay, QS tile, bridge
+│ ├── agent/ # VoiceIntent, ActionExecutor, AssistEngine
+│ ├── vision/ # OCR + content capture
+│ ├── integrations/ # Vikunja, translate, calendar
+│ ├── fixtures/ # Test fixtures + demoScript()
+│ └── ui/ # Result panel, theme, CrklUi tokens
+├── docs/ # Ship + brand docs
+└── Makefile
```
-## Development
+## Model (optional)
-The POC is complete with:
-- ✅ System-wide overlay
-- ✅ Touch detection
-- ✅ Visual feedback
-- ✅ Logging
+Inference is MediaPipe on-device (`make push-model`). Without a model, Circle uses a local stub — still no cloud for summaries. OCR is ML Kit on-device.
-Next: Add gesture recognition, content detection, and AI integration.
+## License
-## Requirements
-
-- Android phone (Android 8.1+)
-- Android SDK on Linux
-- USB debugging enabled
\ No newline at end of file
+See repository license file.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 55329ad..c04b737 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -11,13 +11,16 @@ android {
applicationId = "com.example.crkl"
minSdk = 27
targetSdk = 34
- versionCode = 1
- versionName = "1.0.0-poc"
+ versionCode = 19
+ versionName = "1.16.2-circle-copy"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
+ ndk {
+ abiFilters += listOf("arm64-v8a", "armeabi-v7a")
+ }
}
buildTypes {
@@ -34,12 +37,12 @@ android {
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = "1.8"
+ jvmTarget = "17"
}
buildFeatures {
@@ -56,6 +59,10 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
+
+ testOptions {
+ unitTests.isIncludeAndroidResources = true
+ }
}
dependencies {
@@ -73,7 +80,15 @@ dependencies {
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
- // Accessibility (built into core-ktx)
+ // On-device LLM (Gemma via MediaPipe LLM Inference)
+ implementation("com.google.mediapipe:tasks-genai:0.10.27")
+
+ // On-device OCR (ML Kit)
+ implementation("com.google.mlkit:text-recognition:16.0.1")
+
+ // On-device translation + language ID (ML Kit)
+ implementation("com.google.mlkit:translate:17.0.3")
+ implementation("com.google.mlkit:language-id:17.0.6")
// Coroutines for async operations
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
@@ -86,6 +101,8 @@ dependencies {
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
+ testImplementation("org.robolectric:robolectric:4.12.2")
+ testImplementation("androidx.test:core:1.5.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fa52f75..a7abfea 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,15 +2,15 @@
-
-
-
-
-
+
+
+
+
+
-
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
diff --git a/app/src/main/assets/media/grocery_memo.transcript.txt b/app/src/main/assets/media/grocery_memo.transcript.txt
new file mode 100644
index 0000000..3b5f4ac
--- /dev/null
+++ b/app/src/main/assets/media/grocery_memo.transcript.txt
@@ -0,0 +1 @@
+Hi. Voice memo grocery list. We need milk, eggs, sourdough bread, olive oil, and two avocados. Also pick up oat milk and coffee beans. That is everything.
diff --git a/app/src/main/assets/media/grocery_memo.wav b/app/src/main/assets/media/grocery_memo.wav
new file mode 100644
index 0000000..2870d89
Binary files /dev/null and b/app/src/main/assets/media/grocery_memo.wav differ
diff --git a/app/src/main/assets/media/product_walkthrough.mp4 b/app/src/main/assets/media/product_walkthrough.mp4
new file mode 100644
index 0000000..0a7e24a
Binary files /dev/null and b/app/src/main/assets/media/product_walkthrough.mp4 differ
diff --git a/app/src/main/assets/media/product_walkthrough.transcript.txt b/app/src/main/assets/media/product_walkthrough.transcript.txt
new file mode 100644
index 0000000..9a50a1b
--- /dev/null
+++ b/app/src/main/assets/media/product_walkthrough.transcript.txt
@@ -0,0 +1 @@
+Welcome to the product walkthrough. First, open Settings. Next, enable Privacy Mode. Then tap Save. You are done. The whole flow takes under a minute.
diff --git a/app/src/main/kotlin/com/example/crkl/IntegrationsActivity.kt b/app/src/main/kotlin/com/example/crkl/IntegrationsActivity.kt
new file mode 100644
index 0000000..2eb4a4b
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/IntegrationsActivity.kt
@@ -0,0 +1,294 @@
+package com.example.crkl
+
+import android.Manifest
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.example.crkl.integrations.DeviceCalendar
+import com.example.crkl.integrations.GogBridgeClient
+import com.example.crkl.integrations.IntegrationSettings
+import com.example.crkl.integrations.OnDeviceTranslator
+import com.example.crkl.ui.theme.CrklTheme
+import kotlinx.coroutines.launch
+
+/**
+ * Integrations: Vikunja + translate first; appearance; lab tools collapsed.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+class IntegrationsActivity : ComponentActivity() {
+
+ private lateinit var settings: IntegrationSettings
+ private var statusState: ((String) -> Unit)? = null
+
+ private val calendarPermission = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { granted ->
+ val msg = if (granted) DeviceCalendar.today(this).message else "Calendar permission denied."
+ statusState?.invoke(settings.statusSummary() + "\n\n" + msg)
+ Toast.makeText(
+ this,
+ if (granted) "Calendar access granted" else "Permission denied",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ settings = IntegrationSettings(this)
+ setContent {
+ CrklTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ val scope = rememberCoroutineScope()
+ var vikunjaUrl by remember { mutableStateOf(settings.vikunjaUrl) }
+ var vikunjaToken by remember { mutableStateOf(settings.vikunjaToken) }
+ var projectId by remember { mutableStateOf(settings.vikunjaProjectId.toString()) }
+ var emailTo by remember { mutableStateOf(settings.emailTo) }
+ var gogUrl by remember {
+ mutableStateOf(
+ settings.gogBridgeUrl.ifBlank { IntegrationSettings.DEFAULT_GOG_BRIDGE_EMU }
+ )
+ }
+ var preferGog by remember { mutableStateOf(settings.preferGogEmail) }
+ var circleColor by remember { mutableStateOf(settings.circleColorKey) }
+ var circleNeon by remember { mutableStateOf(settings.circleNeon) }
+ var circleStroke by remember { mutableStateOf(settings.circleStrokeKey) }
+ var translateTarget by remember { mutableStateOf(settings.translateTargetLang) }
+ var showDebugMeta by remember { mutableStateOf(settings.showDebugMeta) }
+ var showAdvanced by remember { mutableStateOf(false) }
+ var showAppearance by remember { mutableStateOf(false) }
+ var status by remember { mutableStateOf(settings.statusSummary()) }
+ statusState = { status = it }
+
+ fun saveAll() {
+ settings.vikunjaUrl = vikunjaUrl
+ settings.vikunjaToken = vikunjaToken
+ settings.vikunjaProjectId = projectId.toIntOrNull()
+ ?: IntegrationSettings.DEFAULT_VIKUNJA_PROJECT
+ settings.emailTo = emailTo
+ settings.gogBridgeUrl = gogUrl
+ settings.preferGogEmail = preferGog
+ settings.circleColorKey = circleColor
+ settings.circleNeon = circleNeon
+ settings.circleStrokeKey = circleStroke
+ settings.translateTargetLang = translateTarget
+ settings.showDebugMeta = showDebugMeta
+ status = settings.statusSummary()
+ Toast.makeText(this@IntegrationsActivity, "Saved", Toast.LENGTH_SHORT).show()
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Text(
+ "Integrations",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ "Connect the apps you already use. Only Vikunja is required for Add to todo.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Text("Vikunja (todos)", fontWeight = FontWeight.SemiBold)
+ Text(
+ "Paste an API token from todo.levkin.ca → Settings → API Tokens.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ OutlinedTextField(
+ vikunjaUrl,
+ { vikunjaUrl = it },
+ label = { Text("URL") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+ OutlinedTextField(
+ vikunjaToken,
+ { vikunjaToken = it },
+ label = { Text("API token") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+ OutlinedTextField(
+ projectId,
+ { projectId = it },
+ label = { Text("Project id") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+
+ Text("Translate", fontWeight = FontWeight.SemiBold)
+ Text(
+ "Default language for the Translate chip.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ OnDeviceTranslator.TARGET_OPTIONS.forEach { (tag, label) ->
+ FilterChip(
+ selected = translateTarget == tag,
+ onClick = { translateTarget = tag },
+ label = { Text(label) }
+ )
+ }
+ }
+
+ Button(onClick = { saveAll() }, modifier = Modifier.fillMaxWidth()) {
+ Text("Save")
+ }
+
+ TextButton(onClick = { showAppearance = !showAppearance }) {
+ Text(if (showAppearance) "Hide circle style" else "Circle style")
+ }
+ AnimatedVisibility(visible = showAppearance) {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ IntegrationSettings.CIRCLE_COLORS.forEach { key ->
+ FilterChip(
+ selected = circleColor == key,
+ onClick = { circleColor = key },
+ label = { Text(key) }
+ )
+ }
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Neon glow", modifier = Modifier.weight(1f))
+ Switch(checked = circleNeon, onCheckedChange = { circleNeon = it })
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ IntegrationSettings.CIRCLE_STROKES.forEach { key ->
+ FilterChip(
+ selected = circleStroke == key,
+ onClick = { circleStroke = key },
+ label = { Text(key) }
+ )
+ }
+ }
+ }
+ }
+
+ TextButton(onClick = { showAdvanced = !showAdvanced }) {
+ Text(if (showAdvanced) "Hide advanced" else "Advanced (email lab, calendar, debug)")
+ }
+ AnimatedVisibility(visible = showAdvanced) {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ OutlinedTextField(
+ emailTo,
+ { emailTo = it },
+ label = { Text("Email To (mailto)") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+ Text("gog bridge (emulator lab only)", fontWeight = FontWeight.SemiBold)
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Prefer gog", modifier = Modifier.weight(1f))
+ Switch(checked = preferGog, onCheckedChange = { preferGog = it })
+ }
+ OutlinedTextField(
+ gogUrl,
+ { gogUrl = it },
+ label = { Text("gog bridge URL") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = preferGog
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Show debug meta on panel", modifier = Modifier.weight(1f))
+ Switch(
+ checked = showDebugMeta,
+ onCheckedChange = { showDebugMeta = it }
+ )
+ }
+ OutlinedButton(
+ onClick = {
+ if (DeviceCalendar.hasPermission(this@IntegrationsActivity)) {
+ status = settings.statusSummary() + "\n\n" +
+ DeviceCalendar.today(this@IntegrationsActivity).message
+ } else {
+ calendarPermission.launch(Manifest.permission.READ_CALENDAR)
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ if (DeviceCalendar.hasPermission(this@IntegrationsActivity)) {
+ "Test calendar"
+ } else {
+ "Grant calendar access"
+ }
+ )
+ }
+ OutlinedButton(
+ onClick = {
+ saveAll()
+ scope.launch {
+ val r = GogBridgeClient(settings).health()
+ status = settings.statusSummary() + "\n\ngog: " + r.message
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = preferGog
+ ) { Text("Test gog bridge") }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(status, style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/MainActivity.kt b/app/src/main/kotlin/com/example/crkl/MainActivity.kt
index c464c4b..c36de7f 100644
--- a/app/src/main/kotlin/com/example/crkl/MainActivity.kt
+++ b/app/src/main/kotlin/com/example/crkl/MainActivity.kt
@@ -1,128 +1,436 @@
package com.example.crkl
+import android.Manifest
+import android.accessibilityservice.AccessibilityServiceInfo
+import android.content.Context
import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
import android.os.Bundle
import android.provider.Settings
+import android.view.accessibility.AccessibilityManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
-import androidx.compose.foundation.layout.*
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import android.app.StatusBarManager
+import android.content.ComponentName
+import android.graphics.drawable.Icon
+import android.widget.Toast
+import com.example.crkl.accessibility.CrklCircleTileService
+import com.example.crkl.accessibility.CrklOverlayBridge
+import com.example.crkl.capture.MediaProjectionHolder
+import com.example.crkl.capture.ProjectionPermissionActivity
+import com.example.crkl.fixtures.TestFixturesActivity
+import com.example.crkl.integrations.IntegrationSettings
import com.example.crkl.ui.theme.CrklTheme
+import java.util.concurrent.Executor
-/**
- * Main Activity for Crkl
- *
- * This activity serves as the entry point and settings page.
- * It guides users to enable the accessibility service.
- */
class MainActivity : ComponentActivity() {
+
+ private val micPermission = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { }
+
+ private val notifPermission = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ maybeRequestMic()
+ maybeRequestNotifications()
setContent {
CrklTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
- MainScreen(
- onOpenAccessibilitySettings = {
- openAccessibilitySettings()
+ var captureReady by remember {
+ mutableStateOf(MediaProjectionHolder.isReady())
+ }
+ val settings = remember { IntegrationSettings(this) }
+ var a11yOn by remember { mutableStateOf(isCrklAccessibilityEnabled()) }
+ var onboardingDone by remember { mutableStateOf(settings.onboardingComplete) }
+ var overlayReady by remember { mutableStateOf(CrklOverlayBridge.isReady()) }
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ a11yOn = isCrklAccessibilityEnabled()
+ onboardingDone = settings.onboardingComplete
+ captureReady = MediaProjectionHolder.isReady()
+ overlayReady = CrklOverlayBridge.isReady()
+ }
}
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ MainScreen(
+ captureReady = captureReady,
+ integrationHint = settings.statusSummary(),
+ accessibilityEnabled = a11yOn,
+ showOnboarding = !onboardingDone,
+ onRefreshA11y = {
+ a11yOn = isCrklAccessibilityEnabled()
+ overlayReady = CrklOverlayBridge.isReady()
+ },
+ onOpenAccessibilitySettings = {
+ startActivity(
+ Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+ )
+ },
+ onOpenIntegrations = {
+ startActivity(Intent(this, IntegrationsActivity::class.java))
+ },
+ onOpenTestFixtures = {
+ settings.onboardingComplete = true
+ onboardingDone = true
+ startActivity(Intent(this, TestFixturesActivity::class.java))
+ },
+ onEnableCapture = {
+ startActivity(ProjectionPermissionActivity.intent(this))
+ window.decorView.postDelayed({
+ captureReady = MediaProjectionHolder.isReady()
+ }, 1500)
+ },
+ onSkipOnboarding = {
+ settings.onboardingComplete = true
+ onboardingDone = true
+ },
+ onAddQsTile = { requestQsTile() },
+ canAddQsTile = Build.VERSION.SDK_INT >= 33,
+ overlayReady = overlayReady
)
}
}
}
}
- private fun openAccessibilitySettings() {
- val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
- intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
- startActivity(intent)
+ private fun requestQsTile() {
+ if (Build.VERSION.SDK_INT < 33) {
+ Toast.makeText(
+ this,
+ "Pull down Quick Settings → edit → add Circle",
+ Toast.LENGTH_LONG
+ ).show()
+ return
+ }
+ val statusBar = getSystemService(StatusBarManager::class.java)
+ val component = ComponentName(this, CrklCircleTileService::class.java)
+ val icon = Icon.createWithResource(this, R.drawable.ic_qs_circle)
+ val executor = Executor { it.run() }
+ statusBar.requestAddTileService(
+ component,
+ getString(R.string.qs_tile_label),
+ icon,
+ executor
+ ) { result ->
+ runOnUiThread {
+ val msg = when (result) {
+ StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ADDED -> "Circle tile added"
+ StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ALREADY_ADDED -> "Circle tile already added"
+ StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_NOT_ADDED -> "Tile not added"
+ else -> "Pull down Quick Settings → edit → add Circle"
+ }
+ Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ private fun isCrklAccessibilityEnabled(): Boolean {
+ val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
+ val enabled = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
+ val me = "$packageName/"
+ return enabled.any { info ->
+ info.resolveInfo?.serviceInfo?.let { si ->
+ si.packageName == packageName
+ } == true || info.id.contains(me) || info.id.contains("CrklAccessibilityService")
+ }
+ }
+
+ private fun maybeRequestMic() {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
+ != PackageManager.PERMISSION_GRANTED
+ ) {
+ micPermission.launch(Manifest.permission.RECORD_AUDIO)
+ }
+ }
+
+ private fun maybeRequestNotifications() {
+ if (Build.VERSION.SDK_INT >= 33 &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
+ != PackageManager.PERMISSION_GRANTED
+ ) {
+ notifPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
}
}
@Composable
-fun MainScreen(onOpenAccessibilitySettings: () -> Unit) {
+fun MainScreen(
+ captureReady: Boolean,
+ integrationHint: String,
+ accessibilityEnabled: Boolean,
+ showOnboarding: Boolean,
+ onRefreshA11y: () -> Unit,
+ onOpenAccessibilitySettings: () -> Unit,
+ onOpenIntegrations: () -> Unit,
+ onOpenTestFixtures: () -> Unit,
+ onEnableCapture: () -> Unit,
+ onSkipOnboarding: () -> Unit,
+ onAddQsTile: () -> Unit = {},
+ canAddQsTile: Boolean = false,
+ overlayReady: Boolean = false
+) {
Column(
modifier = Modifier
.fillMaxSize()
+ .verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
+ verticalArrangement = Arrangement.Top
) {
+ Spacer(modifier = Modifier.height(12.dp))
Text(
- text = "Welcome to Crkl",
+ text = "Circle",
style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center
)
-
- Spacer(modifier = Modifier.height(16.dp))
-
+ Spacer(modifier = Modifier.height(8.dp))
Text(
- text = "Privacy-first, on-device AI assistant",
+ text = "Circle text → translate, copy, explain, share, or Vikunja",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.secondary
)
-
- Spacer(modifier = Modifier.height(32.dp))
-
+
+ if (!accessibilityEnabled) {
+ Spacer(modifier = Modifier.height(20.dp))
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Text(
+ "Accessibility is off",
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ Text(
+ "The floating C won’t appear until you enable Circle Overlay. " +
+ "Re-enable after every reinstall.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ Button(
+ onClick = {
+ onOpenAccessibilitySettings()
+ onRefreshA11y()
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Enable Accessibility")
+ }
+ }
+ }
+ }
+
+ if (showOnboarding) {
+ Spacer(modifier = Modifier.height(20.dp))
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Text("Get started", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
+ Text(
+ "Three steps — then circle anything on screen.",
+ style = MaterialTheme.typography.bodySmall
+ )
+ OnboardStep(
+ number = 1,
+ title = "Enable Accessibility",
+ done = accessibilityEnabled,
+ actionLabel = if (accessibilityEnabled) "Enabled" else "Open settings",
+ onAction = {
+ onOpenAccessibilitySettings()
+ onRefreshA11y()
+ },
+ enabled = !accessibilityEnabled
+ )
+ OnboardStep(
+ number = 2,
+ title = "Integrations (optional)",
+ done = false,
+ actionLabel = "Open Integrations",
+ onAction = onOpenIntegrations,
+ enabled = true,
+ detail = "Vikunja token, translate target, circle style"
+ )
+ OnboardStep(
+ number = 3,
+ title = "Try a circle",
+ done = false,
+ actionLabel = "Open test fixtures",
+ onAction = onOpenTestFixtures,
+ enabled = true,
+ detail = "Tap the C → closed loop around text → Translate / Copy / Explain"
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ TextButton(onClick = onSkipOnboarding) {
+ Text("Skip for now")
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(20.dp))
+
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
- Column(
- modifier = Modifier.padding(16.dp)
- ) {
+ Column(modifier = Modifier.padding(16.dp)) {
Text(
- text = "🔒 Your data stays on your device",
- style = MaterialTheme.typography.bodyMedium
+ text = if (accessibilityEnabled) "Accessibility: ON — floating C ready" else "Accessibility: off — enable to show floating C",
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = "🎯 Circle any content for instant AI help",
- style = MaterialTheme.typography.bodyMedium
- )
+ Text(text = integrationHint, style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(8.dp))
Text(
- text = "🧠 Local inference with no cloud calls",
- style = MaterialTheme.typography.bodyMedium
+ text = if (captureReady) "Screen/audio capture: ON" else "Screen/audio capture: off (optional)",
+ style = MaterialTheme.typography.bodySmall
)
}
}
-
- Spacer(modifier = Modifier.height(32.dp))
-
- Text(
- text = "To get started, enable the Crkl Accessibility Service:",
- style = MaterialTheme.typography.bodyMedium,
- textAlign = TextAlign.Center
- )
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Button(
- onClick = onOpenAccessibilitySettings,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text("Open Accessibility Settings")
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ Button(onClick = {
+ onOpenAccessibilitySettings()
+ onRefreshA11y()
+ }, modifier = Modifier.fillMaxWidth()) {
+ Text(if (accessibilityEnabled) "Accessibility settings" else "1 · Enable Accessibility")
}
-
+ Spacer(modifier = Modifier.height(10.dp))
+ Button(onClick = onOpenIntegrations, modifier = Modifier.fillMaxWidth()) {
+ Text("2 · Integrations")
+ }
+ Spacer(modifier = Modifier.height(10.dp))
+ Button(onClick = onOpenTestFixtures, modifier = Modifier.fillMaxWidth()) {
+ Text("3 · ${stringResource(R.string.open_test_fixtures)}")
+ }
+ Spacer(modifier = Modifier.height(10.dp))
+ OutlinedButton(onClick = onEnableCapture, modifier = Modifier.fillMaxWidth()) {
+ Text(if (captureReady) "Recapture screen/audio" else "Optional · screen/audio capture")
+ }
+ if (canAddQsTile) {
+ Spacer(modifier = Modifier.height(10.dp))
+ OutlinedButton(onClick = onAddQsTile, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.add_qs_tile))
+ }
+ }
+
Spacer(modifier = Modifier.height(16.dp))
-
Text(
- text = "In Settings, find and enable 'Crkl Overlay Service'",
+ text = buildString {
+ append("Shortcuts: QS tile “Circle”")
+ if (overlayReady) append(" (ready)")
+ append(" · Accessibility button · hold both volume keys")
+ },
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Panel chips: Translate · Copy · Explain · Share · Vikunja",
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(24.dp))
}
}
+@Composable
+private fun OnboardStep(
+ number: Int,
+ title: String,
+ done: Boolean,
+ actionLabel: String,
+ onAction: () -> Unit,
+ enabled: Boolean,
+ detail: String? = null
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ text = "$number. $title" + if (done) " ✓" else "",
+ fontWeight = FontWeight.SemiBold,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ if (detail != null) {
+ Text(detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ OutlinedButton(
+ onClick = onAction,
+ enabled = enabled || done,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(actionLabel)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/accessibility/CrklAccessibilityService.kt b/app/src/main/kotlin/com/example/crkl/accessibility/CrklAccessibilityService.kt
index 3a935ae..7071f9b 100644
--- a/app/src/main/kotlin/com/example/crkl/accessibility/CrklAccessibilityService.kt
+++ b/app/src/main/kotlin/com/example/crkl/accessibility/CrklAccessibilityService.kt
@@ -1,149 +1,507 @@
package com.example.crkl.accessibility
import android.accessibilityservice.AccessibilityService
+import android.content.pm.PackageManager
import android.graphics.PixelFormat
+import android.graphics.RectF
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.WindowManager
import android.view.accessibility.AccessibilityEvent
+import androidx.core.content.ContextCompat
+import com.example.crkl.agent.ActionExecutor
+import com.example.crkl.agent.AssistEngine
+import com.example.crkl.agent.VoiceIntent
+import com.example.crkl.media.MediaAssistPipeline
+import com.example.crkl.model.DeviceSpeechStt
+import com.example.crkl.model.MediaPipeLocalLlm
+import com.example.crkl.integrations.IntegrationSettings
+import com.example.crkl.ui.ResultPanelView
+import com.example.crkl.vision.ContentCapture
+import com.example.crkl.vision.ScreenOcr
+import com.example.crkl.vision.ScreenshotCapturer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
/**
- * Crkl Accessibility Service
- *
- * This service monitors accessibility events to detect user interactions
- * and provides AI-powered assistance based on selected screen content.
- *
- * Privacy: All processing is local. No data leaves the device.
- *
- * Note: No overlay is created to avoid blocking touch events.
+ * System overlay + selection pipeline.
+ *
+ * Flow: FAB → circle → extract/media → assist → panel → commands → Vikunja / mailto / device calendar.
*/
class CrklAccessibilityService : AccessibilityService() {
-
- private val TAG = "CrklAccessibilityService"
+
+ private val tagName = "CrklAccessibilityService"
+ private val mainHandler = Handler(Looper.getMainLooper())
private var overlayView: OverlayView? = null
+ private var resultPanel: ResultPanelView? = null
private var windowManager: WindowManager? = null
- private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
-
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+
+ private lateinit var localLlm: MediaPipeLocalLlm
+ private lateinit var assistEngine: AssistEngine
+ private lateinit var localStt: DeviceSpeechStt
+ private lateinit var actionExecutor: ActionExecutor
+ private var lastSession: ActionExecutor.Session? = null
+
override fun onServiceConnected() {
super.onServiceConnected()
- Log.d(TAG, "Crkl Accessibility Service connected")
-
- // Create floating action button for Crkl activation
+ Log.d(tagName, "Crkl Accessibility Service connected")
+ localLlm = MediaPipeLocalLlm(this)
+ assistEngine = AssistEngine(localLlm)
+ localStt = DeviceSpeechStt(this)
+ actionExecutor = ActionExecutor(
+ context = this,
+ explainText = { text -> assistEngine.explain(text) }
+ )
setupFloatingButton()
- Log.d(TAG, "Service ready - floating button created")
+ applyOverlayStyle()
+ CrklOverlayBridge.bind(this)
+ registerAccessibilityButton()
+ serviceScope.launch(Dispatchers.Default) {
+ assistEngine.warmUp()
+ }
}
-
+
+ private fun registerAccessibilityButton() {
+ if (Build.VERSION.SDK_INT < 26) return
+ try {
+ val controller = accessibilityButtonController
+ controller.registerAccessibilityButtonCallback(
+ object : android.accessibilityservice.AccessibilityButtonController.AccessibilityButtonCallback() {
+ override fun onClicked(
+ controller: android.accessibilityservice.AccessibilityButtonController
+ ) {
+ enterCircleModeFromShortcut()
+ }
+ },
+ mainHandler
+ )
+ } catch (e: Exception) {
+ Log.w(tagName, "Accessibility button not available", e)
+ }
+ }
+
+ /**
+ * Called from Quick Settings tile or Accessibility button.
+ * Shows full-screen circle mode immediately.
+ */
+ fun enterCircleModeFromShortcut() {
+ mainHandler.post {
+ try {
+ if (overlayView == null) {
+ setupFloatingButton()
+ applyOverlayStyle()
+ }
+ val view = overlayView
+ if (view == null) {
+ android.widget.Toast.makeText(
+ this,
+ "Crkl overlay not ready — open the app once",
+ android.widget.Toast.LENGTH_LONG
+ ).show()
+ return@post
+ }
+ dismissResultPanel()
+ if (!view.isInOverlayMode()) {
+ view.enterOverlayMode()
+ }
+ } catch (e: Exception) {
+ Log.e(tagName, "enterCircleModeFromShortcut failed", e)
+ }
+ }
+ }
+
+ private fun applyOverlayStyle() {
+ val s = IntegrationSettings(this)
+ overlayView?.applyStyle(
+ colorArgb = s.circleColorArgb(),
+ neon = s.circleNeon,
+ strokeDp = s.circleStrokeDp()
+ )
+ }
+
private fun setupFloatingButton() {
try {
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
-
- // Create overlay view with callback for mode changes
- overlayView = OverlayView(this) { isOverlayMode ->
- updateOverlayMode(isOverlayMode)
- }
-
- // Configure window layout parameters for floating button
- val params = WindowManager.LayoutParams(
- WindowManager.LayoutParams.WRAP_CONTENT,
- WindowManager.LayoutParams.WRAP_CONTENT,
- WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
- WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
- WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
- PixelFormat.TRANSLUCENT
+ overlayView = OverlayView(
+ context = this,
+ onModeChanged = { isOverlayMode -> updateOverlayMode(isOverlayMode) },
+ onSelectionComplete = { bounds -> handleSelection(bounds) }
)
-
- // Position in bottom right corner
- params.gravity = Gravity.BOTTOM or Gravity.END
- params.x = 50
- params.y = 100
-
- // Add floating button to window manager
- windowManager?.addView(overlayView, params)
-
- Log.d(TAG, "Floating button created successfully")
+ windowManager?.addView(overlayView, floatingButtonParams())
+ Log.d(tagName, "Floating button created")
} catch (e: Exception) {
- Log.e(TAG, "Error creating floating button", e)
+ Log.e(tagName, "Error creating floating button", e)
}
}
-
+
+ private fun floatingButtonParams(): WindowManager.LayoutParams =
+ WindowManager.LayoutParams(
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
+ WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
+ WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+ PixelFormat.TRANSLUCENT
+ ).apply {
+ gravity = Gravity.BOTTOM or Gravity.END
+ x = dp(16)
+ y = navigationBarHeight() + dp(24)
+ }
+
+ private fun fullScreenParams(): WindowManager.LayoutParams {
+ val metrics = resources.displayMetrics
+ val nav = navigationBarHeight()
+ val status = statusBarHeight()
+ val height = (metrics.heightPixels - nav - status).coerceAtLeast(dp(200))
+ return WindowManager.LayoutParams(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ height,
+ WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
+ WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
+ PixelFormat.TRANSLUCENT
+ ).apply {
+ gravity = Gravity.TOP or Gravity.START
+ x = 0
+ y = status
+ }
+ }
+
+ private fun resultPanelParams(): WindowManager.LayoutParams {
+ val metrics = resources.displayMetrics
+ val width = (metrics.widthPixels * 0.94f).toInt()
+ val height = (metrics.heightPixels * 0.48f).toInt()
+ val nav = navigationBarHeight()
+ return WindowManager.LayoutParams(
+ width,
+ height,
+ WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
+ WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
+ PixelFormat.TRANSLUCENT
+ ).apply {
+ gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
+ y = nav + dp(10)
+ flags = flags and WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv()
+ }
+ }
+
+ /** Fast path: resize in place — do NOT remove/re-add the window (that lagged the C button). */
private fun updateOverlayMode(isOverlayMode: Boolean) {
+ val view = overlayView ?: return
+ val wm = windowManager ?: return
try {
- overlayView?.let { view ->
- windowManager?.let { wm ->
- // Remove current view
- wm.removeView(view)
-
- // Create new parameters based on mode
- val params = if (isOverlayMode) {
- // Full screen overlay parameters
- WindowManager.LayoutParams(
- WindowManager.LayoutParams.MATCH_PARENT,
- WindowManager.LayoutParams.MATCH_PARENT,
- WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
- WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
- WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
- WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
- PixelFormat.TRANSLUCENT
- ).apply {
- gravity = Gravity.TOP or Gravity.LEFT
- x = 0
- y = 0
- }
- } else {
- // Floating button parameters
- WindowManager.LayoutParams(
- WindowManager.LayoutParams.WRAP_CONTENT,
- WindowManager.LayoutParams.WRAP_CONTENT,
- WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
- WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
- WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
- PixelFormat.TRANSLUCENT
- ).apply {
- gravity = Gravity.BOTTOM or Gravity.END
- x = 50
- y = 100
- }
+ if (isOverlayMode) {
+ dismissResultPanel()
+ applyOverlayStyle()
+ }
+ wm.updateViewLayout(
+ view,
+ if (isOverlayMode) fullScreenParams() else floatingButtonParams()
+ )
+ view.requestLayout()
+ } catch (e: IllegalArgumentException) {
+ // View not attached yet — fall back once.
+ Log.w(tagName, "updateViewLayout failed, re-adding", e)
+ try {
+ wm.addView(view, if (isOverlayMode) fullScreenParams() else floatingButtonParams())
+ } catch (e2: Exception) {
+ Log.e(tagName, "re-add overlay failed", e2)
+ }
+ } catch (e: Exception) {
+ Log.e(tagName, "Error updating overlay mode", e)
+ }
+ }
+
+ private fun handleSelection(bounds: RectF) {
+ serviceScope.launch {
+ overlayView?.exitOverlayMode()
+ showResultPanel(
+ AssistEngine.AssistResponse(
+ title = "Reading…",
+ meta = "",
+ body = "Looking for text in that circle…"
+ )
+ )
+
+ val capture = withContext(Dispatchers.Default) {
+ var windowsSnapshot: List? = null
+ var rootSnapshot: android.view.accessibility.AccessibilityNodeInfo? = null
+
+ withContext(Dispatchers.Main) {
+ overlayView?.visibility = android.view.View.INVISIBLE
+ resultPanel?.visibility = android.view.View.INVISIBLE
+ }
+ delay(50)
+
+ withContext(Dispatchers.Main) {
+ windowsSnapshot = windows
+ rootSnapshot = rootInActiveWindow
+ }
+
+ val a11y = try {
+ ContentCapture.fromA11y(windowsSnapshot, rootSnapshot, bounds)
+ } finally {
+ withContext(Dispatchers.Main) {
+ rootSnapshot?.recycle()
}
-
- // Add view with new parameters
- wm.addView(view, params)
- Log.d(TAG, "Overlay mode updated: ${if (isOverlayMode) "full-screen" else "floating button"}")
+ }
+
+ // OCR when a11y is thin, or always merge for image-like sparse text.
+ val needOcr = a11y.isEmpty || a11y.text.length < 40
+ val merged = if (needOcr) {
+ withContext(Dispatchers.Main) {
+ val debug = IntegrationSettings(this@CrklAccessibilityService).showDebugMeta
+ updateResultPanel(
+ AssistEngine.AssistResponse(
+ title = "Reading…",
+ meta = if (debug) "OCR…" else "",
+ body = "Looking for text in that circle…"
+ )
+ )
+ }
+ val bmp = ScreenshotCapturer.captureRegion(this@CrklAccessibilityService, bounds)
+ try {
+ if (bmp != null) {
+ val ocr = ScreenOcr.recognize(bmp)
+ ContentCapture.mergeWithOcr(a11y, ocr.text, ocr.blockCount)
+ } else {
+ a11y
+ }
+ } finally {
+ bmp?.recycle()
+ }
+ } else {
+ a11y
+ }
+
+ withContext(Dispatchers.Main) {
+ overlayView?.visibility = android.view.View.VISIBLE
+ resultPanel?.visibility = android.view.View.VISIBLE
+ }
+ merged
+ }
+
+ // Media path: circled video/audio → play/listen → transcript → summarize.
+ val mediaPipeline = MediaAssistPipeline(this@CrklAccessibilityService, assistEngine)
+ val mediaSlot = mediaPipeline.resolveSlot(bounds, capture)
+ if (mediaSlot != null) {
+ updateResultPanel(
+ AssistEngine.AssistResponse(
+ title = "Crkl",
+ meta = "media…",
+ body = "Playing and listening to ${mediaSlot.kind.name.lowercase()}…"
+ )
+ )
+ val outcome = mediaPipeline.process(mediaSlot) { msg ->
+ updateResultPanel(
+ AssistEngine.AssistResponse(
+ title = "Crkl",
+ meta = "media…",
+ body = msg
+ )
+ )
+ }
+ lastSession = ActionExecutor.Session(
+ title = outcome.response.title,
+ body = outcome.response.body,
+ transcript = outcome.transcript,
+ workingList = VoiceIntent.extractListFromBody(
+ outcome.response.body + "\n" + outcome.transcript
+ ).toMutableList()
+ )
+ updateResultPanel(
+ outcome.response.copy(
+ body = outcome.response.body +
+ "\n\n—\nCommands: “add to my todo” (Vikunja) · “email me the list” (mailto) · “what’s on my calendar”."
+ )
+ )
+ return@launch
+ }
+
+ val extraction = capture.toExtraction()
+ val response = withContext(Dispatchers.Default) {
+ assistEngine.respond(extraction)
+ }
+ lastSession = ActionExecutor.Session(
+ title = response.title,
+ body = response.body,
+ workingList = VoiceIntent.extractListFromBody(response.body).toMutableList()
+ )
+ val debug = IntegrationSettings(this@CrklAccessibilityService).showDebugMeta
+ val userMeta = if (debug) {
+ buildString {
+ append(response.meta.ifBlank { "debug" })
+ append(" · ${capture.source.name.lowercase()}")
+ if (capture.ocrBlockCount > 0) append(" · ocr=${capture.ocrBlockCount}")
+ if (capture.packageName != null) {
+ append(" · ${capture.packageName.substringAfterLast('.')}")
+ }
+ }
+ } else {
+ ""
+ }
+ updateResultPanel(response.copy(meta = userMeta))
+ }
+ }
+
+ private fun showResultPanel(response: AssistEngine.AssistResponse) {
+ mainHandler.post {
+ try {
+ dismissResultPanel()
+ val panel = ResultPanelView(
+ context = this,
+ onDismiss = { dismissResultPanel() },
+ onListen = { startSttFromPanel() },
+ onShareList = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.SHARE_LIST, raw = "share")) },
+ onAddTodo = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.ADD_TO_TODO, raw = "add to todo")) },
+ onTranslate = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.TRANSLATE, raw = "translate")) },
+ onCopy = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.COPY, raw = "copy")) },
+ onExplain = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.EXPLAIN, raw = "explain")) }
+ )
+ panel.showResult(response.title, response.meta, response.body)
+ resultPanel = panel
+ windowManager?.addView(panel, resultPanelParams())
+ panel.playEnter()
+ } catch (e: Exception) {
+ Log.e(tagName, "Failed to show result panel", e)
+ }
+ }
+ }
+
+ private fun updateResultPanel(response: AssistEngine.AssistResponse) {
+ mainHandler.post {
+ val panel = resultPanel
+ if (panel == null) {
+ showResultPanel(response)
+ } else {
+ panel.showResult(response.title, response.meta, response.body)
+ }
+ }
+ }
+
+ private fun startSttFromPanel() {
+ val micGranted = ContextCompat.checkSelfPermission(
+ this,
+ android.Manifest.permission.RECORD_AUDIO
+ ) == PackageManager.PERMISSION_GRANTED
+
+ if (!micGranted) {
+ resultPanel?.appendBody(
+ "Mic permission needed. Open Crkl app → allow microphone, then retry Speak."
+ )
+ return
+ }
+ if (!localStt.isAvailable) {
+ resultPanel?.appendBody("Speech recognition is not available on this device/emulator.")
+ return
+ }
+
+ serviceScope.launch {
+ resultPanel?.setListening(true)
+ val text = try {
+ localStt.listen(8_000L)
+ } finally {
+ resultPanel?.setListening(false)
+ }
+ if (text.isBlank()) {
+ resultPanel?.appendBody(
+ "STT: (no speech heard)\n" +
+ "Tip: click the phone window once, then Speak again. Emulator needs a Mac mic."
+ )
+ return@launch
+ }
+ resultPanel?.appendBody("You said: $text")
+ val parsed = VoiceIntent.parse(text)
+ runVoiceAction(parsed)
+ }
+ }
+
+ private fun runVoiceAction(parsed: VoiceIntent.Parsed) {
+ serviceScope.launch {
+ Log.i(tagName, "action kind=${parsed.kind} raw=${parsed.raw}")
+ val result = withContext(Dispatchers.IO) {
+ actionExecutor.execute(parsed, lastSession)
+ }
+ Log.i(tagName, "action result ok=${result.ok} msg=${result.message.take(160)}")
+ lastSession?.let { session ->
+ if (session.workingList.isNotEmpty()) {
+ lastSession = session.copy(
+ body = buildString {
+ appendLine(session.title)
+ appendLine()
+ session.workingList.forEach { appendLine("• $it") }
+ }
+ )
}
}
- } catch (e: Exception) {
- Log.e(TAG, "Error updating overlay mode", e)
- }
- }
-
- override fun onAccessibilityEvent(event: AccessibilityEvent?) {
- // We'll use this later for content detection
- // For now, just log events
- event?.let {
- if (it.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
- Log.d(TAG, "Window changed: ${it.packageName}")
+ withContext(Dispatchers.Main) {
+ resultPanel?.appendBody("—\n${result.message}")
+ android.widget.Toast.makeText(
+ this@CrklAccessibilityService,
+ result.message.lineSequence().firstOrNull { it.isNotBlank() }?.take(80)
+ ?: if (result.ok) "Done" else "Failed",
+ android.widget.Toast.LENGTH_LONG
+ ).show()
}
}
}
-
- override fun onInterrupt() {
- Log.d(TAG, "Service interrupted")
+
+ private fun dismissResultPanel() {
+ if (::localStt.isInitialized) {
+ localStt.cancel()
+ }
+ resultPanel?.let { panel ->
+ try {
+ windowManager?.removeView(panel)
+ } catch (e: Exception) {
+ Log.w(tagName, "Result panel already removed", e)
+ }
+ }
+ resultPanel = null
}
-
+
+ override fun onAccessibilityEvent(event: AccessibilityEvent?) = Unit
+
+ override fun onInterrupt() {
+ Log.d(tagName, "Service interrupted")
+ }
+
override fun onDestroy() {
super.onDestroy()
- Log.d(TAG, "Service destroyed")
-
- // Clean up floating button
+ CrklOverlayBridge.unbind(this)
+ dismissResultPanel()
overlayView?.let {
- windowManager?.removeView(it)
+ try {
+ windowManager?.removeView(it)
+ } catch (_: Exception) {
+ }
overlayView = null
}
-
+ if (::localLlm.isInitialized) localLlm.close()
serviceScope.cancel()
+ Log.d(tagName, "Service destroyed")
}
-}
+ private fun navigationBarHeight(): Int {
+ val id = resources.getIdentifier("navigation_bar_height", "dimen", "android")
+ return if (id > 0) resources.getDimensionPixelSize(id) else dp(48)
+ }
+
+ private fun statusBarHeight(): Int {
+ val id = resources.getIdentifier("status_bar_height", "dimen", "android")
+ return if (id > 0) resources.getDimensionPixelSize(id) else dp(24)
+ }
+
+ private fun dp(value: Int): Int =
+ (value * resources.displayMetrics.density).toInt()
+}
diff --git a/app/src/main/kotlin/com/example/crkl/accessibility/CrklCircleTileService.kt b/app/src/main/kotlin/com/example/crkl/accessibility/CrklCircleTileService.kt
new file mode 100644
index 0000000..3ef3aeb
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/accessibility/CrklCircleTileService.kt
@@ -0,0 +1,71 @@
+package com.example.crkl.accessibility
+
+import android.app.PendingIntent
+import android.content.Intent
+import android.os.Build
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import android.widget.Toast
+import com.example.crkl.MainActivity
+import com.example.crkl.R
+
+/**
+ * Quick Settings tile: tap → enter Crkl circle mode (Accessibility must be on).
+ */
+class CrklCircleTileService : TileService() {
+
+ override fun onStartListening() {
+ super.onStartListening()
+ refreshTile()
+ }
+
+ override fun onClick() {
+ super.onClick()
+ unlockAndRun {
+ if (CrklOverlayBridge.enterCircleMode()) {
+ Toast.makeText(this, "Circle text — close the loop", Toast.LENGTH_SHORT).show()
+ refreshTile()
+ } else {
+ Toast.makeText(
+ this,
+ "Enable Circle Overlay first",
+ Toast.LENGTH_LONG
+ ).show()
+ openApp()
+ }
+ }
+ }
+
+ private fun refreshTile() {
+ val tile = qsTile ?: return
+ val ready = CrklOverlayBridge.isReady()
+ tile.label = getString(R.string.qs_tile_label)
+ if (Build.VERSION.SDK_INT >= 29) {
+ tile.subtitle = if (ready) {
+ getString(R.string.qs_tile_subtitle_ready)
+ } else {
+ getString(R.string.qs_tile_subtitle_off)
+ }
+ }
+ tile.state = if (ready) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
+ tile.updateTile()
+ }
+
+ private fun openApp() {
+ val intent = Intent(this, MainActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ }
+ if (Build.VERSION.SDK_INT >= 34) {
+ val pi = PendingIntent.getActivity(
+ this,
+ 0,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+ startActivityAndCollapse(pi)
+ } else {
+ @Suppress("DEPRECATION")
+ startActivityAndCollapse(intent)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/accessibility/CrklOverlayBridge.kt b/app/src/main/kotlin/com/example/crkl/accessibility/CrklOverlayBridge.kt
new file mode 100644
index 0000000..1ff352c
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/accessibility/CrklOverlayBridge.kt
@@ -0,0 +1,27 @@
+package com.example.crkl.accessibility
+
+/**
+ * Process-wide bridge so Quick Settings / shortcuts can open circle mode
+ * without binding to the accessibility service.
+ */
+object CrklOverlayBridge {
+ @Volatile
+ private var service: CrklAccessibilityService? = null
+
+ fun bind(svc: CrklAccessibilityService) {
+ service = svc
+ }
+
+ fun unbind(svc: CrklAccessibilityService) {
+ if (service === svc) service = null
+ }
+
+ fun isReady(): Boolean = service != null
+
+ /** @return true if circle mode was requested on a live service. */
+ fun enterCircleMode(): Boolean {
+ val svc = service ?: return false
+ svc.enterCircleModeFromShortcut()
+ return true
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/accessibility/OverlayView.kt b/app/src/main/kotlin/com/example/crkl/accessibility/OverlayView.kt
index de0280d..561ef9b 100644
--- a/app/src/main/kotlin/com/example/crkl/accessibility/OverlayView.kt
+++ b/app/src/main/kotlin/com/example/crkl/accessibility/OverlayView.kt
@@ -1,250 +1,395 @@
package com.example.crkl.accessibility
import android.content.Context
+import android.graphics.BlurMaskFilter
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PointF
+import android.graphics.RectF
+import android.os.Build
import android.util.Log
-import android.view.Gravity
-import android.view.View.MeasureSpec
+import android.util.TypedValue
+import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
-import android.widget.Toast
+import android.view.View.MeasureSpec
+import com.example.crkl.ui.CrklUi
import kotlin.math.sqrt
/**
- * Crkl Overlay View
- *
- * This view provides a floating action button that toggles to a full-screen overlay
- * for capturing touch gestures and circle selections. It reports selection bounds
- * for content analysis.
+ * Floating “C” that expands into a draw overlay.
+ * Stroke color / neon / thickness come from [applyStyle].
*/
class OverlayView(
context: Context,
- private val onModeChanged: (Boolean) -> Unit = {}
+ private val onModeChanged: (Boolean) -> Unit = {},
+ private val onSelectionComplete: (RectF) -> Unit = {}
) : View(context) {
-
- private val TAG = "OverlayView"
-
- // Button properties
- private val buttonSize = 80f
- private var buttonColor = Color.BLUE
- private val buttonPaint = Paint().apply {
- color = buttonColor
+
+ private val tagName = "OverlayView"
+
+ private val buttonSizePx = dp(56f)
+
+ private val buttonFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = CrklUi.Ink
style = Paint.Style.FILL
- isAntiAlias = true
}
-
- // Overlay mode properties
+ private val buttonRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = CrklUi.Teal
+ style = Paint.Style.STROKE
+ strokeWidth = dp(2.5f)
+ }
+ private val buttonShadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.parseColor("#55000000")
+ maskFilter = BlurMaskFilter(dp(10f), BlurMaskFilter.Blur.NORMAL)
+ }
+
private var isOverlayMode = false
- private val overlayPaint = Paint().apply {
- color = Color.argb(128, 0, 0, 0) // Semi-transparent black
+ private val dimPaint = Paint().apply {
+ color = CrklUi.Dim
style = Paint.Style.FILL
}
-
- private val instructionPaint = Paint().apply {
+
+ private val instructionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
- textSize = 48f
+ textSize = sp(14f)
textAlign = Paint.Align.CENTER
- isAntiAlias = true
}
-
- // Drawing properties
+ private val hintPillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.parseColor("#E61C2B38")
+ style = Paint.Style.FILL
+ }
+ private val hintRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = CrklUi.Teal
+ style = Paint.Style.STROKE
+ strokeWidth = dp(1.2f)
+ alpha = 160
+ }
+
private var isDrawing = false
private var touchPath = mutableListOf()
- private val pathPaint = Paint().apply {
- color = Color.YELLOW
- strokeWidth = 8f
+ private var hintMessage = "Draw a closed loop around text"
+ private var failFlashUntil = 0L
+
+ private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
- isAntiAlias = true
+ strokeCap = Paint.Cap.ROUND
+ strokeJoin = Paint.Join.ROUND
}
-
- // Selection bounds tracking
- private var selectionBounds: android.graphics.RectF? = null
- private val boundsPaint = Paint().apply {
- color = Color.GREEN
- strokeWidth = 4f
+ private val pathPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
- isAntiAlias = true
+ strokeCap = Paint.Cap.ROUND
+ strokeJoin = Paint.Join.ROUND
}
-
- private val textPaint = Paint().apply {
+ private val boundsPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = dp(1.5f)
+ color = Color.parseColor("#88FFFFFF")
+ }
+ private val fillFlashPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.FILL
+ color = Color.argb(40, 31, 167, 160)
+ }
+
+ private var selectionBounds: RectF? = null
+ private var neonEnabled = true
+ private var successFlash = false
+
+ private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
- textSize = 32f
+ textSize = sp(17f)
textAlign = Paint.Align.CENTER
- isAntiAlias = true
+ isFakeBoldText = true
}
-
+
+ private val exitBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.parseColor("#CC1C2B38")
+ style = Paint.Style.FILL
+ }
+ private val exitTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.WHITE
+ textSize = sp(13f)
+ textAlign = Paint.Align.CENTER
+ }
+
+ private val exitRect = RectF()
+ private var touchStartedInExit = false
+
init {
+ setLayerType(LAYER_TYPE_SOFTWARE, null)
setBackgroundColor(Color.TRANSPARENT)
isClickable = true
- isFocusable = true
- isFocusableInTouchMode = true
-
+ isFocusable = false
+ applyStyle(CrklUi.Teal, neon = true, strokeDp = 4.5f)
setOnClickListener {
- Log.d(TAG, "Crkl floating button clicked!")
- toggleOverlayMode()
+ if (!isOverlayMode) {
+ Log.d(tagName, "Crkl floating button clicked")
+ toggleOverlayMode()
+ }
}
-
- Log.d(TAG, "Simple floating button initialized")
}
-
- private fun toggleOverlayMode() {
- isOverlayMode = !isOverlayMode
- if (isOverlayMode) {
- Log.d(TAG, "Overlay mode activated - draw a circle to analyze content")
- showToast("Draw a circle to analyze content!")
+
+ fun applyStyle(colorArgb: Int, neon: Boolean, strokeDp: Float) {
+ neonEnabled = neon
+ pathPaint.color = colorArgb
+ pathPaint.strokeWidth = dp(strokeDp)
+ glowPaint.color = colorArgb
+ glowPaint.strokeWidth = dp(strokeDp + 6f)
+ glowPaint.alpha = if (neon) 120 else 0
+ glowPaint.maskFilter = if (neon) {
+ BlurMaskFilter(dp(12f), BlurMaskFilter.Blur.NORMAL)
} else {
- Log.d(TAG, "Overlay mode deactivated")
- showToast("Overlay mode disabled")
- touchPath.clear()
- selectionBounds = null
+ null
}
- onModeChanged(isOverlayMode)
invalidate()
}
-
- private fun showToast(message: String) {
- val toast = Toast.makeText(context, message, Toast.LENGTH_LONG)
- toast.setGravity(Gravity.TOP or Gravity.CENTER_HORIZONTAL, 0, 200)
- toast.show()
- Log.d(TAG, "Toast shown: $message")
+
+ fun isInOverlayMode(): Boolean = isOverlayMode
+
+ /** Enter draw mode from Quick Settings / Accessibility button. */
+ fun enterOverlayMode() {
+ if (isOverlayMode) return
+ isOverlayMode = true
+ touchPath.clear()
+ selectionBounds = null
+ touchStartedInExit = false
+ successFlash = false
+ hintMessage = "Draw a closed loop around text"
+ onModeChanged(true)
+ performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
+ invalidate()
}
-
+
+ fun exitOverlayMode() {
+ if (!isOverlayMode) return
+ isOverlayMode = false
+ touchPath.clear()
+ selectionBounds = null
+ touchStartedInExit = false
+ successFlash = false
+ hintMessage = "Draw a closed loop around text"
+ onModeChanged(false)
+ invalidate()
+ }
+
+ private fun toggleOverlayMode() {
+ isOverlayMode = !isOverlayMode
+ touchPath.clear()
+ selectionBounds = null
+ touchStartedInExit = false
+ successFlash = false
+ hintMessage = "Draw a closed loop around text"
+ onModeChanged(isOverlayMode)
+ if (isOverlayMode) {
+ performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
+ }
+ invalidate()
+ }
+
override fun onTouchEvent(event: MotionEvent?): Boolean {
if (!isOverlayMode) {
- // In button mode, only handle clicks on the button itself
return super.onTouchEvent(event)
}
-
- // In overlay mode, handle drawing
- event?.let { motionEvent ->
- when (motionEvent.action) {
- MotionEvent.ACTION_DOWN -> {
- isDrawing = true
- touchPath.clear()
+
+ val motionEvent = event ?: return true
+ updateExitRect()
+
+ when (motionEvent.action) {
+ MotionEvent.ACTION_DOWN -> {
+ touchStartedInExit = exitRect.contains(motionEvent.x, motionEvent.y)
+ if (touchStartedInExit) return true
+ isDrawing = true
+ successFlash = false
+ touchPath.clear()
+ selectionBounds = null
+ touchPath.add(PointF(motionEvent.x, motionEvent.y))
+ }
+ MotionEvent.ACTION_MOVE -> {
+ if (!touchStartedInExit && isDrawing) {
touchPath.add(PointF(motionEvent.x, motionEvent.y))
- Log.d(TAG, "Drawing started at (${motionEvent.x}, ${motionEvent.y})")
- }
- MotionEvent.ACTION_MOVE -> {
- if (isDrawing) {
- touchPath.add(PointF(motionEvent.x, motionEvent.y))
- }
- }
- MotionEvent.ACTION_UP -> {
- isDrawing = false
- Log.d(TAG, "Drawing ended. Path points: ${touchPath.size}")
-
- if (isCircleGesture()) {
- Log.d(TAG, "Circle detected!")
- calculateSelectionBounds()
- showToast("Circle detected! Analyzing content...")
- reportSelectionBounds()
- }
}
}
- invalidate()
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
+ if (touchStartedInExit && exitRect.contains(motionEvent.x, motionEvent.y)) {
+ toggleOverlayMode()
+ } else if (isDrawing) {
+ isDrawing = false
+ if (isClosedStroke()) {
+ calculateSelectionBounds()
+ selectionBounds?.let { localBounds ->
+ successFlash = true
+ performHapticFeedback(
+ if (Build.VERSION.SDK_INT >= 30) {
+ HapticFeedbackConstants.CONFIRM
+ } else {
+ HapticFeedbackConstants.LONG_PRESS
+ }
+ )
+ val screenBounds = toScreenBounds(localBounds)
+ Log.d(tagName, "Selection ready: $screenBounds")
+ // Brief flash then hand off — no toast spam
+ postDelayed({
+ onSelectionComplete(screenBounds)
+ }, 90)
+ }
+ } else {
+ hintMessage = "Close the loop to select"
+ failFlashUntil = System.currentTimeMillis() + 1400
+ performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
+ postDelayed({
+ hintMessage = "Draw a closed loop around text"
+ invalidate()
+ }, 1400)
+ }
+ }
+ touchStartedInExit = false
+ isDrawing = false
+ }
}
- return true // Consume touch events in overlay mode
+ invalidate()
+ return true
}
-
- private fun isCircleGesture(): Boolean {
- if (touchPath.size < 10) return false
-
- val firstPoint = touchPath.first()
- val lastPoint = touchPath.last()
- val dx = (lastPoint.x - firstPoint.x).toDouble()
- val dy = (lastPoint.y - firstPoint.y).toDouble()
- val distance = sqrt(dx * dx + dy * dy)
-
- return distance < 100f // Circle is closed if start and end are close
+
+ private fun updateExitRect() {
+ val pad = dp(16f)
+ val w = dp(88f)
+ val h = dp(36f)
+ exitRect.set(width - pad - w, pad, width - pad, pad + h)
}
-
+
+ private fun toScreenBounds(local: RectF): RectF {
+ val location = IntArray(2)
+ getLocationOnScreen(location)
+ return RectF(
+ local.left + location[0],
+ local.top + location[1],
+ local.right + location[0],
+ local.bottom + location[1]
+ )
+ }
+
+ private fun isClosedStroke(): Boolean {
+ if (touchPath.size < 12) return false
+ val first = touchPath.first()
+ val last = touchPath.last()
+ val closeDistance = sqrt(
+ (last.x - first.x) * (last.x - first.x) +
+ (last.y - first.y) * (last.y - first.y)
+ )
+ var pathLength = 0f
+ for (i in 1 until touchPath.size) {
+ val a = touchPath[i - 1]
+ val b = touchPath[i]
+ pathLength += sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y))
+ }
+ // Slightly more forgiving close distance for smoother feel
+ return closeDistance < 140f && pathLength > 160f
+ }
+
private fun calculateSelectionBounds() {
if (touchPath.isEmpty()) return
-
var minX = Float.MAX_VALUE
var maxX = Float.MIN_VALUE
var minY = Float.MAX_VALUE
var maxY = Float.MIN_VALUE
-
for (point in touchPath) {
minX = minOf(minX, point.x)
maxX = maxOf(maxX, point.x)
minY = minOf(minY, point.y)
maxY = maxOf(maxY, point.y)
}
-
- selectionBounds = android.graphics.RectF(minX, minY, maxX, maxY)
- Log.d(TAG, "Selection bounds calculated: $selectionBounds")
+ selectionBounds = RectF(minX, minY, maxX, maxY)
}
-
- private fun reportSelectionBounds() {
- selectionBounds?.let { bounds ->
- Log.d(TAG, "Selection bounds reported: left=${bounds.left}, top=${bounds.top}, right=${bounds.right}, bottom=${bounds.bottom}")
- Log.d(TAG, "Selection area: ${bounds.width()} x ${bounds.height()}")
- // TODO: In future, this will trigger content analysis at these bounds
- }
- }
-
+
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
-
- if (isOverlayMode) {
- drawOverlayMode(canvas)
- } else {
- drawButtonMode(canvas)
- }
+ if (isOverlayMode) drawOverlayMode(canvas) else drawButtonMode(canvas)
}
-
+
private fun drawButtonMode(canvas: Canvas) {
- // Draw button background
- val centerX = width / 2f
- val centerY = height / 2f
- canvas.drawCircle(centerX, centerY, buttonSize / 2, buttonPaint)
-
- // Draw "C" text
- val textY = centerY + (textPaint.textSize / 3)
- canvas.drawText("C", centerX, textY, textPaint)
+ val cx = width / 2f
+ val cy = height / 2f
+ val r = buttonSizePx / 2f
+ canvas.drawCircle(cx, cy + dp(2f), r, buttonShadowPaint)
+ canvas.drawCircle(cx, cy, r, buttonFillPaint)
+ canvas.drawCircle(cx, cy, r - dp(3f), buttonRingPaint)
+ canvas.drawText("C", cx, cy + textPaint.textSize / 3f, textPaint)
}
-
+
private fun drawOverlayMode(canvas: Canvas) {
- // Draw semi-transparent overlay
- canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), overlayPaint)
-
- // Draw instructions
- canvas.drawText("Draw a circle to analyze content", width / 2f, 200f, instructionPaint)
- canvas.drawText("Tap 'C' button again to exit", width / 2f, height - 200f, instructionPaint)
-
- // Draw the drawing path
+ canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), dimPaint)
+
+ val failing = System.currentTimeMillis() < failFlashUntil
+ val pillW = dp(280f)
+ val pillH = dp(38f)
+ val pill = RectF(
+ width / 2f - pillW / 2f,
+ dp(52f),
+ width / 2f + pillW / 2f,
+ dp(52f) + pillH
+ )
+ canvas.drawRoundRect(pill, dp(19f), dp(19f), hintPillPaint)
+ canvas.drawRoundRect(pill, dp(19f), dp(19f), hintRingPaint)
+ instructionPaint.color = if (failing) Color.parseColor("#FFCFC4") else Color.WHITE
+ canvas.drawText(
+ hintMessage,
+ pill.centerX(),
+ pill.centerY() + instructionPaint.textSize / 3f,
+ instructionPaint
+ )
+
+ updateExitRect()
+ canvas.drawRoundRect(exitRect, dp(18f), dp(18f), exitBgPaint)
+ canvas.drawText(
+ "Cancel",
+ exitRect.centerX(),
+ exitRect.centerY() + exitTextPaint.textSize / 3f,
+ exitTextPaint
+ )
+
if (touchPath.size > 1) {
for (i in 1 until touchPath.size) {
val prev = touchPath[i - 1]
val curr = touchPath[i]
+ if (neonEnabled) {
+ canvas.drawLine(prev.x, prev.y, curr.x, curr.y, glowPaint)
+ }
canvas.drawLine(prev.x, prev.y, curr.x, curr.y, pathPaint)
}
}
-
- // Draw selection bounds if available
+
selectionBounds?.let { bounds ->
- canvas.drawRect(bounds, boundsPaint)
- // Draw bounds info
- val boundsText = "Selection: ${bounds.width().toInt()}x${bounds.height().toInt()}"
- canvas.drawText(boundsText, bounds.centerX(), bounds.top - 20f, textPaint)
+ if (successFlash) {
+ canvas.drawRoundRect(bounds, dp(10f), dp(10f), fillFlashPaint)
+ }
+ canvas.drawRoundRect(bounds, dp(10f), dp(10f), boundsPaint)
}
}
-
+
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
if (isOverlayMode) {
- // In overlay mode, use full screen
setMeasuredDimension(
MeasureSpec.getSize(widthMeasureSpec),
MeasureSpec.getSize(heightMeasureSpec)
)
} else {
- // In button mode, use fixed button size
- setMeasuredDimension(buttonSize.toInt(), buttonSize.toInt())
+ val size = buttonSizePx.toInt()
+ setMeasuredDimension(size, size)
}
}
-}
\ No newline at end of file
+
+ private fun dp(value: Float): Float =
+ TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ value,
+ resources.displayMetrics
+ )
+
+ private fun sp(value: Float): Float =
+ TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_SP,
+ value,
+ resources.displayMetrics
+ )
+}
diff --git a/app/src/main/kotlin/com/example/crkl/agent/.gitkeep b/app/src/main/kotlin/com/example/crkl/agent/.gitkeep
deleted file mode 100644
index c828bf7..0000000
--- a/app/src/main/kotlin/com/example/crkl/agent/.gitkeep
+++ /dev/null
@@ -1,2 +0,0 @@
-# Dialogue state management
-
diff --git a/app/src/main/kotlin/com/example/crkl/agent/ActionExecutor.kt b/app/src/main/kotlin/com/example/crkl/agent/ActionExecutor.kt
new file mode 100644
index 0000000..f5fecc6
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/agent/ActionExecutor.kt
@@ -0,0 +1,266 @@
+package com.example.crkl.agent
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import com.example.crkl.integrations.DeviceCalendar
+import com.example.crkl.integrations.GogBridgeClient
+import com.example.crkl.integrations.IntegrationSettings
+import com.example.crkl.integrations.OnDeviceTranslator
+import com.example.crkl.integrations.RealEmailActions
+import com.example.crkl.integrations.VikunjaClient
+
+/**
+ * In-app actions for Circle Assist VIP: copy, explain, share, translate, Vikunja, etc.
+ */
+class ActionExecutor(
+ private val context: Context,
+ private val settings: IntegrationSettings = IntegrationSettings(context),
+ private val createTasks: suspend (titles: List, description: String) -> VikunjaClient.CreateResult =
+ { titles, description -> VikunjaClient(settings).createTasks(titles, description) },
+ private val sendViaGog: suspend (to: String, subject: String, body: String) -> GogBridgeClient.BridgeResult =
+ { to, subject, body -> GogBridgeClient(settings).sendEmail(to, subject, body) },
+ private val translateText: suspend (text: String, targetLang: String) -> OnDeviceTranslator.Result =
+ { text, target -> OnDeviceTranslator().translate(text, target) },
+ private val explainText: suspend (text: String) -> AssistEngine.AssistResponse =
+ { text ->
+ AssistEngine(null).explain(text)
+ },
+ private val todayCalendar: () -> DeviceCalendar.Result =
+ { DeviceCalendar.today(context) },
+ private val composeEmail: (subject: String, body: String, to: String) -> String =
+ { subject, body, to -> RealEmailActions.compose(context, subject, body, to) },
+ private val openTodosUrl: (url: String) -> String =
+ { url -> RealEmailActions.openWebTodos(context, url) },
+ private val launchIntent: (Intent) -> Unit = { context.startActivity(it) },
+ private val copyText: (String) -> Boolean = { text ->
+ try {
+ val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ cm.setPrimaryClip(ClipData.newPlainText("Crkl", text))
+ true
+ } catch (e: Exception) {
+ Log.e("ActionExecutor", "clipboard failed", e)
+ false
+ }
+ }
+) {
+ private val tagName = "ActionExecutor"
+
+ data class Session(
+ val title: String,
+ val body: String,
+ val transcript: String = "",
+ val workingList: MutableList = mutableListOf()
+ ) {
+ fun listItems(): List {
+ if (workingList.isNotEmpty()) return workingList.toList()
+ return VoiceIntent.extractListFromBody(body + "\n" + transcript)
+ }
+
+ fun todoItems(): List {
+ if (workingList.isNotEmpty()) return workingList.toList()
+ return VoiceIntent.extractTasksForTodo(title, body, transcript)
+ }
+
+ fun sharePayload(): String {
+ val items = todoItems()
+ if (items.size >= 2) {
+ return buildString {
+ appendLine(title.ifBlank { "Crkl" })
+ appendLine()
+ items.forEach { appendLine("• $it") }
+ }.trim()
+ }
+ return VoiceIntent.extractTextForTranslate(title, body, transcript)
+ .ifBlank { body.take(2_000).ifBlank { transcript } }
+ }
+ }
+
+ data class Result(
+ val ok: Boolean,
+ val message: String
+ )
+
+ suspend fun execute(intent: VoiceIntent.Parsed, session: Session?): Result {
+ return when (intent.kind) {
+ VoiceIntent.Kind.SHARE_LIST -> shareExtract(session)
+ VoiceIntent.Kind.EMAIL_LIST -> emailList(session)
+ VoiceIntent.Kind.OPEN_MAIL -> openMail()
+ VoiceIntent.Kind.OPEN_TODOS -> Result(true, openTodosUrl(settings.vikunjaUrl))
+ VoiceIntent.Kind.SHOW_CALENDAR -> showCalendar()
+ VoiceIntent.Kind.SUMMARIZE_EMAILS -> summarizeEmails(session)
+ VoiceIntent.Kind.ADD_TO_TODO -> addToTodo(session)
+ VoiceIntent.Kind.ADD_ITEMS -> addItems(session, intent.items)
+ VoiceIntent.Kind.SHOW_TODOS -> Result(true, openTodosUrl(settings.vikunjaUrl))
+ VoiceIntent.Kind.TRANSLATE -> translate(session, intent.targetLang)
+ VoiceIntent.Kind.COPY -> copyExtract(session)
+ VoiceIntent.Kind.EXPLAIN -> explain(session)
+ VoiceIntent.Kind.UNKNOWN -> Result(
+ false,
+ buildString {
+ appendLine("I heard: “${intent.raw}”")
+ appendLine()
+ appendLine("Try: copy · explain · share · translate · add to todo")
+ appendLine()
+ append(settings.statusSummary())
+ }
+ )
+ }
+ }
+
+ private fun copyExtract(session: Session?): Result {
+ if (session == null) {
+ return Result(false, "Nothing to copy — circle content first.")
+ }
+ val text = session.sharePayload()
+ if (text.isBlank()) {
+ return Result(false, "No text to copy.")
+ }
+ return if (copyText(text)) {
+ Result(true, "Copied ${text.length} characters to clipboard.")
+ } else {
+ Result(false, "Could not access clipboard.")
+ }
+ }
+
+ private suspend fun explain(session: Session?): Result {
+ if (session == null) {
+ return Result(false, "Nothing to explain — circle content first.")
+ }
+ val text = VoiceIntent.extractTextForTranslate(session.title, session.body, session.transcript)
+ val r = explainText(text)
+ return Result(true, "— explain —\n${r.body}")
+ }
+
+ private suspend fun translate(session: Session?, spokenTarget: String?): Result {
+ if (session == null) {
+ return Result(false, "Nothing to translate — circle a word or sentence first.")
+ }
+ val text = VoiceIntent.extractTextForTranslate(session.title, session.body, session.transcript)
+ val target = spokenTarget?.takeIf { it.isNotBlank() } ?: settings.translateTargetLang
+ val r = translateText(text, target)
+ return Result(r.ok, r.message)
+ }
+
+ private suspend fun emailList(session: Session?): Result {
+ if (session == null) {
+ return Result(false, "Nothing to email — circle content first.")
+ }
+ val payload = session.sharePayload()
+ val subject = session.title.ifBlank { "From Crkl" }
+ val to = settings.emailTo.ifBlank { "idobkin@gmail.com" }
+
+ if (settings.preferGogEmail && settings.gogReady()) {
+ val sent = sendViaGog(to, subject, payload)
+ if (sent.ok) return Result(true, sent.message)
+ Log.w(tagName, "gog send failed, mailto fallback: ${sent.message}")
+ val msg = composeEmail(subject, payload, to)
+ return Result(true, "gog failed (${sent.message}). $msg")
+ }
+
+ val msg = composeEmail(subject, payload, to)
+ return Result(true, msg)
+ }
+
+ private fun showCalendar(): Result {
+ val r = todayCalendar()
+ return Result(r.ok, r.message)
+ }
+
+ private fun openMail(): Result {
+ val to = settings.emailTo.ifBlank { "idobkin@gmail.com" }
+ return Result(true, composeEmail("", "", to))
+ }
+
+ private suspend fun addToTodo(session: Session?): Result {
+ if (session == null) {
+ return Result(false, "Nothing to save — circle something first.")
+ }
+ val items = session.todoItems()
+ if (items.isEmpty()) {
+ return Result(
+ false,
+ "Couldn’t find a task title in that circle. Try circling an email Subject " +
+ "or a bulleted list, then tap Add to Vikunja again."
+ )
+ }
+ Log.i(tagName, "addToTodo items=${items.size} first=${items.firstOrNull()}")
+ val desc = buildString {
+ appendLine("From Crkl")
+ if (session.transcript.isNotBlank()) {
+ appendLine()
+ append(session.transcript.take(500))
+ } else {
+ appendLine()
+ append(session.body.take(800))
+ }
+ }
+ return createTasks(items, desc).let { Result(it.ok, it.message) }
+ }
+
+ private suspend fun addItems(session: Session?, items: List): Result {
+ if (items.isEmpty()) {
+ return Result(false, "Didn’t catch which items to add.")
+ }
+ if (session != null) {
+ session.workingList.clear()
+ session.workingList.addAll(
+ (session.todoItems() + items).distinctBy { it.lowercase() }
+ )
+ }
+ return createTasks(items, "Added via Crkl voice").let { Result(it.ok, it.message) }
+ }
+
+ private fun summarizeEmails(session: Session?): Result {
+ val body = session?.body.orEmpty() + "\n" + session?.transcript.orEmpty()
+ val lower = body.lowercase()
+ if ("yesterday" in lower || "inbox" in lower || "from:" in lower || "subject:" in lower) {
+ val lines = body.lineSequence()
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .filter {
+ val l = it.lowercase()
+ l.startsWith("from:") || l.startsWith("subject:") ||
+ l.startsWith("•") || l.contains("@")
+ }
+ .take(12)
+ .toList()
+ return Result(
+ true,
+ buildString {
+ appendLine("From the circled inbox:")
+ if (lines.isEmpty()) appendLine(body.take(800)) else lines.forEach { appendLine(it) }
+ }
+ )
+ }
+ return Result(true, "Circle an inbox (Test fixtures → Mail), then ask again.")
+ }
+
+ private fun shareExtract(session: Session?): Result {
+ if (session == null) {
+ return Result(false, "Nothing to share — circle content first.")
+ }
+ val payload = session.sharePayload()
+ if (payload.isBlank()) {
+ return Result(false, "No text to share.")
+ }
+ return try {
+ val share = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_SUBJECT, session.title.ifBlank { "From Crkl" })
+ putExtra(Intent.EXTRA_TEXT, payload)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ val chooser = Intent.createChooser(share, "Share with Crkl").apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ launchIntent(chooser)
+ Result(true, "Opened share sheet (${payload.length} chars).")
+ } catch (e: Exception) {
+ Log.e(tagName, "share failed", e)
+ Result(false, "Could not open share sheet: ${e.message}")
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/agent/AssistEngine.kt b/app/src/main/kotlin/com/example/crkl/agent/AssistEngine.kt
new file mode 100644
index 0000000..c0c819f
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/agent/AssistEngine.kt
@@ -0,0 +1,191 @@
+package com.example.crkl.agent
+
+import android.util.Log
+import com.example.crkl.model.LocalLlm
+import com.example.crkl.model.ModelPaths
+import com.example.crkl.vision.ContentCapture
+import com.example.crkl.vision.RegionContentExtractor
+
+/**
+ * Routes extracted screen text / media transcripts to an on-device LLM when available,
+ * otherwise falls back to [LocalAssistStub].
+ */
+class AssistEngine(
+ private val llm: LocalLlm?,
+ private val maxInputChars: Int = 2_500
+) {
+ private val tagName = "AssistEngine"
+
+ enum class Mode { SCREEN, MEDIA }
+
+ data class AssistResponse(
+ val title: String,
+ val meta: String,
+ val body: String
+ )
+
+ suspend fun warmUp() {
+ val media = llm as? com.example.crkl.model.MediaPipeLocalLlm ?: return
+ runCatching { media.ensureLoaded() }
+ .onFailure { Log.w(tagName, "LLM warm-up failed", it) }
+ }
+
+ suspend fun respond(
+ extraction: RegionContentExtractor.ExtractionResult,
+ mode: Mode = Mode.SCREEN
+ ): AssistResponse {
+ if (extraction.isEmpty || !ContentCapture.hasUsefulText(extraction.text)) {
+ val stub = LocalAssistStub.respond(extraction)
+ return AssistResponse(stub.title, "", stub.body)
+ }
+
+ val llmEngine = llm
+ if (llmEngine == null || !ensureReady(llmEngine)) {
+ val stub = LocalAssistStub.respond(extraction, mode = mode)
+ try {
+ Log.i(tagName, "stub path (no LLM). ${ModelPaths.missingModelHint().lineSequence().firstOrNull()}")
+ } catch (_: RuntimeException) {
+ // Android Log is not mocked in JVM unit tests.
+ }
+ return AssistResponse(
+ title = stub.title,
+ meta = "",
+ body = stub.body
+ )
+ }
+
+ val prompt = buildPrompt(extraction.text, mode)
+ return try {
+ val generated = llmEngine.generate(prompt)
+ if (generated.isBlank()) {
+ fallbackWithNote(extraction, "Model returned empty output", mode)
+ } else {
+ AssistResponse(
+ title = if (mode == Mode.MEDIA) "On-device media summary" else "On-device summary",
+ meta = packageMeta(extraction) +
+ " · ${llmEngine.displayName} · ${extraction.text.length} chars in",
+ body = buildString {
+ appendLine(generated.trim())
+ appendLine()
+ appendLine("— source —")
+ appendLine()
+ append(extraction.text.trim().take(1_200))
+ if (extraction.text.length > 1_200) append("…")
+ }
+ )
+ }
+ } catch (e: Exception) {
+ Log.e(tagName, "LLM generate failed", e)
+ fallbackWithNote(extraction, "LLM error: ${e.message ?: e.javaClass.simpleName}", mode)
+ }
+ }
+
+ private suspend fun ensureReady(llmEngine: LocalLlm): Boolean {
+ if (llmEngine.isReady) return true
+ val media = llmEngine as? com.example.crkl.model.MediaPipeLocalLlm ?: return false
+ return media.ensureLoaded()
+ }
+
+ private fun fallbackWithNote(
+ extraction: RegionContentExtractor.ExtractionResult,
+ note: String,
+ mode: Mode = Mode.SCREEN
+ ): AssistResponse {
+ val stub = LocalAssistStub.respond(extraction, mode = mode)
+ return AssistResponse(
+ title = stub.title,
+ meta = stub.meta + " · stub",
+ body = buildString {
+ appendLine(note)
+ appendLine()
+ append(stub.body)
+ }
+ )
+ }
+
+ private fun buildPrompt(rawText: String, mode: Mode): String {
+ val clipped = rawText.trim().take(maxInputChars)
+ return when (mode) {
+ Mode.MEDIA -> """
+ You are Crkl, an on-device assistant. The user circled audio or video.
+ You are given a transcript of what was spoken. Summarize it and extract actions.
+ Rules:
+ - 2 to 5 short sentences
+ - If it is a list (groceries, todos), bullet the items
+ - If it is a how-to, list the steps in order
+ - Be concrete; do not invent facts not present in the transcript
+ - No markdown headings
+
+ Media transcript payload:
+ ---
+ $clipped
+ ---
+ """.trimIndent()
+ Mode.SCREEN -> """
+ You are Crkl, an on-device assistant. Summarize the on-screen text below for the user.
+ Rules:
+ - 2 to 4 short sentences
+ - Be concrete; do not invent facts not present in the text
+ - If the text is a message or email, say who/what it is about
+ - No markdown headings
+
+ On-screen text:
+ ---
+ $clipped
+ ---
+ """.trimIndent()
+ }
+ }
+
+ /**
+ * You are Crkl… explain prompt helper for AssistEngine.
+ */
+ suspend fun explain(text: String): AssistResponse {
+ val clipped = text.trim().take(maxInputChars)
+ if (clipped.isBlank()) {
+ return AssistResponse("Explain", "empty", "Nothing to explain — circle text first.")
+ }
+
+ val llmEngine = llm
+ if (llmEngine == null || !ensureReady(llmEngine)) {
+ val stub = LocalAssistStub.explain(clipped)
+ return AssistResponse(stub.title, stub.meta + " · stub", stub.body)
+ }
+
+ val prompt = """
+ You are Crkl, an on-device assistant. Explain the text below in plain language.
+ Rules:
+ - 2 to 4 short sentences
+ - Assume a smart adult who is busy — no jargon unless necessary
+ - Do not invent facts not in the text
+ - If it is an email or message, say who it is from and what they want
+ - No markdown headings
+
+ Text:
+ ---
+ $clipped
+ ---
+ """.trimIndent()
+
+ return try {
+ val generated = llmEngine.generate(prompt)
+ if (generated.isBlank()) {
+ val stub = LocalAssistStub.explain(clipped)
+ AssistResponse(stub.title, stub.meta + " · empty model", stub.body)
+ } else {
+ AssistResponse(
+ title = "Explain",
+ meta = "${llmEngine.displayName} · ${clipped.length} chars",
+ body = generated.trim()
+ )
+ }
+ } catch (e: Exception) {
+ Log.e(tagName, "explain failed", e)
+ val stub = LocalAssistStub.explain(clipped)
+ AssistResponse(stub.title, stub.meta + " · error", stub.body)
+ }
+ }
+
+ private fun packageMeta(extraction: RegionContentExtractor.ExtractionResult): String =
+ extraction.packageName?.substringAfterLast('.') ?: "unknown app"
+}
diff --git a/app/src/main/kotlin/com/example/crkl/agent/LocalAssistStub.kt b/app/src/main/kotlin/com/example/crkl/agent/LocalAssistStub.kt
new file mode 100644
index 0000000..a063a48
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/agent/LocalAssistStub.kt
@@ -0,0 +1,204 @@
+package com.example.crkl.agent
+
+import com.example.crkl.vision.ContentCapture
+import com.example.crkl.vision.RegionContentExtractor
+
+/**
+ * Temporary on-device "assist" path until a real local LLM is wired.
+ * Formats extracted screen text / media transcripts into a readable panel response.
+ * No network. No model weights.
+ */
+object LocalAssistStub {
+
+ data class AssistResponse(
+ val title: String,
+ val meta: String,
+ val body: String
+ )
+
+ fun respond(
+ extraction: RegionContentExtractor.ExtractionResult,
+ mode: AssistEngine.Mode = AssistEngine.Mode.SCREEN
+ ): AssistResponse {
+ if (extraction.isEmpty || !ContentCapture.hasUsefulText(extraction.text)) {
+ return AssistResponse(
+ title = "No text found",
+ meta = "",
+ body = buildString {
+ appendLine("Nothing useful to read in that circle.")
+ appendLine()
+ appendLine("Try circling a message, article, email, or label.")
+ appendLine()
+ appendLine("Photos and icons: use Google Lens — Circle is for on-screen text.")
+ }.trim()
+ )
+ }
+
+ val preview = extraction.text.trim()
+ if (mode == AssistEngine.Mode.MEDIA || preview.contains("Transcript:", ignoreCase = true)) {
+ return mediaResponse(extraction, preview)
+ }
+
+ val summary = heuristicSummary(preview)
+ return AssistResponse(
+ title = "Circled text",
+ meta = "",
+ body = buildString {
+ if (summary.isNotBlank()) {
+ appendLine(summary)
+ appendLine()
+ appendLine("—")
+ appendLine()
+ }
+ append(preview)
+ }
+ )
+ }
+
+ fun explain(text: String): AssistResponse {
+ val cleaned = text.trim()
+ if (cleaned.isBlank()) {
+ return AssistResponse(
+ title = "Explain",
+ meta = "empty",
+ body = "Nothing to explain — circle a word or sentence first."
+ )
+ }
+
+ val lower = cleaned.lowercase()
+ val kind = when {
+ "subject:" in lower || "from:" in lower -> "This looks like an email or message."
+ cleaned.lineSequence().count { it.trim().startsWith("•") || it.trim().startsWith("-") } >= 2 ->
+ "This looks like a list."
+ cleaned.length < 40 -> "This is a short phrase or label."
+ else -> "Here is a plain-language reading."
+ }
+
+ val first = cleaned.lineSequence().firstOrNull { line ->
+ val l = line.trim().lowercase()
+ l.isNotEmpty() && !l.startsWith("—") && !l.startsWith("local stub")
+ }?.trim().orEmpty()
+
+ val who = Regex("""(?i)from:\s*(.+)""").find(cleaned)?.groupValues?.getOrNull(1)?.trim()
+ val subject = Regex("""(?i)subject:\s*(.+)""").find(cleaned)?.groupValues?.getOrNull(1)?.trim()
+
+ val body = buildString {
+ appendLine(kind)
+ appendLine()
+ when {
+ who != null || subject != null -> {
+ if (who != null) appendLine("From: $who")
+ if (subject != null) appendLine("About: $subject")
+ appendLine()
+ appendLine(
+ "In short: someone is writing about “${subject ?: first.take(80)}”. " +
+ "Open the full text below the divider if you need details."
+ )
+ }
+ cleaned.length <= 120 -> {
+ appendLine("It means roughly: “$cleaned”")
+ appendLine()
+ appendLine("No hidden steps — take it at face value.")
+ }
+ else -> {
+ val head = first.take(140).ifBlank { cleaned.take(140) }
+ appendLine("Main idea: $head")
+ appendLine()
+ appendLine(
+ "Skim the original for names, dates, and numbers — those are usually the parts that matter."
+ )
+ }
+ }
+ }.trim()
+
+ return AssistResponse(
+ title = "Explain",
+ meta = "plain language · ${cleaned.length} chars",
+ body = body
+ )
+ }
+
+ private fun mediaResponse(
+ extraction: RegionContentExtractor.ExtractionResult,
+ preview: String
+ ): AssistResponse {
+ val transcript = preview.substringAfter("Transcript:", preview).trim()
+ val kind = when {
+ preview.contains("Kind: video", ignoreCase = true) -> "video"
+ preview.contains("Kind: audio", ignoreCase = true) -> "audio"
+ else -> "media"
+ }
+ val summary = mediaSummary(transcript, kind)
+ return AssistResponse(
+ title = if (kind == "video") "Video summary" else "Audio summary",
+ meta = packageMeta(extraction) + " · ${transcript.length} transcript chars",
+ body = buildString {
+ appendLine(summary)
+ appendLine()
+ appendLine("— transcript —")
+ appendLine()
+ append(transcript)
+ }
+ )
+ }
+
+ private fun mediaSummary(transcript: String, kind: String): String {
+ val lower = transcript.lowercase()
+ return when {
+ "grocery" in lower || "milk" in lower -> {
+ val items = extractListish(transcript)
+ buildString {
+ appendLine("Local stub listened to the $kind and pulled a shopping list:")
+ items.forEach { appendLine("• $it") }
+ if (items.isEmpty()) {
+ appendLine("• (see transcript)")
+ }
+ }.trim()
+ }
+ "settings" in lower && ("privacy" in lower || "save" in lower) -> {
+ buildString {
+ appendLine("Local stub listened to the $kind walkthrough:")
+ appendLine("1. Open Settings")
+ appendLine("2. Enable Privacy Mode")
+ appendLine("3. Tap Save")
+ appendLine("Takes under a minute.")
+ }.trim()
+ }
+ else -> {
+ val head = transcript.lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty()
+ "Local stub (no LLM yet) summary of $kind: " +
+ head.take(160).ifBlank { "see transcript below." }
+ }
+ }
+ }
+
+ private fun extractListish(transcript: String): List {
+ val known = listOf(
+ "milk", "eggs", "sourdough bread", "olive oil", "avocados",
+ "oat milk", "coffee beans", "bread", "oil"
+ )
+ val lower = transcript.lowercase()
+ return known.filter { it in lower }.distinct()
+ .map { it.replaceFirstChar { c -> c.uppercase() } }
+ }
+
+ private fun packageMeta(extraction: RegionContentExtractor.ExtractionResult): String =
+ extraction.packageName?.substringAfterLast('.') ?: "unknown app"
+
+ private fun heuristicSummary(text: String): String {
+ val firstLine = text.lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty()
+ val sentence = firstLine
+ .split(Regex("(?<=[.!?])\\s+"))
+ .firstOrNull()
+ ?.trim()
+ .orEmpty()
+
+ val head = when {
+ sentence.isNotEmpty() && sentence.length <= 160 -> sentence
+ firstLine.length <= 160 -> firstLine
+ else -> firstLine.take(157) + "…"
+ }
+
+ return head
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/agent/VoiceIntent.kt b/app/src/main/kotlin/com/example/crkl/agent/VoiceIntent.kt
new file mode 100644
index 0000000..a2e09ef
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/agent/VoiceIntent.kt
@@ -0,0 +1,265 @@
+package com.example.crkl.agent
+
+import com.example.crkl.integrations.OnDeviceTranslator
+
+/**
+ * Parses short spoken follow-ups after a circle/media result.
+ */
+object VoiceIntent {
+
+ enum class Kind {
+ SHARE_LIST,
+ EMAIL_LIST,
+ OPEN_MAIL,
+ OPEN_TODOS,
+ SHOW_CALENDAR,
+ SUMMARIZE_EMAILS,
+ ADD_TO_TODO,
+ ADD_ITEMS,
+ SHOW_TODOS,
+ TRANSLATE,
+ COPY,
+ EXPLAIN,
+ UNKNOWN
+ }
+
+ data class Parsed(
+ val kind: Kind,
+ val items: List = emptyList(),
+ /** BCP-47 target for TRANSLATE, else null → use settings default. */
+ val targetLang: String? = null,
+ val raw: String
+ )
+
+ fun parse(spoken: String): Parsed {
+ val raw = spoken.trim()
+ val lower = raw.lowercase()
+ .replace(Regex("[?.!,]"), " ")
+ .replace(Regex("\\s+"), " ")
+ .trim()
+
+ if (lower.isBlank()) return Parsed(Kind.UNKNOWN, raw = raw)
+
+ if (looksLikeTranslate(lower)) {
+ return Parsed(
+ Kind.TRANSLATE,
+ targetLang = OnDeviceTranslator.resolveSpokenTarget(lower),
+ raw = raw
+ )
+ }
+ if (looksLikeCopy(lower)) return Parsed(Kind.COPY, raw = raw)
+ if (looksLikeExplain(lower)) return Parsed(Kind.EXPLAIN, raw = raw)
+ if (looksLikeCalendar(lower)) return Parsed(Kind.SHOW_CALENDAR, raw = raw)
+ if (looksLikeShowTodos(lower)) return Parsed(Kind.SHOW_TODOS, raw = raw)
+ if (looksLikeOpenTodos(lower)) return Parsed(Kind.OPEN_TODOS, raw = raw)
+ if (looksLikeOpenMail(lower)) return Parsed(Kind.OPEN_MAIL, raw = raw)
+ if (looksLikeSummarizeEmails(lower)) return Parsed(Kind.SUMMARIZE_EMAILS, raw = raw)
+ if (looksLikeEmailList(lower)) return Parsed(Kind.EMAIL_LIST, raw = raw)
+ if (looksLikeShare(lower)) return Parsed(Kind.SHARE_LIST, raw = raw)
+ if (looksLikeAddToTodo(lower)) return Parsed(Kind.ADD_TO_TODO, raw = raw)
+
+ extractAddItems(lower)?.let { items ->
+ if (items.isNotEmpty()) return Parsed(Kind.ADD_ITEMS, items = items, raw = raw)
+ }
+
+ return Parsed(Kind.UNKNOWN, raw = raw)
+ }
+
+ private fun looksLikeTranslate(lower: String): Boolean {
+ return "translate" in lower || "translation" in lower ||
+ "перевед" in lower || "переклади" in lower
+ }
+
+ private fun looksLikeCopy(lower: String): Boolean {
+ return (
+ "copy" in lower || "clipboard" in lower || "скопир" in lower
+ ) && "email" !in lower && "share" !in lower
+ }
+
+ private fun looksLikeExplain(lower: String): Boolean {
+ return "explain" in lower || "eli5" in lower || "simplify" in lower ||
+ "what does this mean" in lower || "plain english" in lower ||
+ "объясн" in lower || "упрост" in lower
+ }
+
+ private fun looksLikeCalendar(lower: String): Boolean {
+ return "calendar" in lower || "schedule" in lower ||
+ ("what" in lower && ("today" in lower || "agenda" in lower)) ||
+ "what's on my cal" in lower || "whats on my cal" in lower
+ }
+
+ private fun looksLikeEmailList(lower: String): Boolean {
+ if ("email" !in lower && "gmail" !in lower && "mail me" !in lower) return false
+ return "list" in lower || "summary" in lower || "this" in lower ||
+ "that" in lower || "send" in lower || "me" in lower
+ }
+
+ private fun looksLikeOpenMail(lower: String): Boolean {
+ return (
+ "open" in lower || "go to" in lower || "launch" in lower
+ ) && (
+ "gmail" in lower || "inbox" in lower || "mail" in lower
+ ) && "list" !in lower && "todo" !in lower
+ }
+
+ private fun looksLikeOpenTodos(lower: String): Boolean {
+ return (
+ "open" in lower || "go to" in lower
+ ) && (
+ "todo" in lower || "vikunja" in lower || "tasks" in lower
+ ) && "add" !in lower
+ }
+
+ private fun looksLikeSummarizeEmails(lower: String): Boolean {
+ val emailCue = "email" in lower || "emails" in lower || "inbox" in lower || "mail" in lower
+ val timeCue = "yesterday" in lower || "last" in lower || "recent" in lower ||
+ "3" in lower || "three" in lower || "summar" in lower || "show" in lower || "read" in lower
+ return emailCue && timeCue && "todo" !in lower
+ }
+
+ private fun looksLikeShare(lower: String): Boolean {
+ val shareWords = listOf("send", "share", "text me", "message me", "forward")
+ val target = listOf("list", "summary", "transcript", "that", "it", "this", "result", "extract")
+ return shareWords.any { it in lower } && (
+ target.any { it in lower } || "me" in lower || lower.startsWith("send") || lower == "share"
+ ) && "email" !in lower && "gmail" !in lower && "mail" !in lower && "copy" !in lower
+ }
+
+ private fun looksLikeAddToTodo(lower: String): Boolean {
+ return (
+ "todo" in lower || "to do" in lower || "to-do" in lower ||
+ "vikunja" in lower || "task list" in lower || "my tasks" in lower
+ ) && (
+ "add" in lower || "save" in lower || "put" in lower ||
+ "update" in lower || "into" in lower
+ )
+ }
+
+ private fun looksLikeShowTodos(lower: String): Boolean {
+ return (
+ "show" in lower || "list my" in lower || "what's on" in lower ||
+ "what is on" in lower || "read my" in lower
+ ) && (
+ "todo" in lower || "to do" in lower || "tasks" in lower || "vikunja" in lower
+ )
+ }
+
+ private fun extractAddItems(lower: String): List? {
+ val patterns = listOf(
+ Regex("""(?:add|also get|also pick up|include)\s+(.+)$"""),
+ Regex("""(?:update(?:\s+the)?\s+list.*?(?:add|with))\s+(.+)$"""),
+ Regex("""(?:put)\s+(.+?)\s+(?:on|in|to)\s+(?:the\s+)?list""")
+ )
+ for (p in patterns) {
+ val m = p.find(lower) ?: continue
+ val chunk = m.groupValues.getOrNull(1)?.trim().orEmpty()
+ if (chunk.isBlank()) continue
+ if ("todo" in chunk || "to do" in chunk) continue
+ return splitItems(chunk)
+ }
+ return null
+ }
+
+ fun splitItems(chunk: String): List {
+ return chunk
+ .replace(Regex("""\band\b"""), ",")
+ .split(",")
+ .map { it.trim() }
+ .map { it.removePrefix("some ").removePrefix("a ").removePrefix("an ").trim() }
+ .filter { it.length in 2..48 }
+ .map { it.replaceFirstChar { c -> if (c.isLowerCase()) c.titlecase() else c.toString() } }
+ .distinct()
+ }
+
+ fun extractListFromBody(body: String): List {
+ val items = mutableListOf()
+ for (raw in body.lineSequence()) {
+ val line = raw.trim()
+ if (line.isEmpty()) continue
+ val marked = line.startsWith("•") || line.startsWith("-") || line.startsWith("*") ||
+ Regex("""^\d+[.)]\s+""").containsMatchIn(line)
+ if (!marked) continue
+ val cleaned = line
+ .removePrefix("•").removePrefix("-").removePrefix("*").trim()
+ .replace(Regex("""^\d+[.)]\s*"""), "")
+ .trim()
+ if (cleaned.length !in 2..60) continue
+ val lower = cleaned.lowercase()
+ if (lower.startsWith("local stub") ||
+ lower.startsWith("transcript") ||
+ lower.startsWith("media:") ||
+ lower.startsWith("kind:") ||
+ lower.startsWith("—") ||
+ lower.startsWith("stt:") ||
+ lower.contains("no on-device model") ||
+ lower.startsWith("from crkl") ||
+ lower.startsWith("commands:") ||
+ lower.startsWith("speak a command")
+ ) {
+ continue
+ }
+ items.add(cleaned)
+ }
+ return items.distinct()
+ }
+
+ /**
+ * Tasks for Vikunja: prefer bullets; otherwise a single task from Subject / title / first line.
+ */
+ fun extractTasksForTodo(title: String, body: String, transcript: String = ""): List {
+ val bullets = extractListFromBody(body + "\n" + transcript)
+ if (bullets.isNotEmpty()) return bullets.take(20)
+
+ val combined = (body + "\n" + transcript).lineSequence().map { it.trim() }.filter { it.isNotEmpty() }
+ val subject = combined.firstOrNull { it.startsWith("Subject:", ignoreCase = true) }
+ ?.substringAfter(':')?.trim()
+ if (!subject.isNullOrBlank() && subject.length in 2..120) {
+ return listOf(subject)
+ }
+
+ val skipPrefixes = listOf(
+ "from:", "to:", "local stub", "—", "commands:", "speak", "you said:",
+ "stt:", "kind:", "media:", "transcript"
+ )
+ val candidate = combined.firstOrNull { line ->
+ val l = line.lowercase()
+ skipPrefixes.none { l.startsWith(it) } &&
+ !l.startsWith("extracted") &&
+ line.length in 8..120
+ }
+ if (!candidate.isNullOrBlank()) return listOf(candidate.take(120))
+
+ val t = title.trim()
+ if (t.isNotBlank() && !t.equals("Crkl", ignoreCase = true) &&
+ !t.equals("Extracted text", ignoreCase = true)
+ ) {
+ return listOf(t.take(120))
+ }
+ return emptyList()
+ }
+
+ /** Text to translate: prefer short circled snippet, strip Crkl chrome. */
+ fun extractTextForTranslate(title: String, body: String, transcript: String = ""): String {
+ val raw = (body.ifBlank { transcript }).ifBlank { title }
+ val lines = raw.lineSequence()
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .filter { line ->
+ val l = line.lowercase()
+ !l.startsWith("—") &&
+ !l.startsWith("commands:") &&
+ !l.startsWith("speak") &&
+ !l.startsWith("you said:") &&
+ !l.startsWith("translated") &&
+ !l.startsWith("local stub") &&
+ !l.startsWith("stt:") &&
+ l != "extracted text"
+ }
+ .toList()
+ if (lines.isEmpty()) return raw.trim().take(1_500)
+ // Prefer the densest paragraph chunk after a summary separator
+ val afterSep = lines.dropWhile { it != "—" }.drop(1)
+ val chunk = if (afterSep.size >= 1) afterSep else lines
+ return chunk.joinToString("\n").take(1_500)
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/capture/CaptureForegroundService.kt b/app/src/main/kotlin/com/example/crkl/capture/CaptureForegroundService.kt
new file mode 100644
index 0000000..1ab0ccc
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/capture/CaptureForegroundService.kt
@@ -0,0 +1,109 @@
+package com.example.crkl.capture
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.media.projection.MediaProjectionManager
+import android.os.Build
+import android.os.IBinder
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import com.example.crkl.MainActivity
+
+/**
+ * Foreground service required to keep [android.media.projection.MediaProjection] alive.
+ */
+class CaptureForegroundService : Service() {
+
+ private val tagName = "CaptureFgs"
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_START -> {
+ ensureNotification()
+ val code = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
+ @Suppress("DEPRECATION")
+ val data = if (Build.VERSION.SDK_INT >= 33) {
+ intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
+ } else {
+ intent.getParcelableExtra(EXTRA_RESULT_DATA)
+ }
+ if (data == null) {
+ Log.e(tagName, "Missing projection result data")
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
+ val projection = mpm.getMediaProjection(code, data)
+ MediaProjectionHolder.set(projection)
+ Log.i(tagName, "MediaProjection ready")
+ }
+ ACTION_STOP -> {
+ MediaProjectionHolder.clear()
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ else -> ensureNotification()
+ }
+ return START_STICKY
+ }
+
+ private fun ensureNotification() {
+ createChannel()
+ val pending = PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("Crkl capture active")
+ .setContentText("Screen/audio capture permission is available for listen-in.")
+ .setSmallIcon(android.R.drawable.ic_btn_speak_now)
+ .setContentIntent(pending)
+ .setOngoing(true)
+ .build()
+
+ if (Build.VERSION.SDK_INT >= 29) {
+ startForeground(
+ NOTIF_ID,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
+ )
+ } else {
+ startForeground(NOTIF_ID, notification)
+ }
+ }
+
+ private fun createChannel() {
+ val nm = getSystemService(NotificationManager::class.java)
+ nm.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_ID,
+ "Crkl capture",
+ NotificationManager.IMPORTANCE_LOW
+ )
+ )
+ }
+
+ companion object {
+ const val ACTION_START = "com.example.crkl.capture.START"
+ const val ACTION_STOP = "com.example.crkl.capture.STOP"
+ const val EXTRA_RESULT_CODE = "result_code"
+ const val EXTRA_RESULT_DATA = "result_data"
+ private const val CHANNEL_ID = "crkl_capture"
+ private const val NOTIF_ID = 42
+
+ fun stop(context: Context) {
+ MediaProjectionHolder.clear()
+ context.stopService(Intent(context, CaptureForegroundService::class.java))
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/capture/MediaProjectionHolder.kt b/app/src/main/kotlin/com/example/crkl/capture/MediaProjectionHolder.kt
new file mode 100644
index 0000000..cae9613
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/capture/MediaProjectionHolder.kt
@@ -0,0 +1,24 @@
+package com.example.crkl.capture
+
+import android.media.projection.MediaProjection
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Process-wide holder for an active [MediaProjection] after user consent.
+ */
+object MediaProjectionHolder {
+ private val projectionRef = AtomicReference(null)
+
+ fun set(projection: MediaProjection?) {
+ projectionRef.getAndSet(projection)?.stop()
+ projectionRef.set(projection)
+ }
+
+ fun get(): MediaProjection? = projectionRef.get()
+
+ fun clear() {
+ set(null)
+ }
+
+ fun isReady(): Boolean = get() != null
+}
diff --git a/app/src/main/kotlin/com/example/crkl/capture/PlaybackCaptureRecorder.kt b/app/src/main/kotlin/com/example/crkl/capture/PlaybackCaptureRecorder.kt
new file mode 100644
index 0000000..dfe5fae
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/capture/PlaybackCaptureRecorder.kt
@@ -0,0 +1,152 @@
+package com.example.crkl.capture
+
+import android.media.AudioAttributes
+import android.media.AudioFormat
+import android.media.AudioPlaybackCaptureConfiguration
+import android.media.AudioRecord
+import android.media.projection.MediaProjection
+import android.os.Build
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+import java.io.RandomAccessFile
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+/**
+ * Captures other apps' media audio via [AudioPlaybackCaptureConfiguration] (API 29+).
+ * Writes a mono 16-bit PCM WAV suitable for later on-device file STT (Vosk/Whisper).
+ */
+class PlaybackCaptureRecorder {
+
+ private val tagName = "PlaybackCapture"
+
+ data class CaptureResult(
+ val wavFile: File,
+ val durationMs: Long,
+ val bytesCaptured: Int
+ )
+
+ suspend fun capture(
+ projection: MediaProjection,
+ outDir: File,
+ durationMs: Long = 8_000L,
+ sampleRate: Int = 16_000
+ ): CaptureResult? = withContext(Dispatchers.IO) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ Log.w(tagName, "AudioPlaybackCapture requires API 29+")
+ return@withContext null
+ }
+
+ val config = AudioPlaybackCaptureConfiguration.Builder(projection)
+ .addMatchingUsage(AudioAttributes.USAGE_MEDIA)
+ .addMatchingUsage(AudioAttributes.USAGE_GAME)
+ .addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
+ .build()
+
+ val format = AudioFormat.Builder()
+ .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
+ .setSampleRate(sampleRate)
+ .setChannelMask(AudioFormat.CHANNEL_IN_MONO)
+ .build()
+
+ val minBuf = AudioRecord.getMinBufferSize(
+ sampleRate,
+ AudioFormat.CHANNEL_IN_MONO,
+ AudioFormat.ENCODING_PCM_16BIT
+ ).coerceAtLeast(sampleRate / 2)
+
+ val recorder = try {
+ AudioRecord.Builder()
+ .setAudioFormat(format)
+ .setBufferSizeInBytes(minBuf * 2)
+ .setAudioPlaybackCaptureConfig(config)
+ .build()
+ } catch (e: SecurityException) {
+ Log.e(tagName, "AudioPlaybackCapture not permitted", e)
+ return@withContext null
+ } catch (e: Exception) {
+ Log.e(tagName, "AudioRecord build failed", e)
+ return@withContext null
+ }
+
+ if (recorder.state != AudioRecord.STATE_INITIALIZED) {
+ Log.e(tagName, "AudioRecord not initialized")
+ recorder.release()
+ return@withContext null
+ }
+
+ outDir.mkdirs()
+ val out = File(outDir, "playback_capture_${System.currentTimeMillis()}.wav")
+ val pcm = ArrayList()
+ var total = 0
+ val buf = ByteArray(minBuf)
+
+ try {
+ recorder.startRecording()
+ val deadline = System.currentTimeMillis() + durationMs
+ while (System.currentTimeMillis() < deadline) {
+ val n = recorder.read(buf, 0, buf.size)
+ if (n > 0) {
+ pcm.add(buf.copyOf(n))
+ total += n
+ } else if (n < 0) {
+ Log.w(tagName, "AudioRecord read error $n")
+ break
+ }
+ }
+ } finally {
+ try {
+ recorder.stop()
+ } catch (_: Exception) {
+ }
+ recorder.release()
+ }
+
+ if (total == 0) {
+ Log.w(tagName, "No playback audio captured (app may block capture or silent)")
+ return@withContext null
+ }
+
+ writeWav(out, pcm, sampleRate, total)
+ val actualMs = (total / 2.0 / sampleRate * 1000.0).toLong()
+ Log.i(tagName, "Captured ${total}B (~${actualMs}ms) → ${out.name}")
+ CaptureResult(out, actualMs, total)
+ }
+
+ private fun writeWav(file: File, chunks: List, sampleRate: Int, dataBytes: Int) {
+ FileOutputStream(file).use { fos ->
+ // Placeholder header; rewrite sizes after.
+ fos.write(ByteArray(44))
+ for (c in chunks) fos.write(c)
+ }
+ RandomAccessFile(file, "rw").use { raf ->
+ raf.seek(0)
+ raf.writeBytes("RIFF")
+ writeIntLE(raf, 36 + dataBytes)
+ raf.writeBytes("WAVE")
+ raf.writeBytes("fmt ")
+ writeIntLE(raf, 16)
+ writeShortLE(raf, 1) // PCM
+ writeShortLE(raf, 1) // mono
+ writeIntLE(raf, sampleRate)
+ writeIntLE(raf, sampleRate * 2)
+ writeShortLE(raf, 2)
+ writeShortLE(raf, 16)
+ raf.writeBytes("data")
+ writeIntLE(raf, dataBytes)
+ }
+ }
+
+ private fun writeIntLE(raf: RandomAccessFile, value: Int) {
+ val buf = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array()
+ raf.write(buf)
+ }
+
+ private fun writeShortLE(raf: RandomAccessFile, value: Int) {
+ val buf = ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(value.toShort()).array()
+ raf.write(buf)
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/capture/ProjectionPermissionActivity.kt b/app/src/main/kotlin/com/example/crkl/capture/ProjectionPermissionActivity.kt
new file mode 100644
index 0000000..7c605b1
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/capture/ProjectionPermissionActivity.kt
@@ -0,0 +1,54 @@
+package com.example.crkl.capture
+
+import androidx.core.content.ContextCompat
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.media.projection.MediaProjectionManager
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.result.contract.ActivityResultContracts
+
+/**
+ * One-shot consent UI for MediaProjection (required for AudioPlaybackCapture
+ * of other apps' audio and optional screen buffers).
+ */
+class ProjectionPermissionActivity : ComponentActivity() {
+
+ private val tagName = "ProjectionPermission"
+
+ private val launcher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK && result.data != null) {
+ val data = result.data!!
+ // Start FGS first (Android 14+), then obtain projection inside the service.
+ val svc = Intent(this, CaptureForegroundService::class.java).apply {
+ action = CaptureForegroundService.ACTION_START
+ putExtra(CaptureForegroundService.EXTRA_RESULT_CODE, result.resultCode)
+ putExtra(CaptureForegroundService.EXTRA_RESULT_DATA, data)
+ }
+ ContextCompat.startForegroundService(this, svc)
+ Log.i(tagName, "MediaProjection consent granted; starting capture service")
+ setResult(Activity.RESULT_OK)
+ } else {
+ Log.w(tagName, "MediaProjection consent denied")
+ setResult(Activity.RESULT_CANCELED)
+ }
+ finish()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
+ launcher.launch(mpm.createScreenCaptureIntent())
+ }
+
+ companion object {
+ fun intent(context: Context): Intent =
+ Intent(context, ProjectionPermissionActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/fixtures/FixtureCatalog.kt b/app/src/main/kotlin/com/example/crkl/fixtures/FixtureCatalog.kt
new file mode 100644
index 0000000..5fdff3e
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/fixtures/FixtureCatalog.kt
@@ -0,0 +1,187 @@
+package com.example.crkl.fixtures
+
+/**
+ * Catalog of on-device test targets for Crkl circle → extract demos.
+ * Used by [TestFixturesActivity] and unit tests.
+ */
+object FixtureCatalog {
+
+ enum class ContentKind {
+ EMAIL,
+ ARTICLE,
+ IMAGE,
+ VIDEO,
+ AUDIO
+ }
+
+ enum class Expectation {
+ /** Accessibility text should be extracted today. */
+ EXTRACTABLE_NOW,
+ /** Needs OCR / MediaProjection — expect empty or weak extract. */
+ NEEDS_OCR,
+ /** Real playable media → listen/transcribe → summarize. */
+ MEDIA_PLAY_AND_SUMMARIZE,
+ /** Mic STT path (Speak on panel). */
+ NEEDS_STT
+ }
+
+ data class Fixture(
+ val id: String,
+ val kind: ContentKind,
+ val title: String,
+ val body: String,
+ val contentDescription: String? = null,
+ val expectation: Expectation,
+ val howToTest: String,
+ /** Asset under app assets/, e.g. media/grocery_memo.wav */
+ val mediaAssetPath: String? = null,
+ val transcriptAssetPath: String? = null
+ )
+
+ val all: List = listOf(
+ Fixture(
+ id = "email_meeting",
+ kind = ContentKind.EMAIL,
+ title = "Email · Meeting reschedule",
+ body = """
+ From: alex@example.com
+ To: you@levkin.ca
+ Subject: Q2 planning moved
+
+ Hi — the Q2 planning sync is moved to Thursday at 3pm.
+ Please bring the notes from last sprint and the budget draft.
+ Thanks,
+ Alex
+ """.trimIndent(),
+ expectation = Expectation.EXTRACTABLE_NOW,
+ howToTest = "DEMO 3: Circle Subject “Q2 planning moved” → Vikunja. Or whole email → Explain."
+ ),
+ Fixture(
+ id = "article_paragraph",
+ kind = ContentKind.ARTICLE,
+ title = "Article · Short paragraph",
+ body = """
+ Crkl is a privacy-first Android assistant. Users circle on-screen
+ content; the app reads accessibility text and summarizes it with
+ an on-device model when available.
+ """.trimIndent(),
+ expectation = Expectation.EXTRACTABLE_NOW,
+ howToTest = "DEMO 2: Circle the paragraph → Copy, then Explain."
+ ),
+ Fixture(
+ id = "image_with_description",
+ kind = ContentKind.IMAGE,
+ title = "Image · Has contentDescription",
+ body = "Photo placeholder (described for a11y)",
+ contentDescription = "Sunset over a lake with a wooden dock and pine trees",
+ expectation = Expectation.EXTRACTABLE_NOW,
+ howToTest = "Circle the teal image block. Extract should include the description text."
+ ),
+ Fixture(
+ id = "image_pixels_only",
+ kind = ContentKind.IMAGE,
+ title = "Image · Pixels only (OCR target)",
+ body = "",
+ contentDescription = null,
+ expectation = Expectation.NEEDS_OCR,
+ howToTest = "Circle the dark block that says OCR-HELLO-42. Panel meta should mention ocr."
+ ),
+ Fixture(
+ id = "mail_inbox_yesterday",
+ kind = ContentKind.EMAIL,
+ title = "Mail · Last 3 emails (yesterday)",
+ body = SampleInbox.text(),
+ contentDescription = "Sample inbox with three emails from yesterday",
+ expectation = Expectation.EXTRACTABLE_NOW,
+ howToTest = "Circle inbox text → summarize. After a list: Email list opens your mail app (mailto)."
+ ),
+ Fixture(
+ id = "translate_phrase",
+ kind = ContentKind.ARTICLE,
+ title = "Translate · English phrase",
+ body = "The meeting is postponed until Thursday afternoon.",
+ expectation = Expectation.EXTRACTABLE_NOW,
+ howToTest = "DEMO 1: Circle the sentence → Translate (default → Russian)."
+ ),
+ Fixture(
+ id = "grocery_list",
+ kind = ContentKind.ARTICLE,
+ title = "List · Groceries (Vikunja / Share)",
+ body = """
+ Shopping
+ • Milk
+ • Eggs
+ • Olive oil
+ • Sourdough bread
+ """.trimIndent(),
+ expectation = Expectation.EXTRACTABLE_NOW,
+ howToTest = "DEMO 4: Circle the bullets → Share or Vikunja (creates 4 tasks)."
+ ),
+ Fixture(
+ id = "video_walkthrough",
+ kind = ContentKind.VIDEO,
+ title = "Video · Product walkthrough",
+ body = "▶ Demo clip: product walkthrough (real audio)",
+ contentDescription = "Crkl playable video: product walkthrough",
+ expectation = Expectation.MEDIA_PLAY_AND_SUMMARIZE,
+ howToTest = "Tap Play (optional), then C → circle the video. Crkl plays/listens, transcribes, summarizes steps.",
+ mediaAssetPath = "media/product_walkthrough.mp4",
+ transcriptAssetPath = "media/product_walkthrough.transcript.txt"
+ ),
+ Fixture(
+ id = "audio_grocery",
+ kind = ContentKind.AUDIO,
+ title = "Audio · Grocery voice memo",
+ body = "♫ Voice memo: grocery list (real audio)",
+ contentDescription = "Crkl playable audio: grocery voice memo",
+ expectation = Expectation.MEDIA_PLAY_AND_SUMMARIZE,
+ howToTest = "Tap Play (optional), then C → circle the audio. Crkl listens, transcribes, extracts the shopping list.",
+ mediaAssetPath = "media/grocery_memo.wav",
+ transcriptAssetPath = "media/grocery_memo.transcript.txt"
+ )
+ )
+
+ fun byId(id: String): Fixture =
+ all.first { it.id == id }
+
+ fun extractableNow(): List =
+ all.filter { it.expectation == Expectation.EXTRACTABLE_NOW }
+
+ fun mediaFixtures(): List =
+ all.filter { it.mediaAssetPath != null }
+
+ /** Ordered VIP demo path for ship / recording (see docs/DEMO.md). */
+ fun demoScript(): List = listOf(
+ DemoStep(
+ order = 1,
+ fixtureId = "translate_phrase",
+ chip = "Translate",
+ say = "Circle the English sentence → tap Translate"
+ ),
+ DemoStep(
+ order = 2,
+ fixtureId = "article_paragraph",
+ chip = "Copy / Explain",
+ say = "Circle the paragraph → Copy, then Explain"
+ ),
+ DemoStep(
+ order = 3,
+ fixtureId = "email_meeting",
+ chip = "Vikunja",
+ say = "Circle the Subject line (or whole email) → Add to Vikunja"
+ ),
+ DemoStep(
+ order = 4,
+ fixtureId = "grocery_list",
+ chip = "Share / Vikunja",
+ say = "Circle the bulleted list → Share or Vikunja"
+ )
+ )
+
+ data class DemoStep(
+ val order: Int,
+ val fixtureId: String,
+ val chip: String,
+ val say: String
+ )
+}
diff --git a/app/src/main/kotlin/com/example/crkl/fixtures/PlayableMediaFixture.kt b/app/src/main/kotlin/com/example/crkl/fixtures/PlayableMediaFixture.kt
new file mode 100644
index 0000000..e0bff32
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/fixtures/PlayableMediaFixture.kt
@@ -0,0 +1,224 @@
+package com.example.crkl.fixtures
+
+import android.media.MediaPlayer
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.VideoView
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+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.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import com.example.crkl.media.ActiveMediaRegistry
+import java.io.File
+import java.io.FileOutputStream
+
+/**
+ * Real playable video/audio fixture that registers with [ActiveMediaRegistry]
+ * so circling it triggers listen → transcript → summarize.
+ */
+@Composable
+fun PlayableMediaFixture(
+ fixture: FixtureCatalog.Fixture,
+ accent: Color
+) {
+ val assetPath = fixture.mediaAssetPath ?: return
+ val transcriptPath = fixture.transcriptAssetPath ?: return
+ val context = LocalContext.current
+ val kind = when (fixture.kind) {
+ FixtureCatalog.ContentKind.VIDEO -> ActiveMediaRegistry.Kind.VIDEO
+ else -> ActiveMediaRegistry.Kind.AUDIO
+ }
+
+ DisposableEffect(fixture.id) {
+ ActiveMediaRegistry.register(
+ ActiveMediaRegistry.Slot(
+ id = fixture.id,
+ kind = kind,
+ assetPath = assetPath,
+ transcriptAssetPath = transcriptPath,
+ title = fixture.title
+ )
+ )
+ onDispose { ActiveMediaRegistry.unregister(fixture.id) }
+ }
+
+ var playing by remember { mutableStateOf(false) }
+ var status by remember { mutableStateOf("Ready · tap Play or circle with C") }
+ var mediaPlayer by remember { mutableStateOf(null) }
+ var videoView by remember { mutableStateOf(null) }
+
+ fun stopPlayback() {
+ try {
+ mediaPlayer?.stop()
+ } catch (_: Exception) {
+ }
+ mediaPlayer?.release()
+ mediaPlayer = null
+ try {
+ videoView?.stopPlayback()
+ } catch (_: Exception) {
+ }
+ playing = false
+ }
+
+ fun play() {
+ stopPlayback()
+ val cache = copyAsset(context, assetPath)
+ if (kind == ActiveMediaRegistry.Kind.VIDEO) {
+ val vv = videoView ?: return
+ vv.setOnCompletionListener {
+ playing = false
+ status = "Finished"
+ }
+ vv.setVideoPath(cache.absolutePath)
+ vv.start()
+ playing = true
+ status = "Playing video…"
+ } else {
+ val mp = MediaPlayer()
+ mediaPlayer = mp
+ mp.setDataSource(cache.absolutePath)
+ mp.setOnCompletionListener {
+ playing = false
+ status = "Finished"
+ mp.release()
+ mediaPlayer = null
+ }
+ mp.prepare()
+ mp.start()
+ playing = true
+ status = "Playing audio…"
+ }
+ }
+
+ DisposableEffect(Unit) {
+ onDispose { stopPlayback() }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(accent, RoundedCornerShape(8.dp))
+ .onGloballyPositioned { coords ->
+ val b = coords.boundsInWindow()
+ ActiveMediaRegistry.updateBounds(
+ fixture.id,
+ ActiveMediaRegistry.Bounds(b.left, b.top, b.right, b.bottom)
+ )
+ }
+ .semantics {
+ contentDescription = fixture.contentDescription
+ ?: "Crkl playable ${kind.name.lowercase()}: ${fixture.title}"
+ }
+ .padding(12.dp)
+ ) {
+ Text(
+ text = fixture.body,
+ color = Color.White,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+
+ if (kind == ActiveMediaRegistry.Kind.VIDEO) {
+ AndroidView(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(180.dp),
+ factory = { ctx ->
+ FrameLayout(ctx).apply {
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+ val vv = VideoView(ctx)
+ videoView = vv
+ addView(
+ vv,
+ FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT
+ )
+ )
+ setBackgroundColor(0xFF2A1540.toInt())
+ }
+ }
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(72.dp)
+ .background(Color.Black.copy(alpha = 0.25f), RoundedCornerShape(8.dp)),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = if (playing) "♪ Playing…" else "♪ Audio ready",
+ color = Color.White,
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ Button(
+ onClick = {
+ if (playing) {
+ stopPlayback()
+ status = "Stopped"
+ } else {
+ play()
+ }
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color.White.copy(alpha = 0.2f),
+ contentColor = Color.White
+ )
+ ) {
+ Text(if (playing) "Stop" else "Play")
+ }
+ Text(
+ text = status,
+ color = Color.White.copy(alpha = 0.85f),
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+ }
+}
+
+private fun copyAsset(context: android.content.Context, assetPath: String): File {
+ val name = assetPath.substringAfterLast('/')
+ val out = File(context.cacheDir, "fixture_$name")
+ if (!out.exists() || out.length() == 0L) {
+ context.assets.open(assetPath).use { input ->
+ FileOutputStream(out).use { output -> input.copyTo(output) }
+ }
+ }
+ return out
+}
diff --git a/app/src/main/kotlin/com/example/crkl/fixtures/SampleInbox.kt b/app/src/main/kotlin/com/example/crkl/fixtures/SampleInbox.kt
new file mode 100644
index 0000000..02f714d
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/fixtures/SampleInbox.kt
@@ -0,0 +1,24 @@
+package com.example.crkl.fixtures
+
+/**
+ * Static sample inbox text for circle/extract demos in [TestFixturesActivity].
+ * Not a mail product — real send uses the device mail app (mailto).
+ */
+object SampleInbox {
+
+ fun text(): String = """
+ Mail · Inbox · Yesterday
+
+ From: jordan@example.com
+ Subject: School pickup tomorrow
+ Can you do pickup at 3:30?
+
+ From: billing@utility.example
+ Subject: Your July statement is ready
+ Account ending 4421 — amount due $86.40
+
+ From: sam@example.com
+ Subject: Dinner Friday?
+ Thinking Thai place at 7
+ """.trimIndent()
+}
diff --git a/app/src/main/kotlin/com/example/crkl/fixtures/TestFixturesActivity.kt b/app/src/main/kotlin/com/example/crkl/fixtures/TestFixturesActivity.kt
new file mode 100644
index 0000000..21c1a6a
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/fixtures/TestFixturesActivity.kt
@@ -0,0 +1,247 @@
+package com.example.crkl.fixtures
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTag
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import android.view.Gravity
+import android.view.View
+import android.widget.TextView
+import com.example.crkl.ui.theme.CrklTheme
+
+/**
+ * Scrollable playground with email / article / image / video / audio samples.
+ * Enable Crkl accessibility, tap C, circle each card to validate extract behavior.
+ */
+class TestFixturesActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ CrklTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ TestFixturesScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun TestFixturesScreen() {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = "Test fixtures · VIP demo",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = "Ship demo (≈45s)",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = FixtureCatalog.demoScript().joinToString("\n") { step ->
+ "${step.order}. ${step.say} [${step.chip}]"
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "How to circle",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = "1. Tap the floating C (or Quick Settings → Circle). Screen dims.\n" +
+ "2. Draw a closed loop around text. Sheet slides up with chips.\n" +
+ "3. Cancel (top-right) leaves draw mode so you can scroll.\n\n" +
+ "Chips: Translate · Copy · Explain · Share · Vikunja\n" +
+ "Full script: docs/DEMO.md",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ FixtureCatalog.all.forEach { fixture ->
+ FixtureCard(fixture)
+ }
+
+ Spacer(modifier = Modifier.height(48.dp))
+ }
+}
+
+@Composable
+private fun FixtureCard(fixture: FixtureCatalog.Fixture) {
+ val badge = when (fixture.expectation) {
+ FixtureCatalog.Expectation.EXTRACTABLE_NOW -> "SHOULD EXTRACT NOW"
+ FixtureCatalog.Expectation.NEEDS_OCR -> "NEEDS OCR"
+ FixtureCatalog.Expectation.MEDIA_PLAY_AND_SUMMARIZE -> "PLAY → LISTEN → SUMMARIZE"
+ FixtureCatalog.Expectation.NEEDS_STT -> "NEEDS MIC STT"
+ }
+ val badgeColor = when (fixture.expectation) {
+ FixtureCatalog.Expectation.EXTRACTABLE_NOW -> Color(0xFF1B7F4E)
+ FixtureCatalog.Expectation.NEEDS_OCR -> Color(0xFF9A5B00)
+ FixtureCatalog.Expectation.MEDIA_PLAY_AND_SUMMARIZE -> Color(0xFF1F4E79)
+ FixtureCatalog.Expectation.NEEDS_STT -> Color(0xFF8A3A00)
+ }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics {
+ testTag = "fixture_${fixture.id}"
+ fixture.contentDescription?.let { contentDescription = it }
+ },
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ ),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Column(modifier = Modifier.padding(14.dp)) {
+ Text(
+ text = badge,
+ color = badgeColor,
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ Text(
+ text = fixture.title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = fixture.howToTest,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+
+ when (fixture.kind) {
+ FixtureCatalog.ContentKind.EMAIL,
+ FixtureCatalog.ContentKind.ARTICLE -> {
+ Text(
+ text = fixture.body,
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = if (fixture.kind == FixtureCatalog.ContentKind.EMAIL) {
+ FontFamily.Monospace
+ } else {
+ FontFamily.Default
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ MaterialTheme.colorScheme.surface,
+ RoundedCornerShape(8.dp)
+ )
+ .padding(12.dp)
+ .semantics { testTag = "fixture_body_${fixture.id}" }
+ )
+ }
+ FixtureCatalog.ContentKind.IMAGE -> {
+ val desc = fixture.contentDescription
+ if (desc == null && fixture.id == "image_pixels_only") {
+ // Painted glyphs with a11y disabled — forces OCR path.
+ OcrOnlySample()
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(140.dp)
+ .background(
+ if (desc == null) Color(0xFF3A3A3A) else Color(0xFF1F6F8B),
+ RoundedCornerShape(8.dp)
+ )
+ .border(2.dp, Color.White.copy(alpha = 0.25f), RoundedCornerShape(8.dp))
+ .then(
+ if (desc != null) {
+ Modifier.semantics { contentDescription = desc }
+ } else {
+ Modifier
+ }
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = if (desc != null) "🖼 described image" else "🖼 pixels only",
+ color = Color.White,
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ }
+ if (fixture.body.isNotBlank()) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(text = fixture.body, style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ FixtureCatalog.ContentKind.VIDEO -> {
+ PlayableMediaFixture(
+ fixture = fixture,
+ accent = Color(0xFF5B2C6F)
+ )
+ }
+ FixtureCatalog.ContentKind.AUDIO -> {
+ PlayableMediaFixture(
+ fixture = fixture,
+ accent = Color(0xFF1A5276)
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun OcrOnlySample() {
+ AndroidView(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(140.dp),
+ factory = { ctx ->
+ TextView(ctx).apply {
+ text = "OCR-HELLO-42"
+ textSize = 28f
+ setTextColor(android.graphics.Color.WHITE)
+ gravity = Gravity.CENTER
+ setBackgroundColor(android.graphics.Color.parseColor("#3A3A3A"))
+ // Hide from accessibility so capture must use OCR.
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+ }
+ }
+ )
+}
diff --git a/app/src/main/kotlin/com/example/crkl/integrations/DeviceCalendar.kt b/app/src/main/kotlin/com/example/crkl/integrations/DeviceCalendar.kt
new file mode 100644
index 0000000..30dde04
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/integrations/DeviceCalendar.kt
@@ -0,0 +1,108 @@
+package com.example.crkl.integrations
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.provider.CalendarContract
+import androidx.core.content.ContextCompat
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/**
+ * Reads today's events from the device calendar (CalendarContract).
+ * Works with whatever accounts the user has synced (Google, Exchange, etc.) —
+ * no host bridge, no Google OAuth client in the APK.
+ */
+object DeviceCalendar {
+
+ data class Event(val whenLabel: String, val title: String)
+
+ data class Result(val ok: Boolean, val message: String, val events: List = emptyList())
+
+ fun hasPermission(context: Context): Boolean =
+ ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
+ PackageManager.PERMISSION_GRANTED
+
+ fun today(context: Context, limit: Int = 20): Result {
+ if (!hasPermission(context)) {
+ return Result(
+ false,
+ "Calendar permission needed. Open Integrations → Grant calendar access, " +
+ "or enable it in Android Settings → Apps → Crkl → Permissions."
+ )
+ }
+
+ val zone = TimeZone.getDefault()
+ val startCal = Calendar.getInstance(zone).apply {
+ set(Calendar.HOUR_OF_DAY, 0)
+ set(Calendar.MINUTE, 0)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }
+ val endCal = Calendar.getInstance(zone).apply {
+ timeInMillis = startCal.timeInMillis
+ add(Calendar.DAY_OF_YEAR, 1)
+ }
+ val startMs = startCal.timeInMillis
+ val endMs = endCal.timeInMillis
+
+ val projection = arrayOf(
+ CalendarContract.Events.TITLE,
+ CalendarContract.Events.DTSTART,
+ CalendarContract.Events.ALL_DAY
+ )
+ val selection =
+ "(${CalendarContract.Events.DTSTART} >= ? AND ${CalendarContract.Events.DTSTART} < ?) " +
+ "OR (${CalendarContract.Events.ALL_DAY}=1 AND " +
+ "${CalendarContract.Events.DTSTART} >= ? AND ${CalendarContract.Events.DTSTART} < ?)"
+ val args = arrayOf(
+ startMs.toString(),
+ endMs.toString(),
+ startMs.toString(),
+ endMs.toString()
+ )
+ val timeFmt = SimpleDateFormat("HH:mm", Locale.getDefault())
+
+ return try {
+ val events = mutableListOf()
+ context.contentResolver.query(
+ CalendarContract.Events.CONTENT_URI,
+ projection,
+ selection,
+ args,
+ "${CalendarContract.Events.DTSTART} ASC"
+ )?.use { cursor ->
+ val titleIdx = cursor.getColumnIndex(CalendarContract.Events.TITLE)
+ val startIdx = cursor.getColumnIndex(CalendarContract.Events.DTSTART)
+ val allDayIdx = cursor.getColumnIndex(CalendarContract.Events.ALL_DAY)
+ while (cursor.moveToNext() && events.size < limit) {
+ val title = cursor.getString(titleIdx)?.ifBlank { "(no title)" } ?: "(no title)"
+ val start = cursor.getLong(startIdx)
+ val allDay = cursor.getInt(allDayIdx) == 1
+ val whenLabel = if (allDay) {
+ "all day"
+ } else {
+ timeFmt.format(Date(start))
+ }
+ events.add(Event(whenLabel, title))
+ }
+ }
+ if (events.isEmpty()) {
+ Result(true, "No calendar events for today.", emptyList())
+ } else {
+ val body = buildString {
+ appendLine("Today’s calendar:")
+ events.forEach { appendLine("• ${it.whenLabel} — ${it.title}") }
+ }.trim()
+ Result(true, body, events)
+ }
+ } catch (e: SecurityException) {
+ Result(false, "Calendar permission denied: ${e.message}")
+ } catch (e: Exception) {
+ Result(false, "Calendar read failed: ${e.message}")
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/integrations/GogBridgeClient.kt b/app/src/main/kotlin/com/example/crkl/integrations/GogBridgeClient.kt
new file mode 100644
index 0000000..8bb5b56
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/integrations/GogBridgeClient.kt
@@ -0,0 +1,90 @@
+package com.example.crkl.integrations
+
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.OutputStreamWriter
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * Optional host bridge wrapping `gog` for Gmail send (lab/emulator testing).
+ * Production path remains mailto; enable Prefer gog in Integrations when bridge is running.
+ */
+class GogBridgeClient(
+ private val settings: IntegrationSettings
+) {
+ data class BridgeResult(val ok: Boolean, val message: String)
+
+ suspend fun health(): BridgeResult = withContext(Dispatchers.IO) {
+ if (!settings.gogReady()) {
+ return@withContext BridgeResult(false, "gog bridge URL not set")
+ }
+ get("/health")
+ }
+
+ suspend fun sendEmail(to: String, subject: String, body: String): BridgeResult =
+ withContext(Dispatchers.IO) {
+ if (!settings.gogReady()) {
+ return@withContext BridgeResult(false, "gog bridge not configured")
+ }
+ val payload = JSONObject()
+ .put("to", to)
+ .put("subject", subject)
+ .put("body", body)
+ post("/v1/gmail/send", payload)
+ }
+
+ private fun get(path: String): BridgeResult {
+ val url = URL(settings.gogBridgeUrl + path)
+ val conn = (url.openConnection() as HttpURLConnection).apply {
+ requestMethod = "GET"
+ connectTimeout = 8_000
+ readTimeout = 30_000
+ }
+ return read(conn)
+ }
+
+ private fun post(path: String, json: JSONObject): BridgeResult {
+ val url = URL(settings.gogBridgeUrl + path)
+ val conn = (url.openConnection() as HttpURLConnection).apply {
+ requestMethod = "POST"
+ setRequestProperty("Content-Type", "application/json")
+ doOutput = true
+ connectTimeout = 8_000
+ readTimeout = 60_000
+ }
+ return try {
+ OutputStreamWriter(conn.outputStream).use { it.write(json.toString()) }
+ read(conn)
+ } catch (e: Exception) {
+ Log.e("GogBridge", "post $path failed", e)
+ BridgeResult(false, e.message ?: e.javaClass.simpleName)
+ }
+ }
+
+ private fun read(conn: HttpURLConnection): BridgeResult {
+ return try {
+ val code = conn.responseCode
+ val text = (if (code in 200..299) conn.inputStream else conn.errorStream)
+ ?.bufferedReader()?.readText().orEmpty()
+ if (code in 200..299) {
+ val msg = runCatching {
+ JSONObject(text).optString("message", text.take(800))
+ }.getOrDefault(text.take(800))
+ BridgeResult(true, msg)
+ } else {
+ val detail = runCatching {
+ JSONObject(text).optString("message", text.take(400))
+ }.getOrDefault(text.take(400))
+ BridgeResult(false, "HTTP $code $detail")
+ }
+ } catch (e: Exception) {
+ BridgeResult(false, e.message ?: e.javaClass.simpleName)
+ } finally {
+ conn.disconnect()
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/integrations/IntegrationSettings.kt b/app/src/main/kotlin/com/example/crkl/integrations/IntegrationSettings.kt
new file mode 100644
index 0000000..83ac5d4
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/integrations/IntegrationSettings.kt
@@ -0,0 +1,138 @@
+package com.example.crkl.integrations
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.graphics.Color
+
+/**
+ * In-app integration + appearance settings. Secrets stay in prefs — never in git.
+ */
+class IntegrationSettings(
+ private val prefs: SharedPreferences
+) {
+ constructor(context: Context) : this(
+ context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ )
+
+ var vikunjaUrl: String
+ get() = prefs.getString(KEY_VIKUNJA_URL, DEFAULT_VIKUNJA_URL).orEmpty()
+ set(value) = prefs.edit().putString(KEY_VIKUNJA_URL, value.trim().trimEnd('/')).apply()
+
+ var vikunjaToken: String
+ get() = prefs.getString(KEY_VIKUNJA_TOKEN, "").orEmpty()
+ set(value) = prefs.edit().putString(KEY_VIKUNJA_TOKEN, value.trim()).apply()
+
+ var vikunjaProjectId: Int
+ get() = prefs.getInt(KEY_VIKUNJA_PROJECT, DEFAULT_VIKUNJA_PROJECT)
+ set(value) = prefs.edit().putInt(KEY_VIKUNJA_PROJECT, value).apply()
+
+ var emailTo: String
+ get() = prefs.getString(KEY_EMAIL_TO, "").orEmpty()
+ set(value) = prefs.edit().putString(KEY_EMAIL_TO, value.trim()).apply()
+
+ /**
+ * Optional host bridge for Gmail via `gog` (emulator → http://10.0.2.2:8765).
+ * Empty = mailto only. For lab testing, not required in production.
+ */
+ var gogBridgeUrl: String
+ get() = prefs.getString(KEY_GOG_BRIDGE, "").orEmpty()
+ set(value) = prefs.edit().putString(KEY_GOG_BRIDGE, value.trim().trimEnd('/')).apply()
+
+ var preferGogEmail: Boolean
+ get() = prefs.getBoolean(KEY_PREFER_GOG, false)
+ set(value) = prefs.edit().putBoolean(KEY_PREFER_GOG, value).apply()
+
+ // --- Circle / overlay appearance ---
+
+ /** Preset: yellow | cyan | magenta | white | lime */
+ var circleColorKey: String
+ get() = prefs.getString(KEY_CIRCLE_COLOR, DEFAULT_CIRCLE_COLOR).orEmpty()
+ set(value) = prefs.edit().putString(KEY_CIRCLE_COLOR, value).apply()
+
+ var circleNeon: Boolean
+ get() = prefs.getBoolean(KEY_CIRCLE_NEON, true)
+ set(value) = prefs.edit().putBoolean(KEY_CIRCLE_NEON, value).apply()
+
+ /** thin | medium | thick */
+ var circleStrokeKey: String
+ get() = prefs.getString(KEY_CIRCLE_STROKE, DEFAULT_CIRCLE_STROKE).orEmpty()
+ set(value) = prefs.edit().putString(KEY_CIRCLE_STROKE, value).apply()
+
+ /** BCP-47 target for Translate (en, ru, he, …). */
+ var translateTargetLang: String
+ get() = prefs.getString(KEY_TRANSLATE_TARGET, DEFAULT_TRANSLATE_TARGET).orEmpty()
+ set(value) = prefs.edit().putString(KEY_TRANSLATE_TARGET, value.trim().lowercase()).apply()
+
+ /** First-run onboarding finished (or skipped). */
+ var onboardingComplete: Boolean
+ get() = prefs.getBoolean(KEY_ONBOARDING_DONE, false)
+ set(value) = prefs.edit().putBoolean(KEY_ONBOARDING_DONE, value).apply()
+
+ /** Show capture source / OCR counts on the result panel (for debugging). */
+ var showDebugMeta: Boolean
+ get() = prefs.getBoolean(KEY_SHOW_DEBUG_META, false)
+ set(value) = prefs.edit().putBoolean(KEY_SHOW_DEBUG_META, value).apply()
+
+ fun vikunjaReady(): Boolean = vikunjaToken.isNotBlank() && vikunjaUrl.isNotBlank()
+ fun gogReady(): Boolean = gogBridgeUrl.isNotBlank()
+
+ fun circleColorArgb(): Int = when (circleColorKey.lowercase()) {
+ "cyan" -> Color.parseColor("#00E5FF")
+ "magenta" -> Color.parseColor("#FF2BD6")
+ "white" -> Color.parseColor("#F5F7FA")
+ "lime" -> Color.parseColor("#B8FF3D")
+ else -> Color.parseColor("#FFE566") // yellow
+ }
+
+ fun circleStrokeDp(): Float = when (circleStrokeKey.lowercase()) {
+ "thin" -> 2.5f
+ "thick" -> 7f
+ else -> 4.5f
+ }
+
+ fun statusSummary(): String = buildString {
+ append("Vikunja: ")
+ append(if (vikunjaReady()) "ready → $vikunjaUrl (project $vikunjaProjectId)" else "needs token")
+ append('\n')
+ append("Email: ")
+ when {
+ preferGogEmail && gogReady() -> append("gog → ${emailTo.ifBlank { "(set Email To)" }}")
+ emailTo.isNotBlank() -> append("mailto → $emailTo")
+ else -> append("mailto (To not set)")
+ }
+ append('\n')
+ append("Calendar: device CalendarContract")
+ append('\n')
+ append("Circle: ${circleColorKey}")
+ if (circleNeon) append(" · neon")
+ append(" · ${circleStrokeKey}")
+ append('\n')
+ append("Translate → ${translateTargetLang}")
+ }
+
+ companion object {
+ private const val PREFS = "crkl_integrations"
+ private const val KEY_VIKUNJA_URL = "vikunja_url"
+ private const val KEY_VIKUNJA_TOKEN = "vikunja_token"
+ private const val KEY_VIKUNJA_PROJECT = "vikunja_project"
+ private const val KEY_EMAIL_TO = "email_to"
+ private const val KEY_GOG_BRIDGE = "gog_bridge"
+ private const val KEY_PREFER_GOG = "prefer_gog_email"
+ private const val KEY_CIRCLE_COLOR = "circle_color"
+ private const val KEY_CIRCLE_NEON = "circle_neon"
+ private const val KEY_CIRCLE_STROKE = "circle_stroke"
+ private const val KEY_TRANSLATE_TARGET = "translate_target"
+ private const val KEY_ONBOARDING_DONE = "onboarding_done"
+ private const val KEY_SHOW_DEBUG_META = "show_debug_meta"
+
+ const val DEFAULT_VIKUNJA_URL = "https://todo.levkin.ca"
+ const val DEFAULT_VIKUNJA_PROJECT = 13
+ const val DEFAULT_GOG_BRIDGE_EMU = "http://10.0.2.2:8765"
+ const val DEFAULT_CIRCLE_COLOR = "cyan"
+ const val DEFAULT_CIRCLE_STROKE = "medium"
+ const val DEFAULT_TRANSLATE_TARGET = "ru"
+
+ val CIRCLE_COLORS = listOf("yellow", "cyan", "magenta", "white", "lime")
+ val CIRCLE_STROKES = listOf("thin", "medium", "thick")
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/integrations/OnDeviceTranslator.kt b/app/src/main/kotlin/com/example/crkl/integrations/OnDeviceTranslator.kt
new file mode 100644
index 0000000..b66e7d0
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/integrations/OnDeviceTranslator.kt
@@ -0,0 +1,157 @@
+package com.example.crkl.integrations
+
+import android.util.Log
+import com.google.mlkit.common.model.DownloadConditions
+import com.google.mlkit.nl.languageid.LanguageIdentification
+import com.google.mlkit.nl.translate.TranslateLanguage
+import com.google.mlkit.nl.translate.Translation
+import com.google.mlkit.nl.translate.TranslatorOptions
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+
+/**
+ * On-device ML Kit translation. First use downloads a language model (needs network once);
+ * afterward works offline.
+ */
+class OnDeviceTranslator {
+
+ data class Result(val ok: Boolean, val message: String)
+
+ suspend fun translate(
+ text: String,
+ targetLangTag: String,
+ allowCellularDownload: Boolean = true
+ ): Result {
+ val cleaned = text.trim().take(1_500)
+ if (cleaned.isBlank()) {
+ return Result(false, "Nothing to translate — circle a word or sentence first.")
+ }
+
+ val target = TranslateLanguage.fromLanguageTag(targetLangTag)
+ ?: return Result(false, "Unsupported target language: $targetLangTag")
+
+ val sourceTag = identifyLanguage(cleaned) ?: TranslateLanguage.ENGLISH
+ val source = TranslateLanguage.fromLanguageTag(sourceTag) ?: TranslateLanguage.ENGLISH
+
+ if (source == target) {
+ return Result(
+ true,
+ "Already looks like ${displayName(target)}:\n\n$cleaned"
+ )
+ }
+
+ val options = TranslatorOptions.Builder()
+ .setSourceLanguage(source)
+ .setTargetLanguage(target)
+ .build()
+ val translator = Translation.getClient(options)
+
+ return try {
+ val conditions = DownloadConditions.Builder().let {
+ if (!allowCellularDownload) it.requireWifi() else it
+ }.build()
+
+ val downloaded = awaitTask(translator.downloadModelIfNeeded(conditions))
+ if (!downloaded) {
+ return Result(
+ false,
+ "Couldn’t download translation model ($sourceTag→$targetLangTag). " +
+ "Connect to the internet once, then try again."
+ )
+ }
+
+ val out = awaitTaskValue(translator.translate(cleaned))
+ ?: return Result(false, "Translation failed.")
+
+ Result(
+ true,
+ buildString {
+ appendLine("Translated (${displayName(source)} → ${displayName(target)}):")
+ appendLine()
+ appendLine(out)
+ appendLine()
+ appendLine("— original —")
+ append(cleaned.take(400))
+ }
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "translate failed", e)
+ Result(false, "Translate error: ${e.message}")
+ } finally {
+ translator.close()
+ }
+ }
+
+ private suspend fun identifyLanguage(text: String): String? {
+ val client = LanguageIdentification.getClient()
+ return try {
+ val code = awaitTaskValue(client.identifyLanguage(text))
+ if (code == null || code == "und") null else code
+ } catch (e: Exception) {
+ Log.w(TAG, "language id failed", e)
+ null
+ } finally {
+ client.close()
+ }
+ }
+
+ private fun displayName(tag: String): String = when (tag) {
+ TranslateLanguage.ENGLISH -> "English"
+ TranslateLanguage.RUSSIAN -> "Russian"
+ TranslateLanguage.HEBREW -> "Hebrew"
+ TranslateLanguage.SPANISH -> "Spanish"
+ TranslateLanguage.FRENCH -> "French"
+ TranslateLanguage.GERMAN -> "German"
+ TranslateLanguage.UKRAINIAN -> "Ukrainian"
+ TranslateLanguage.CHINESE -> "Chinese"
+ else -> tag
+ }
+
+ private suspend fun awaitTaskValue(task: com.google.android.gms.tasks.Task): T? =
+ suspendCancellableCoroutine { cont ->
+ task
+ .addOnSuccessListener { cont.resume(it) }
+ .addOnFailureListener { e ->
+ Log.e(TAG, "task failed", e)
+ cont.resume(null)
+ }
+ }
+
+ private suspend fun awaitTask(task: com.google.android.gms.tasks.Task): Boolean =
+ suspendCancellableCoroutine { cont ->
+ task
+ .addOnSuccessListener { cont.resume(true) }
+ .addOnFailureListener { e ->
+ Log.e(TAG, "download failed", e)
+ cont.resume(false)
+ }
+ }
+
+ companion object {
+ private const val TAG = "OnDeviceTranslator"
+
+ /** BCP-47 tags used in settings / voice. */
+ val TARGET_OPTIONS = listOf(
+ "en" to "English",
+ "ru" to "Russian",
+ "he" to "Hebrew",
+ "uk" to "Ukrainian",
+ "es" to "Spanish",
+ "fr" to "French",
+ "de" to "German"
+ )
+
+ fun resolveSpokenTarget(lower: String): String? {
+ return when {
+ "russian" in lower || "русский" in lower || "русск" in lower -> "ru"
+ "hebrew" in lower || "иврит" in lower -> "he"
+ "ukrainian" in lower || "україн" in lower || "украин" in lower -> "uk"
+ "spanish" in lower || "español" in lower -> "es"
+ "french" in lower || "français" in lower || "francais" in lower -> "fr"
+ "german" in lower || "deutsch" in lower -> "de"
+ "english" in lower || "английск" in lower -> "en"
+ else -> null
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/integrations/RealEmailActions.kt b/app/src/main/kotlin/com/example/crkl/integrations/RealEmailActions.kt
new file mode 100644
index 0000000..369ec82
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/integrations/RealEmailActions.kt
@@ -0,0 +1,54 @@
+package com.example.crkl.integrations
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.util.Log
+
+/**
+ * Opens a real mail client (FairEmail / Gmail / K-9 / Betterbird companion) via mailto:.
+ */
+object RealEmailActions {
+ private const val TAG = "RealEmail"
+
+ fun compose(
+ context: Context,
+ subject: String,
+ body: String,
+ to: String = ""
+ ): String {
+ val mailto = Intent(Intent.ACTION_SENDTO).apply {
+ data = Uri.parse("mailto:" + Uri.encode(to))
+ putExtra(Intent.EXTRA_SUBJECT, subject)
+ putExtra(Intent.EXTRA_TEXT, body)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ return try {
+ val chooser = Intent.createChooser(mailto, "Send email").apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(chooser)
+ "Opened mail app compose" + if (to.isNotBlank()) " → $to" else ""
+ } catch (e: ActivityNotFoundException) {
+ Log.e(TAG, "no mail app", e)
+ "No mail app installed. Install FairEmail/K-9 and add test@levkine.ca (Mailcow IMAP)."
+ } catch (e: Exception) {
+ Log.e(TAG, "compose failed", e)
+ "Could not open mail app: ${e.message}"
+ }
+ }
+
+ fun openWebTodos(context: Context, url: String): String {
+ return try {
+ context.startActivity(
+ Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ )
+ "Opening $url"
+ } catch (e: Exception) {
+ "Could not open browser: ${e.message}"
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/integrations/VikunjaClient.kt b/app/src/main/kotlin/com/example/crkl/integrations/VikunjaClient.kt
new file mode 100644
index 0000000..3570b81
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/integrations/VikunjaClient.kt
@@ -0,0 +1,89 @@
+package com.example.crkl.integrations
+
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.io.OutputStreamWriter
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * Creates tasks on todo.levkin.ca (Vikunja).
+ * API: PUT /api/v1/projects/{id}/tasks Authorization: Bearer …
+ */
+class VikunjaClient(
+ private val settings: IntegrationSettings
+) {
+ private val tagName = "VikunjaClient"
+
+ data class CreateResult(val ok: Boolean, val message: String, val taskId: Long? = null)
+
+ suspend fun createTasks(titles: List, description: String = ""): CreateResult =
+ withContext(Dispatchers.IO) {
+ if (!settings.vikunjaReady()) {
+ return@withContext CreateResult(
+ false,
+ "Vikunja not configured. Open Circle → Integrations and paste an API token " +
+ "(from ansible .env / Infisical)."
+ )
+ }
+ if (titles.isEmpty()) {
+ return@withContext CreateResult(false, "No items to add.")
+ }
+
+ val created = mutableListOf()
+ val errors = mutableListOf()
+ for (title in titles.take(20)) {
+ val one = createOne(title.trim(), description)
+ if (one.ok) created.add(title) else errors.add("${title}: ${one.message}")
+ }
+ when {
+ created.isEmpty() -> CreateResult(false, errors.joinToString("\n").ifBlank { "Create failed" })
+ errors.isEmpty() -> CreateResult(
+ true,
+ "Added ${created.size} task(s) to Vikunja (project ${settings.vikunjaProjectId}):\n" +
+ created.joinToString("\n") { "• $it" } +
+ "\n\nOpen: ${settings.vikunjaUrl}"
+ )
+ else -> CreateResult(
+ true,
+ "Added ${created.size}; some failed:\n${errors.joinToString("\n")}"
+ )
+ }
+ }
+
+ private fun createOne(title: String, description: String): CreateResult {
+ val url = URL("${settings.vikunjaUrl}/api/v1/projects/${settings.vikunjaProjectId}/tasks")
+ val conn = (url.openConnection() as HttpURLConnection).apply {
+ requestMethod = "PUT"
+ setRequestProperty("Authorization", "Bearer ${settings.vikunjaToken}")
+ setRequestProperty("Content-Type", "application/json")
+ doOutput = true
+ connectTimeout = 12_000
+ readTimeout = 12_000
+ }
+ return try {
+ val body = JSONObject()
+ .put("title", title.take(200))
+ .put("description", description.take(2_000))
+ .put("priority", 2)
+ OutputStreamWriter(conn.outputStream).use { it.write(body.toString()) }
+ val code = conn.responseCode
+ val text = (if (code in 200..299) conn.inputStream else conn.errorStream)
+ ?.bufferedReader()?.readText().orEmpty()
+ if (code in 200..299) {
+ val id = runCatching { JSONObject(text).optLong("id") }.getOrNull()
+ CreateResult(true, "ok", id)
+ } else {
+ Log.w(tagName, "create failed code=$code body=$text")
+ CreateResult(false, "HTTP $code ${text.take(200)}")
+ }
+ } catch (e: Exception) {
+ Log.e(tagName, "create failed", e)
+ CreateResult(false, e.message ?: e.javaClass.simpleName)
+ } finally {
+ conn.disconnect()
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/media/ActiveMediaRegistry.kt b/app/src/main/kotlin/com/example/crkl/media/ActiveMediaRegistry.kt
new file mode 100644
index 0000000..dec1157
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/media/ActiveMediaRegistry.kt
@@ -0,0 +1,94 @@
+package com.example.crkl.media
+
+import android.graphics.RectF
+import java.util.concurrent.CopyOnWriteArrayList
+
+/**
+ * Tracks on-screen playable media so a circle selection can resolve to a real asset.
+ * Fixtures (and later real players) register slots with screen bounds.
+ */
+object ActiveMediaRegistry {
+
+ enum class Kind { VIDEO, AUDIO }
+
+ /** Plain bounds so JVM unit tests do not need mocked android.graphics.RectF methods. */
+ data class Bounds(
+ val left: Float,
+ val top: Float,
+ val right: Float,
+ val bottom: Float
+ ) {
+ fun isValid(): Boolean = right > left && bottom > top
+
+ fun toRectF(): RectF = RectF(left, top, right, bottom)
+
+ companion object {
+ fun from(rect: RectF): Bounds =
+ Bounds(rect.left, rect.top, rect.right, rect.bottom)
+ }
+ }
+
+ data class Slot(
+ val id: String,
+ val kind: Kind,
+ /** Path under assets/, e.g. media/grocery_memo.wav */
+ val assetPath: String,
+ /** Matching transcript under assets/ (fixture / on-device STT stand-in). */
+ val transcriptAssetPath: String,
+ val title: String,
+ @Volatile var screenBounds: Bounds = Bounds(0f, 0f, 0f, 0f)
+ )
+
+ private val slots = CopyOnWriteArrayList()
+
+ fun register(slot: Slot) {
+ slots.removeAll { it.id == slot.id }
+ slots.add(slot)
+ }
+
+ fun unregister(id: String) {
+ slots.removeAll { it.id == id }
+ }
+
+ fun updateBounds(id: String, bounds: RectF) {
+ slots.find { it.id == id }?.screenBounds = Bounds.from(bounds)
+ }
+
+ fun updateBounds(id: String, bounds: Bounds) {
+ slots.find { it.id == id }?.screenBounds = bounds
+ }
+
+ fun clear() {
+ slots.clear()
+ }
+
+ fun findOverlapping(selection: RectF): Slot? =
+ findOverlapping(Bounds.from(selection))
+
+ fun findOverlapping(selection: Bounds): Slot? {
+ var best: Slot? = null
+ var bestArea = 0f
+ for (slot in slots) {
+ val b = slot.screenBounds
+ if (!b.isValid()) continue
+ val overlap = overlapArea(selection, b)
+ if (overlap > bestArea && overlap > 2_000f) {
+ bestArea = overlap
+ best = slot
+ }
+ }
+ return best
+ }
+
+ fun all(): List = slots.toList()
+
+ private fun overlapArea(a: Bounds, b: Bounds): Float {
+ val left = maxOf(a.left, b.left)
+ val top = maxOf(a.top, b.top)
+ val right = minOf(a.right, b.right)
+ val bottom = minOf(a.bottom, b.bottom)
+ val w = right - left
+ val h = bottom - top
+ return if (w > 0f && h > 0f) w * h else 0f
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/media/AssetMediaTranscriber.kt b/app/src/main/kotlin/com/example/crkl/media/AssetMediaTranscriber.kt
new file mode 100644
index 0000000..d77c106
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/media/AssetMediaTranscriber.kt
@@ -0,0 +1,142 @@
+package com.example.crkl.media
+
+import android.content.Context
+import android.media.MediaMetadataRetriever
+import android.media.MediaPlayer
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import java.io.File
+import java.io.FileOutputStream
+import kotlin.coroutines.resume
+
+/**
+ * Plays an asset media file (so the user hears/sees it) and returns a transcript.
+ *
+ * Fixture assets ship matching `*.transcript.txt` sidecars generated from the same
+ * spoken script. That unblocks end-to-end circle → listen → summarize on-device
+ * without cloud STT. Swap [loadTranscript] for Vosk/Whisper file STT later.
+ */
+class AssetMediaTranscriber(
+ private val context: Context
+) {
+ private val tagName = "AssetMediaTranscriber"
+
+ data class Result(
+ val transcript: String,
+ val durationMs: Long,
+ val played: Boolean,
+ val source: String
+ )
+
+ suspend fun transcribe(
+ assetPath: String,
+ transcriptAssetPath: String,
+ playAudio: Boolean = true,
+ maxPlayMs: Long = 12_000L
+ ): Result = withContext(Dispatchers.IO) {
+ val transcript = loadTranscript(transcriptAssetPath)
+ val cacheFile = copyAssetToCache(assetPath)
+ val durationMs = probeDurationMs(cacheFile) ?: estimateFromTranscript(transcript)
+
+ var played = false
+ if (playAudio) {
+ played = withContext(Dispatchers.Main) {
+ playFile(cacheFile, maxPlayMs.coerceAtMost(durationMs + 500L))
+ }
+ }
+
+ Result(
+ transcript = transcript,
+ durationMs = durationMs,
+ played = played,
+ source = "asset-transcript"
+ )
+ }
+
+ private fun loadTranscript(transcriptAssetPath: String): String {
+ return try {
+ context.assets.open(transcriptAssetPath).bufferedReader().use { it.readText() }.trim()
+ } catch (e: Exception) {
+ Log.w(tagName, "Missing transcript $transcriptAssetPath", e)
+ ""
+ }
+ }
+
+ private fun copyAssetToCache(assetPath: String): File {
+ val name = assetPath.substringAfterLast('/')
+ val out = File(context.cacheDir, "crkl_media_$name")
+ if (!out.exists() || out.length() == 0L) {
+ context.assets.open(assetPath).use { input ->
+ FileOutputStream(out).use { output -> input.copyTo(output) }
+ }
+ }
+ return out
+ }
+
+ private fun probeDurationMs(file: File): Long? {
+ val retriever = MediaMetadataRetriever()
+ return try {
+ retriever.setDataSource(file.absolutePath)
+ retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
+ ?.toLongOrNull()
+ } catch (e: Exception) {
+ Log.w(tagName, "duration probe failed", e)
+ null
+ } finally {
+ try {
+ retriever.release()
+ } catch (_: Exception) {
+ }
+ }
+ }
+
+ private fun estimateFromTranscript(text: String): Long {
+ val words = text.split(Regex("\\s+")).count { it.isNotBlank() }
+ return (words * 420L).coerceIn(2_000L, 20_000L)
+ }
+
+ private suspend fun playFile(file: File, maxMs: Long): Boolean {
+ return withTimeoutOrNull(maxMs + 1_500L) {
+ suspendCancellableCoroutine { cont ->
+ val player = MediaPlayer()
+ try {
+ player.setDataSource(file.absolutePath)
+ player.setOnCompletionListener {
+ player.release()
+ if (cont.isActive) cont.resume(true)
+ }
+ player.setOnErrorListener { _, what, extra ->
+ Log.w(tagName, "MediaPlayer error what=$what extra=$extra")
+ player.release()
+ if (cont.isActive) cont.resume(false)
+ true
+ }
+ cont.invokeOnCancellation {
+ try {
+ player.stop()
+ } catch (_: Exception) {
+ }
+ player.release()
+ }
+ player.prepare()
+ player.start()
+ } catch (e: Exception) {
+ Log.e(tagName, "play failed", e)
+ try {
+ player.release()
+ } catch (_: Exception) {
+ }
+ if (cont.isActive) cont.resume(false)
+ }
+ }
+ } ?: run {
+ // Timed out — still count as attempted listen.
+ delay(50)
+ true
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/media/MediaAssistPipeline.kt b/app/src/main/kotlin/com/example/crkl/media/MediaAssistPipeline.kt
new file mode 100644
index 0000000..dd00ca6
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/media/MediaAssistPipeline.kt
@@ -0,0 +1,122 @@
+package com.example.crkl.media
+
+import android.content.Context
+import android.graphics.RectF
+import android.util.Log
+import com.example.crkl.agent.AssistEngine
+import com.example.crkl.capture.MediaProjectionHolder
+import com.example.crkl.capture.PlaybackCaptureRecorder
+import com.example.crkl.vision.ContentCapture
+import com.example.crkl.vision.RegionContentExtractor
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.withContext
+
+/**
+ * Circle media → play/listen → transcript → [AssistEngine] summarize.
+ * When MediaProjection is granted, also runs [AudioPlaybackCapture] in parallel.
+ */
+class MediaAssistPipeline(
+ private val context: Context,
+ private val assistEngine: AssistEngine,
+ private val transcriber: AssetMediaTranscriber = AssetMediaTranscriber(context),
+ private val playbackCapture: PlaybackCaptureRecorder = PlaybackCaptureRecorder()
+) {
+ private val tagName = "MediaAssistPipeline"
+
+ data class Outcome(
+ val response: AssistEngine.AssistResponse,
+ val slot: ActiveMediaRegistry.Slot,
+ val transcript: String
+ )
+
+ fun resolveSlot(selection: RectF, capture: ContentCapture.CaptureResult): ActiveMediaRegistry.Slot? {
+ ActiveMediaRegistry.findOverlapping(selection)?.let { return it }
+ val hay = capture.text.lowercase()
+ return ActiveMediaRegistry.all().firstOrNull { slot ->
+ hay.contains(slot.title.lowercase()) ||
+ hay.contains(slot.id.lowercase()) ||
+ (slot.kind == ActiveMediaRegistry.Kind.VIDEO && "video" in hay && "walkthrough" in hay) ||
+ (slot.kind == ActiveMediaRegistry.Kind.AUDIO && ("grocery" in hay || "voice memo" in hay))
+ }
+ }
+
+ suspend fun process(
+ slot: ActiveMediaRegistry.Slot,
+ onProgress: (String) -> Unit = {}
+ ): Outcome = coroutineScope {
+ onProgress("Playing ${slot.kind.name.lowercase()}…")
+ Log.d(tagName, "process id=${slot.id} asset=${slot.assetPath}")
+
+ val projection = MediaProjectionHolder.get()
+ val captureJob = if (projection != null) {
+ onProgress("Playing + AudioPlaybackCapture…")
+ async(Dispatchers.IO) {
+ playbackCapture.capture(
+ projection = projection,
+ outDir = context.cacheDir,
+ durationMs = 9_000L
+ )
+ }
+ } else {
+ null
+ }
+
+ val media = withContext(Dispatchers.Default) {
+ transcriber.transcribe(
+ assetPath = slot.assetPath,
+ transcriptAssetPath = slot.transcriptAssetPath,
+ playAudio = true,
+ maxPlayMs = 3_500L
+ )
+ }
+
+ val capture = captureJob?.await()
+ val captureNote = when {
+ projection == null -> "capture=off (enable in Crkl home)"
+ capture == null -> "capture=empty"
+ else -> "capture=${capture.durationMs}ms wav"
+ }
+
+ onProgress("Transcribed · summarizing…")
+
+ val bodyForAssist = buildString {
+ appendLine("Media: ${slot.title}")
+ appendLine("Kind: ${slot.kind.name.lowercase()}")
+ appendLine("Duration: ${media.durationMs} ms")
+ appendLine("Transcript source: ${media.source}")
+ appendLine("PlaybackCapture: $captureNote")
+ if (capture != null) {
+ appendLine("Captured file: ${capture.wavFile.name} (${capture.bytesCaptured} bytes)")
+ }
+ appendLine()
+ appendLine("Transcript:")
+ append(media.transcript.ifBlank { "(empty transcript)" })
+ }
+
+ val extraction = RegionContentExtractor.ExtractionResult(
+ text = bodyForAssist,
+ nodeCount = 1,
+ bounds = slot.screenBounds.toRectF(),
+ packageName = context.packageName
+ )
+
+ val response = withContext(Dispatchers.Default) {
+ assistEngine.respond(extraction, mode = AssistEngine.Mode.MEDIA)
+ }
+
+ val playedNote = if (media.played) "played" else "play-skipped"
+ Outcome(
+ response = response.copy(
+ title = when (slot.kind) {
+ ActiveMediaRegistry.Kind.VIDEO -> "Video summary"
+ ActiveMediaRegistry.Kind.AUDIO -> "Audio summary"
+ },
+ meta = response.meta + " · media · $playedNote · ${media.source} · $captureNote"
+ ),
+ slot = slot,
+ transcript = media.transcript
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/model/.gitkeep b/app/src/main/kotlin/com/example/crkl/model/.gitkeep
deleted file mode 100644
index 3b4f3f1..0000000
--- a/app/src/main/kotlin/com/example/crkl/model/.gitkeep
+++ /dev/null
@@ -1,2 +0,0 @@
-# STT/LLM wrappers, inference runners
-
diff --git a/app/src/main/kotlin/com/example/crkl/model/DeviceSpeechStt.kt b/app/src/main/kotlin/com/example/crkl/model/DeviceSpeechStt.kt
new file mode 100644
index 0000000..d9bdd36
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/model/DeviceSpeechStt.kt
@@ -0,0 +1,95 @@
+package com.example.crkl.model
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.speech.RecognitionListener
+import android.speech.RecognizerIntent
+import android.speech.SpeechRecognizer
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import java.util.Locale
+import kotlin.coroutines.resume
+
+/**
+ * Device speech recognizer. Requests offline when the OEM supports it.
+ * Falls back to on-device/network engine provided by the system.
+ */
+class DeviceSpeechStt(
+ context: Context
+) : LocalStt {
+
+ private val appContext = context.applicationContext
+ private val tagName = "DeviceSpeechStt"
+ private var recognizer: SpeechRecognizer? = null
+
+ override val isAvailable: Boolean
+ get() = SpeechRecognizer.isRecognitionAvailable(appContext)
+
+ override val displayName: String = "Device STT"
+
+ override suspend fun listen(timeoutMs: Long): String = withContext(Dispatchers.Main) {
+ if (!isAvailable) return@withContext ""
+ withTimeoutOrNull(timeoutMs + 1_500L) {
+ suspendCancellableCoroutine { cont ->
+ val sr = SpeechRecognizer.createSpeechRecognizer(appContext)
+ recognizer = sr
+ sr.setRecognitionListener(object : RecognitionListener {
+ override fun onReadyForSpeech(params: Bundle?) {
+ Log.d(tagName, "ready for speech")
+ }
+
+ override fun onBeginningOfSpeech() = Unit
+ override fun onRmsChanged(rmsdB: Float) = Unit
+ override fun onBufferReceived(buffer: ByteArray?) = Unit
+ override fun onEndOfSpeech() = Unit
+ override fun onPartialResults(partialResults: Bundle?) = Unit
+ override fun onEvent(eventType: Int, params: Bundle?) = Unit
+
+ override fun onError(error: Int) {
+ Log.w(tagName, "STT error=$error")
+ cleanup()
+ if (cont.isActive) cont.resume("")
+ }
+
+ override fun onResults(results: Bundle?) {
+ val texts = results
+ ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
+ .orEmpty()
+ cleanup()
+ if (cont.isActive) cont.resume(texts.firstOrNull().orEmpty())
+ }
+ })
+
+ cont.invokeOnCancellation { cleanup() }
+
+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
+ putExtra(
+ RecognizerIntent.EXTRA_LANGUAGE_MODEL,
+ RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
+ )
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault())
+ putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
+ putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, false)
+ // Prefer on-device when the recognizer supports it (API 33+ constant mirrored).
+ putExtra("android.speech.extra.PREFER_OFFLINE", true)
+ }
+ sr.startListening(intent)
+ }
+ }.orEmpty()
+ }
+
+ override fun cancel() = cleanup()
+
+ private fun cleanup() {
+ try {
+ recognizer?.cancel()
+ recognizer?.destroy()
+ } catch (_: Exception) {
+ }
+ recognizer = null
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/model/LocalLlm.kt b/app/src/main/kotlin/com/example/crkl/model/LocalLlm.kt
new file mode 100644
index 0000000..5b3812e
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/model/LocalLlm.kt
@@ -0,0 +1,16 @@
+package com.example.crkl.model
+
+/**
+ * On-device text generation. Implementations must not call the network.
+ */
+interface LocalLlm {
+ val isReady: Boolean
+ val displayName: String
+
+ /**
+ * Blocking/suspend generation. Call from a background dispatcher.
+ */
+ suspend fun generate(prompt: String): String
+
+ fun close()
+}
diff --git a/app/src/main/kotlin/com/example/crkl/model/LocalStt.kt b/app/src/main/kotlin/com/example/crkl/model/LocalStt.kt
new file mode 100644
index 0000000..45e63f6
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/model/LocalStt.kt
@@ -0,0 +1,16 @@
+package com.example.crkl.model
+
+/**
+ * Short-form speech-to-text. Implementations should prefer on-device engines.
+ */
+interface LocalStt {
+ val isAvailable: Boolean
+ val displayName: String
+
+ /**
+ * Listen for up to [timeoutMs] and return transcribed text (may be blank).
+ */
+ suspend fun listen(timeoutMs: Long = 6_000L): String
+
+ fun cancel()
+}
diff --git a/app/src/main/kotlin/com/example/crkl/model/MediaPipeLocalLlm.kt b/app/src/main/kotlin/com/example/crkl/model/MediaPipeLocalLlm.kt
new file mode 100644
index 0000000..1ef3f8d
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/model/MediaPipeLocalLlm.kt
@@ -0,0 +1,89 @@
+package com.example.crkl.model
+
+import android.content.Context
+import android.util.Log
+import com.google.mediapipe.tasks.genai.llminference.LlmInference
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * MediaPipe LLM Inference backend (Gemma 3 .task / .litertlm).
+ *
+ * Loads lazily on first [ensureLoaded] / [generate]. Safe to call when no model
+ * file is present — [isReady] stays false and callers should fall back.
+ */
+class MediaPipeLocalLlm(
+ context: Context
+) : LocalLlm {
+
+ private val appContext = context.applicationContext
+ private val tagName = "MediaPipeLocalLlm"
+ private val mutex = Mutex()
+ private val engine = AtomicReference(null)
+ private var modelFile: File? = null
+
+ @Volatile
+ override var isReady: Boolean = false
+ private set
+
+ @Volatile
+ override var displayName: String = "MediaPipe (unloaded)"
+ private set
+
+ suspend fun ensureLoaded(): Boolean = withContext(Dispatchers.Default) {
+ mutex.withLock {
+ if (engine.get() != null) return@withLock true
+
+ val file = ModelPaths.findModel(appContext.filesDir)
+ if (file == null) {
+ Log.i(tagName, "No model file found; LLM disabled")
+ isReady = false
+ displayName = "MediaPipe (no model)"
+ return@withLock false
+ }
+
+ try {
+ Log.i(tagName, "Loading model from ${file.absolutePath} (${file.length()} bytes)")
+ val options = LlmInference.LlmInferenceOptions.builder()
+ .setModelPath(file.absolutePath)
+ .setMaxTokens(1024)
+ .setMaxTopK(40)
+ .build()
+ val created = LlmInference.createFromOptions(appContext, options)
+ engine.set(created)
+ modelFile = file
+ isReady = true
+ displayName = "MediaPipe · ${file.name}"
+ Log.i(tagName, "Model ready: $displayName")
+ true
+ } catch (e: Exception) {
+ Log.e(tagName, "Failed to load model ${file.absolutePath}", e)
+ engine.set(null)
+ isReady = false
+ displayName = "MediaPipe (load failed)"
+ false
+ }
+ }
+ }
+
+ override suspend fun generate(prompt: String): String = withContext(Dispatchers.Default) {
+ if (!ensureLoaded()) {
+ error("On-device model is not loaded")
+ }
+ mutex.withLock {
+ val llm = engine.get() ?: error("On-device model is not loaded")
+ llm.generateResponse(prompt).orEmpty().trim()
+ }
+ }
+
+ override fun close() {
+ engine.getAndSet(null)?.close()
+ isReady = false
+ displayName = "MediaPipe (closed)"
+ modelFile = null
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/model/ModelPaths.kt b/app/src/main/kotlin/com/example/crkl/model/ModelPaths.kt
new file mode 100644
index 0000000..dee0759
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/model/ModelPaths.kt
@@ -0,0 +1,44 @@
+package com.example.crkl.model
+
+import java.io.File
+
+/**
+ * Resolves on-device MediaPipe `.task` model paths.
+ * Models are NOT bundled in the APK — push with `make push-model`.
+ */
+object ModelPaths {
+
+ const val ADB_MODEL_PATH = "/data/local/tmp/llm/crkl.task"
+ private const val LEGACY_ADB_MODEL_PATH = "/data/local/tmp/llm/model.task"
+
+ fun candidateFiles(filesDir: File): List {
+ val appModelsDir = File(filesDir, "models")
+ val appDirMatches = appModelsDir
+ .listFiles { f -> f.isFile && (f.name.endsWith(".task") || f.name.endsWith(".litertlm")) }
+ ?.sortedBy { it.name }
+ .orEmpty()
+
+ return listOf(
+ File(ADB_MODEL_PATH),
+ File(LEGACY_ADB_MODEL_PATH),
+ File(appModelsDir, "gemma-3-1b-it-int4.task"),
+ File(appModelsDir, "model.task")
+ ) + appDirMatches
+ }
+
+ fun findModel(filesDir: File): File? =
+ candidateFiles(filesDir).firstOrNull { it.isFile && it.canRead() && it.length() > 0L }
+
+ fun missingModelHint(): String = buildString {
+ appendLine("No on-device model found.")
+ appendLine()
+ appendLine("Download Gemma 3 1B INT4 (.task) from Hugging Face")
+ appendLine("(litert-community/Gemma3-1B-IT), then:")
+ appendLine()
+ appendLine(" make push-model MODEL=/path/to/model.task")
+ appendLine()
+ appendLine("Expected path: $ADB_MODEL_PATH")
+ appendLine()
+ appendLine("Until then, Crkl shows extracted text only.")
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/ui/.gitkeep b/app/src/main/kotlin/com/example/crkl/ui/.gitkeep
deleted file mode 100644
index 61de1a8..0000000
--- a/app/src/main/kotlin/com/example/crkl/ui/.gitkeep
+++ /dev/null
@@ -1,2 +0,0 @@
-# Jetpack Compose overlays and feedback UIs
-
diff --git a/app/src/main/kotlin/com/example/crkl/ui/CrklUi.kt b/app/src/main/kotlin/com/example/crkl/ui/CrklUi.kt
new file mode 100644
index 0000000..ea58dc5
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/ui/CrklUi.kt
@@ -0,0 +1,23 @@
+package com.example.crkl.ui
+
+import android.graphics.Color
+
+/**
+ * Shared overlay palette — ink paper + teal ring (not Material purple).
+ * Used by FAB, circle stroke defaults, and the result sheet.
+ */
+object CrklUi {
+ val Ink = Color.parseColor("#0E1A24")
+ val InkSoft = Color.parseColor("#1C2B38")
+ val Paper = Color.parseColor("#F3F5F2")
+ val PaperHi = Color.parseColor("#FFFFFF")
+ val Teal = Color.parseColor("#1FA7A0")
+ val TealDeep = Color.parseColor("#157F7A")
+ val Mist = Color.parseColor("#6B7A88")
+ val MistLight = Color.parseColor("#9AA6B2")
+ val Hairline = Color.parseColor("#1A0E1A24")
+ val Dim = Color.argb(140, 8, 14, 20)
+ val Danger = Color.parseColor("#B85A4A")
+ val Ok = Color.parseColor("#2A7A4B")
+ val ChipFg = Color.parseColor("#FFFFFF")
+}
diff --git a/app/src/main/kotlin/com/example/crkl/ui/ResultPanelView.kt b/app/src/main/kotlin/com/example/crkl/ui/ResultPanelView.kt
new file mode 100644
index 0000000..97a069f
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/ui/ResultPanelView.kt
@@ -0,0 +1,256 @@
+package com.example.crkl.ui
+
+import android.animation.AnimatorSet
+import android.animation.ObjectAnimator
+import android.content.Context
+import android.graphics.Typeface
+import android.graphics.drawable.GradientDrawable
+import android.util.TypedValue
+import android.view.Gravity
+import android.view.View
+import android.view.animation.DecelerateInterpolator
+import android.widget.HorizontalScrollView
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import android.widget.TextView
+
+/**
+ * Bottom-sheet style result card: title, text, five VIP chips, quiet status.
+ */
+class ResultPanelView(
+ context: Context,
+ private val onDismiss: () -> Unit,
+ private val onListen: (() -> Unit)? = null,
+ private val onShareList: (() -> Unit)? = null,
+ private val onAddTodo: (() -> Unit)? = null,
+ private val onTranslate: (() -> Unit)? = null,
+ private val onCopy: (() -> Unit)? = null,
+ private val onExplain: (() -> Unit)? = null
+) : LinearLayout(context) {
+
+ private val titleView: TextView
+ private val metaView: TextView
+ private val bodyView: TextView
+ private val listenView: TextView
+ private val statusView: TextView
+ private var entered = false
+
+ init {
+ orientation = VERTICAL
+ setPadding(dp(18), dp(12), dp(18), dp(14))
+ background = GradientDrawable().apply {
+ setColor(CrklUi.PaperHi)
+ cornerRadii = floatArrayOf(
+ dp(22).toFloat(), dp(22).toFloat(),
+ dp(22).toFloat(), dp(22).toFloat(),
+ dp(10).toFloat(), dp(10).toFloat(),
+ dp(10).toFloat(), dp(10).toFloat()
+ )
+ setStroke(dp(1), CrklUi.Hairline)
+ }
+ elevation = dp(16).toFloat()
+ clipToOutline = false
+
+ // Drag handle
+ val handle = View(context).apply {
+ background = GradientDrawable().apply {
+ setColor(CrklUi.MistLight)
+ cornerRadius = dp(2).toFloat()
+ }
+ layoutParams = LayoutParams(dp(36), dp(4)).apply {
+ gravity = Gravity.CENTER_HORIZONTAL
+ bottomMargin = dp(12)
+ }
+ }
+
+ val header = LinearLayout(context).apply {
+ orientation = HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ }
+
+ titleView = TextView(context).apply {
+ text = "Circle"
+ setTextColor(CrklUi.Ink)
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 17f)
+ typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
+ layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
+ }
+
+ val close = TextView(context).apply {
+ text = "Close"
+ setTextColor(CrklUi.Mist)
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
+ setPadding(dp(10), dp(6), dp(2), dp(6))
+ setOnClickListener { onDismiss() }
+ }
+
+ header.addView(titleView)
+ header.addView(close)
+
+ // Teal accent rule under header
+ val rule = View(context).apply {
+ background = GradientDrawable().apply {
+ setColor(CrklUi.Teal)
+ alpha = 180
+ }
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(2)).apply {
+ topMargin = dp(10)
+ bottomMargin = dp(10)
+ }
+ }
+
+ metaView = TextView(context).apply {
+ setTextColor(CrklUi.Mist)
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
+ setPadding(0, 0, 0, dp(6))
+ visibility = GONE
+ }
+
+ bodyView = TextView(context).apply {
+ setTextColor(CrklUi.InkSoft)
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 14.5f)
+ setLineSpacing(dp(2).toFloat(), 1.2f)
+ }
+
+ val scroll = ScrollView(context).apply {
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)
+ isFillViewport = true
+ isVerticalScrollBarEnabled = false
+ addView(bodyView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
+ }
+
+ statusView = TextView(context).apply {
+ setTextColor(CrklUi.Ok)
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 12.5f)
+ setPadding(0, dp(6), 0, 0)
+ visibility = GONE
+ }
+
+ val actionsRow = LinearLayout(context).apply {
+ orientation = HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ }
+
+ fun solidChip(label: String, bg: Int, onClick: () -> Unit): TextView =
+ TextView(context).apply {
+ text = label
+ setTextColor(CrklUi.ChipFg)
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
+ typeface = Typeface.DEFAULT_BOLD
+ setPadding(dp(14), dp(11), dp(14), dp(11))
+ background = GradientDrawable().apply {
+ setColor(bg)
+ cornerRadius = dp(20).toFloat()
+ }
+ val lp = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
+ lp.marginEnd = dp(8)
+ layoutParams = lp
+ isClickable = true
+ isFocusable = true
+ setOnClickListener {
+ statusView.visibility = VISIBLE
+ statusView.setTextColor(CrklUi.Mist)
+ statusView.text = "$label…"
+ onClick()
+ }
+ }
+
+ // VIP only — Email / Calendar stay on voice
+ if (onTranslate != null) {
+ actionsRow.addView(solidChip("Translate", CrklUi.TealDeep, onTranslate))
+ }
+ if (onCopy != null) {
+ actionsRow.addView(solidChip("Copy", CrklUi.InkSoft, onCopy))
+ }
+ if (onExplain != null) {
+ actionsRow.addView(solidChip("Explain", CrklUi.Ink, onExplain))
+ }
+ if (onShareList != null) {
+ actionsRow.addView(solidChip("Share", CrklUi.Mist, onShareList))
+ }
+ if (onAddTodo != null) {
+ actionsRow.addView(solidChip("Vikunja", CrklUi.Ok, onAddTodo))
+ }
+
+ val actionsScroll = HorizontalScrollView(context).apply {
+ isHorizontalScrollBarEnabled = false
+ overScrollMode = OVER_SCROLL_NEVER
+ setPadding(0, dp(10), 0, dp(2))
+ addView(actionsRow)
+ }
+
+ listenView = TextView(context).apply {
+ text = "Or speak a command"
+ gravity = Gravity.CENTER
+ setTextColor(CrklUi.TealDeep)
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
+ setPadding(0, dp(10), 0, dp(2))
+ setOnClickListener { onListen?.invoke() }
+ visibility = if (onListen != null) VISIBLE else GONE
+ }
+
+ addView(handle)
+ addView(header, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
+ addView(rule)
+ addView(metaView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
+ addView(scroll)
+ if (actionsRow.childCount > 0) {
+ addView(actionsScroll, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
+ }
+ addView(listenView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
+ addView(statusView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
+ }
+
+ fun playEnter() {
+ if (entered) return
+ entered = true
+ alpha = 0f
+ translationY = dp(48).toFloat()
+ val fade = ObjectAnimator.ofFloat(this, View.ALPHA, 0f, 1f)
+ val slide = ObjectAnimator.ofFloat(this, View.TRANSLATION_Y, dp(48).toFloat(), 0f)
+ AnimatorSet().apply {
+ playTogether(fade, slide)
+ duration = 240
+ interpolator = DecelerateInterpolator()
+ start()
+ }
+ }
+
+ fun showResult(title: String, meta: String, body: String) {
+ titleView.text = title
+ if (meta.isBlank()) {
+ metaView.visibility = GONE
+ metaView.text = ""
+ } else {
+ metaView.visibility = VISIBLE
+ metaView.text = meta
+ }
+ bodyView.text = body
+ if (!entered) playEnter()
+ }
+
+ fun appendBody(extra: String) {
+ val current = bodyView.text?.toString().orEmpty()
+ bodyView.text = if (current.isBlank()) extra else "$current\n\n$extra"
+ statusView.visibility = VISIBLE
+ statusView.text = extra.lineSequence().firstOrNull { it.isNotBlank() }?.take(100)
+ ?: "Done"
+ val bad = extra.contains("fail", ignoreCase = true) ||
+ extra.contains("Couldn’t", ignoreCase = true) ||
+ extra.contains("Couldn't", ignoreCase = true) ||
+ extra.contains("not configured", ignoreCase = true) ||
+ extra.contains("Nothing", ignoreCase = true)
+ statusView.setTextColor(if (bad) CrklUi.Danger else CrklUi.Ok)
+ }
+
+ fun setListening(listening: Boolean) {
+ listenView.text = if (listening) "Listening…" else "Or speak a command"
+ }
+
+ private fun dp(value: Int): Int =
+ TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ value.toFloat(),
+ resources.displayMetrics
+ ).toInt()
+}
diff --git a/app/src/main/kotlin/com/example/crkl/ui/theme/Theme.kt b/app/src/main/kotlin/com/example/crkl/ui/theme/Theme.kt
index 26a6d3b..8e6a879 100644
--- a/app/src/main/kotlin/com/example/crkl/ui/theme/Theme.kt
+++ b/app/src/main/kotlin/com/example/crkl/ui/theme/Theme.kt
@@ -1,22 +1,49 @@
package com.example.crkl.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material3.*
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
+private val Ink = Color(0xFF0E1A24)
+private val InkSoft = Color(0xFF1C2B38)
+private val Paper = Color(0xFFF3F5F2)
+private val Teal = Color(0xFF1FA7A0)
+private val TealDeep = Color(0xFF157F7A)
+private val Mist = Color(0xFF6B7A88)
+private val Danger = Color(0xFFB85A4A)
+
private val DarkColorScheme = darkColorScheme(
- primary = Color(0xFF6200EE),
- secondary = Color(0xFF03DAC6),
- tertiary = Color(0xFF018786)
+ primary = Teal,
+ onPrimary = Color.White,
+ secondary = TealDeep,
+ tertiary = Mist,
+ background = Ink,
+ surface = InkSoft,
+ onBackground = Paper,
+ onSurface = Paper,
+ error = Danger
)
private val LightColorScheme = lightColorScheme(
- primary = Color(0xFF6200EE),
- secondary = Color(0xFF03DAC6),
- tertiary = Color(0xFF018786),
- background = Color(0xFFFFFBFE),
- surface = Color(0xFFFFFBFE),
+ primary = TealDeep,
+ onPrimary = Color.White,
+ secondary = InkSoft,
+ tertiary = Mist,
+ background = Paper,
+ surface = Color.White,
+ onBackground = Ink,
+ onSurface = Ink,
+ onSurfaceVariant = Mist,
+ primaryContainer = Color(0xFFD7EFEC),
+ onPrimaryContainer = Ink,
+ secondaryContainer = Color(0xFFE8ECF0),
+ onSecondaryContainer = InkSoft,
+ errorContainer = Color(0xFFF5DED9),
+ onErrorContainer = Color(0xFF5C2A22),
+ error = Danger
)
@Composable
@@ -24,15 +51,9 @@ fun CrklTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
- val colorScheme = when {
- darkTheme -> DarkColorScheme
- else -> LightColorScheme
- }
-
MaterialTheme(
- colorScheme = colorScheme,
- typography = Typography(),
+ colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
+ typography = Typography,
content = content
)
}
-
diff --git a/app/src/main/kotlin/com/example/crkl/vision/.gitkeep b/app/src/main/kotlin/com/example/crkl/vision/.gitkeep
deleted file mode 100644
index 988c1a4..0000000
--- a/app/src/main/kotlin/com/example/crkl/vision/.gitkeep
+++ /dev/null
@@ -1,2 +0,0 @@
-# Content classification and ML components
-
diff --git a/app/src/main/kotlin/com/example/crkl/vision/ContentCapture.kt b/app/src/main/kotlin/com/example/crkl/vision/ContentCapture.kt
new file mode 100644
index 0000000..96c277e
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/vision/ContentCapture.kt
@@ -0,0 +1,87 @@
+package com.example.crkl.vision
+
+import android.graphics.RectF
+import android.util.Log
+import android.view.accessibility.AccessibilityNodeInfo
+import android.view.accessibility.AccessibilityWindowInfo
+
+/**
+ * Merges accessibility text + optional OCR into one extraction payload.
+ */
+object ContentCapture {
+
+ private const val TAG = "ContentCapture"
+
+ enum class Source { A11Y, OCR, MERGED, EMPTY }
+
+ data class CaptureResult(
+ val text: String,
+ val nodeCount: Int,
+ val bounds: RectF,
+ val packageName: String?,
+ val source: Source,
+ val ocrBlockCount: Int = 0
+ ) {
+ val isEmpty: Boolean get() = text.isBlank()
+
+ fun toExtraction(): RegionContentExtractor.ExtractionResult =
+ RegionContentExtractor.ExtractionResult(
+ text = text,
+ nodeCount = nodeCount,
+ bounds = bounds,
+ packageName = packageName
+ )
+ }
+
+ fun fromA11y(
+ windows: List?,
+ rootFallback: AccessibilityNodeInfo?,
+ selection: RectF
+ ): CaptureResult {
+ val base = RegionContentExtractor.extract(windows, rootFallback, selection)
+ return CaptureResult(
+ text = base.text,
+ nodeCount = base.nodeCount,
+ bounds = base.bounds,
+ packageName = base.packageName,
+ source = if (base.isEmpty) Source.EMPTY else Source.A11Y
+ )
+ }
+
+ fun mergeWithOcr(a11y: CaptureResult, ocrText: String, ocrBlocks: Int): CaptureResult {
+ val a11yText = a11y.text.trim()
+ val ocr = ocrText.trim()
+ val (merged, source) = when {
+ a11yText.isBlank() && ocr.isBlank() -> "" to Source.EMPTY
+ a11yText.isBlank() -> ocr to Source.OCR
+ ocr.isBlank() -> a11yText to Source.A11Y
+ ocr.contains(a11yText) || a11yText.contains(ocr) -> {
+ // Prefer the longer unique reading.
+ (if (ocr.length >= a11yText.length) ocr else a11yText) to Source.MERGED
+ }
+ else -> "$a11yText\n\n$ocr" to Source.MERGED
+ }
+ try {
+ Log.d(TAG, "merge source=$source a11y=${a11yText.length} ocr=${ocr.length}")
+ } catch (_: RuntimeException) {
+ // Android Log is not mocked in JVM unit tests.
+ }
+ return a11y.copy(
+ text = merged.take(4_000),
+ source = source,
+ ocrBlockCount = ocrBlocks
+ )
+ }
+
+ /** Enough letter characters to show as circled text (not OCR junk / icons). */
+ fun hasUsefulText(text: String): Boolean {
+ return text.count { it.isLetter() } >= 6
+ }
+
+ /** Heuristic: selection looks like media chrome (audio/video). */
+ fun looksLikeMedia(text: String, packageName: String?): Boolean {
+ val hay = (text + " " + (packageName ?: "")).lowercase()
+ return listOf("audio", "voice", "video", "player", "♫", "▶", "memo", "podcast")
+ .any { it in hay }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/vision/RegionContentExtractor.kt b/app/src/main/kotlin/com/example/crkl/vision/RegionContentExtractor.kt
new file mode 100644
index 0000000..62b132d
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/vision/RegionContentExtractor.kt
@@ -0,0 +1,124 @@
+package com.example.crkl.vision
+
+import android.graphics.Rect
+import android.graphics.RectF
+import android.util.Log
+import android.view.accessibility.AccessibilityNodeInfo
+import android.view.accessibility.AccessibilityWindowInfo
+
+/**
+ * Pulls readable text from accessibility nodes that intersect a screen-space selection.
+ *
+ * Prefers the accessibility node tree over MediaProjection so the first vertical
+ * slice stays permission-light and fully on-device.
+ */
+object RegionContentExtractor {
+
+ private const val TAG = "RegionContentExtractor"
+ private const val MAX_NODES = 80
+ private const val MAX_CHARS = 4_000
+
+ data class ExtractionResult(
+ val text: String,
+ val nodeCount: Int,
+ val bounds: RectF,
+ val packageName: String?
+ ) {
+ val isEmpty: Boolean get() = text.isBlank()
+ }
+
+ fun extract(
+ windows: List?,
+ rootFallback: AccessibilityNodeInfo?,
+ selection: RectF
+ ): ExtractionResult {
+ val selectionRect = Rect().also { selection.round(it) }
+ val snippets = linkedSetOf()
+ var nodesHit = 0
+ var packageName: String? = null
+
+ val roots = mutableListOf()
+ windows
+ ?.filter { it.type == AccessibilityWindowInfo.TYPE_APPLICATION }
+ ?.sortedByDescending { it.layer }
+ ?.forEach { window ->
+ window.root?.let { roots.add(it) }
+ }
+ if (roots.isEmpty()) {
+ rootFallback?.let { roots.add(it) }
+ }
+
+ for (root in roots) {
+ if (packageName == null) {
+ packageName = root.packageName?.toString()
+ }
+ try {
+ nodesHit += collectIntersectingText(root, selectionRect, snippets)
+ } finally {
+ // Only recycle roots we obtained from windows; caller owns rootFallback.
+ if (root !== rootFallback) {
+ root.recycle()
+ }
+ }
+ if (snippets.isNotEmpty() || nodesHit >= MAX_NODES) break
+ }
+
+ val joined = snippets
+ .joinToString(separator = "\n")
+ .trim()
+ .take(MAX_CHARS)
+
+ Log.d(
+ TAG,
+ "extract: nodes=$nodesHit snippets=${snippets.size} chars=${joined.length} pkg=$packageName"
+ )
+
+ return ExtractionResult(
+ text = joined,
+ nodeCount = nodesHit,
+ bounds = RectF(selection),
+ packageName = packageName
+ )
+ }
+
+ private fun collectIntersectingText(
+ node: AccessibilityNodeInfo,
+ selection: Rect,
+ out: MutableSet,
+ depth: Int = 0
+ ): Int {
+ if (depth > 40 || out.size >= MAX_NODES) return 0
+
+ var hit = 0
+ val bounds = Rect()
+ node.getBoundsInScreen(bounds)
+
+ if (Rect.intersects(bounds, selection)) {
+ readableText(node)?.let { out.add(it) }
+ hit = 1
+ }
+
+ for (i in 0 until node.childCount) {
+ val child = node.getChild(i) ?: continue
+ try {
+ hit += collectIntersectingText(child, selection, out, depth + 1)
+ } finally {
+ child.recycle()
+ }
+ if (out.size >= MAX_NODES) break
+ }
+ return hit
+ }
+
+ private fun readableText(node: AccessibilityNodeInfo): String? {
+ val candidates = listOfNotNull(
+ node.text?.toString(),
+ node.contentDescription?.toString(),
+ node.hintText?.toString()
+ )
+ .map { it.trim() }
+ .filter { it.isNotEmpty() && it.length < 2_000 }
+
+ return candidates.firstOrNull()
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/vision/ScreenOcr.kt b/app/src/main/kotlin/com/example/crkl/vision/ScreenOcr.kt
new file mode 100644
index 0000000..e531bdd
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/vision/ScreenOcr.kt
@@ -0,0 +1,55 @@
+package com.example.crkl.vision
+
+import android.graphics.Bitmap
+import android.util.Log
+import com.google.mlkit.vision.common.InputImage
+import com.google.mlkit.vision.text.TextRecognition
+import com.google.mlkit.vision.text.latin.TextRecognizerOptions
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+
+/**
+ * On-device OCR via ML Kit (latin script). No network.
+ */
+object ScreenOcr {
+
+ private const val TAG = "ScreenOcr"
+ private val recognizer by lazy {
+ TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
+ }
+
+ data class OcrResult(
+ val text: String,
+ val blockCount: Int
+ ) {
+ val isEmpty: Boolean get() = text.isBlank()
+ }
+
+ suspend fun recognize(bitmap: Bitmap): OcrResult {
+ return try {
+ val image = InputImage.fromBitmap(bitmap, 0)
+ suspendCancellableCoroutine { cont ->
+ recognizer.process(image)
+ .addOnSuccessListener { result ->
+ val text = result.text.trim()
+ Log.d(TAG, "OCR blocks=${result.textBlocks.size} chars=${text.length}")
+ if (cont.isActive) {
+ cont.resume(
+ OcrResult(
+ text = text.take(4_000),
+ blockCount = result.textBlocks.size
+ )
+ )
+ }
+ }
+ .addOnFailureListener { e ->
+ Log.e(TAG, "OCR failed", e)
+ if (cont.isActive) cont.resume(OcrResult("", 0))
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "OCR failed", e)
+ OcrResult(text = "", blockCount = 0)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/crkl/vision/ScreenshotCapturer.kt b/app/src/main/kotlin/com/example/crkl/vision/ScreenshotCapturer.kt
new file mode 100644
index 0000000..6220694
--- /dev/null
+++ b/app/src/main/kotlin/com/example/crkl/vision/ScreenshotCapturer.kt
@@ -0,0 +1,83 @@
+package com.example.crkl.vision
+
+import android.accessibilityservice.AccessibilityService
+import android.graphics.Bitmap
+import android.graphics.Rect
+import android.graphics.RectF
+import android.os.Build
+import android.util.Log
+import android.view.Display
+import androidx.annotation.RequiresApi
+import kotlinx.coroutines.suspendCancellableCoroutine
+import java.util.concurrent.Executors
+import kotlin.coroutines.resume
+
+/**
+ * Captures a screenshot via AccessibilityService (API 30+) and crops to [bounds].
+ */
+object ScreenshotCapturer {
+
+ private const val TAG = "ScreenshotCapturer"
+ private val executor = Executors.newSingleThreadExecutor()
+
+ suspend fun captureRegion(
+ service: AccessibilityService,
+ bounds: RectF
+ ): Bitmap? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ Log.w(TAG, "takeScreenshot requires API 30+")
+ return null
+ }
+ val full = captureFull(service) ?: return null
+ return try {
+ crop(full, bounds)
+ } finally {
+ if (!full.isRecycled) full.recycle()
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ private suspend fun captureFull(service: AccessibilityService): Bitmap? =
+ suspendCancellableCoroutine { cont ->
+ service.takeScreenshot(
+ Display.DEFAULT_DISPLAY,
+ executor,
+ object : AccessibilityService.TakeScreenshotCallback {
+ override fun onSuccess(screenshot: AccessibilityService.ScreenshotResult) {
+ try {
+ val hw = screenshot.hardwareBuffer
+ val bitmap = Bitmap.wrapHardwareBuffer(hw, screenshot.colorSpace)
+ ?.copy(Bitmap.Config.ARGB_8888, false)
+ hw.close()
+ if (cont.isActive) cont.resume(bitmap)
+ } catch (e: Exception) {
+ Log.e(TAG, "Screenshot wrap failed", e)
+ if (cont.isActive) cont.resume(null)
+ }
+ }
+
+ override fun onFailure(errorCode: Int) {
+ Log.e(TAG, "takeScreenshot failed code=$errorCode")
+ if (cont.isActive) cont.resume(null)
+ }
+ }
+ )
+ }
+
+ private fun crop(source: Bitmap, bounds: RectF): Bitmap? {
+ val rect = Rect().also { bounds.round(it) }
+ val left = rect.left.coerceIn(0, source.width - 1)
+ val top = rect.top.coerceIn(0, source.height - 1)
+ val right = rect.right.coerceIn(left + 1, source.width)
+ val bottom = rect.bottom.coerceIn(top + 1, source.height)
+ val w = right - left
+ val h = bottom - top
+ if (w < 8 || h < 8) return null
+ return try {
+ Bitmap.createBitmap(source, left, top, w, h)
+ } catch (e: Exception) {
+ Log.e(TAG, "crop failed $rect on ${source.width}x${source.height}", e)
+ null
+ }
+ }
+}
diff --git a/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..8689d02
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..6cd2870
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..81bfa33
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_qs_circle.png b/app/src/main/res/drawable-xhdpi/ic_qs_circle.png
new file mode 100644
index 0000000..378eb2b
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_qs_circle.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..35bf86b
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_qs_circle.png b/app/src/main/res/drawable-xxhdpi/ic_qs_circle.png
new file mode 100644
index 0000000..addf9ee
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_qs_circle.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..a25243f
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_qs_circle.png b/app/src/main/res/drawable-xxxhdpi/ic_qs_circle.png
new file mode 100644
index 0000000..d526afc
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_qs_circle.png differ
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
deleted file mode 100644
index d3aa2b2..0000000
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index e2caff1..a8a8fa5 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,6 +1,5 @@
-
-
+
+
-
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index e2caff1..a8a8fa5 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,6 +1,5 @@
-
-
+
+
-
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 4fa2b94..2090f19 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,11 +1,12 @@
- #6200EE
- #3700B3
- #03DAC6
- #018786
+
+ #F3F5F2
+ #157F7A
+ #0E1A24
+ #1FA7A0
+ #157F7A
#FFFFFF
- #000000
- #8003DAC6
+ #0E1A24
+ #801FA7A0
-
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 835d089..8ce34e7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,14 +1,19 @@
- Crkl
- Crkl Overlay Service
- Allows Crkl to create overlays and capture screen content for local AI assistance. All processing is done on-device with no data leaving your phone.
- Enable Crkl Accessibility Service
+ Circle
+ Circle Overlay
+ Lets Circle draw over other apps and read on-screen text for on-device assist. Nothing leaves your phone unless you use an integration you configure.
+ Enable Circle Overlay
Service Enabled
Service Disabled
Open Accessibility Settings
- Welcome to Crkl
- Privacy-first, on-device AI assistant. No data leaves your device.
- To get started, enable the Crkl Accessibility Service in Settings.
+ Welcome to Circle
+ Privacy-first, on-device assist. Circle text → act.
+ Enable Circle Overlay in Accessibility Settings to show the floating C.
+ Circle Test Fixtures
+ Open test fixtures
+ Circle
+ Draw on screen
+ Enable Accessibility
+ Add Quick Settings tile
-
diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml
index ab85c86..a90063b 100644
--- a/app/src/main/res/xml/accessibility_service_config.xml
+++ b/app/src/main/res/xml/accessibility_service_config.xml
@@ -2,9 +2,11 @@
diff --git a/app/src/test/java/com/example/crkl/accessibility/CrklOverlayBridgeTest.kt b/app/src/test/java/com/example/crkl/accessibility/CrklOverlayBridgeTest.kt
new file mode 100644
index 0000000..2f785e1
--- /dev/null
+++ b/app/src/test/java/com/example/crkl/accessibility/CrklOverlayBridgeTest.kt
@@ -0,0 +1,22 @@
+package com.example.crkl.accessibility
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CrklOverlayBridgeTest {
+
+ @Test
+ fun enterCircleMode_withoutService_returnsFalse() {
+ // Ensure unbound (no live a11y service in JVM unit tests).
+ assertFalse(CrklOverlayBridge.isReady())
+ assertFalse(CrklOverlayBridge.enterCircleMode())
+ }
+
+ @Test
+ fun unbind_clearsOnlyMatchingInstance() {
+ // Smoke: API stays callable; readiness stays false without a real service.
+ assertFalse(CrklOverlayBridge.isReady())
+ assertTrue(!CrklOverlayBridge.enterCircleMode())
+ }
+}
diff --git a/app/src/test/java/com/example/crkl/agent/ActionExecutorTest.kt b/app/src/test/java/com/example/crkl/agent/ActionExecutorTest.kt
new file mode 100644
index 0000000..d2f9913
--- /dev/null
+++ b/app/src/test/java/com/example/crkl/agent/ActionExecutorTest.kt
@@ -0,0 +1,213 @@
+package com.example.crkl.agent
+
+import android.content.Context
+import android.content.Intent
+import com.example.crkl.integrations.DeviceCalendar
+import com.example.crkl.integrations.GogBridgeClient
+import com.example.crkl.integrations.IntegrationSettings
+import com.example.crkl.integrations.OnDeviceTranslator
+import com.example.crkl.integrations.VikunjaClient
+import com.example.crkl.testutil.MemorySharedPreferences
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+
+@RunWith(RobolectricTestRunner::class)
+class ActionExecutorTest {
+
+ private val context: Context get() = RuntimeEnvironment.getApplication()
+
+ private fun settings(
+ token: String = "tok",
+ emailTo: String = "idobkin@gmail.com",
+ preferGog: Boolean = false,
+ gogUrl: String = ""
+ ): IntegrationSettings {
+ val s = IntegrationSettings(MemorySharedPreferences())
+ s.vikunjaToken = token
+ s.emailTo = emailTo
+ s.preferGogEmail = preferGog
+ s.gogBridgeUrl = gogUrl
+ return s
+ }
+
+ private fun session(body: String = "• Milk\n• Eggs") =
+ ActionExecutor.Session(title = "Groceries", body = body, transcript = "")
+
+ @Test
+ fun addToTodo_callsVikunjaWithListItems() = runTest {
+ var captured: List = emptyList()
+ val exec = ActionExecutor(
+ context = context,
+ settings = settings(),
+ createTasks = { titles, _ ->
+ captured = titles
+ VikunjaClient.CreateResult(true, "ok")
+ }
+ )
+ val r = exec.execute(VoiceIntent.parse("add this to my todo"), session())
+ assertTrue(r.ok)
+ assertEquals(listOf("Milk", "Eggs"), captured)
+ }
+
+ @Test
+ fun addToTodo_usesEmailSubjectWhenNoBullets() = runTest {
+ var captured: List = emptyList()
+ val exec = ActionExecutor(
+ context = context,
+ settings = settings(),
+ createTasks = { titles, _ ->
+ captured = titles
+ VikunjaClient.CreateResult(true, "Added 1")
+ }
+ )
+ val emailSession = ActionExecutor.Session(
+ title = "Extracted text",
+ body = """
+ From: alex@example.com
+ Subject: Q2 planning moved
+ Hi — the sync is Thursday.
+ """.trimIndent()
+ )
+ val r = exec.execute(VoiceIntent.parse("add this to my todo"), emailSession)
+ assertTrue(r.ok)
+ assertEquals(listOf("Q2 planning moved"), captured)
+ }
+
+ @Test
+ fun emailList_usesGogWhenPreferred() = runTest {
+ var sentTo: String? = null
+ val exec = ActionExecutor(
+ context = context,
+ settings = settings(preferGog = true, gogUrl = "http://10.0.2.2:8765"),
+ sendViaGog = { to, _, _ ->
+ sentTo = to
+ GogBridgeClient.BridgeResult(true, "Sent via gog")
+ },
+ composeEmail = { _, _, _ -> error("mailto should not run") }
+ )
+ val r = exec.execute(VoiceIntent.parse("email me the list"), session())
+ assertTrue(r.ok)
+ assertEquals("idobkin@gmail.com", sentTo)
+ }
+
+ @Test
+ fun showCalendar_returnsDeviceCalendarMessage() = runTest {
+ val exec = ActionExecutor(
+ context = context,
+ settings = settings(),
+ todayCalendar = {
+ DeviceCalendar.Result(true, "Today’s calendar:\n• 18:00 — Interview")
+ }
+ )
+ val r = exec.execute(VoiceIntent.parse("what's on my calendar"), null)
+ assertTrue(r.ok)
+ assertTrue(r.message.contains("Interview"))
+ }
+
+ @Test
+ fun openTodos_opensVikunjaUrl() = runTest {
+ var opened: String? = null
+ val exec = ActionExecutor(
+ context = context,
+ settings = settings(),
+ openTodosUrl = {
+ opened = it
+ "Opening $it"
+ }
+ )
+ val r = exec.execute(VoiceIntent.parse("open my todos"), null)
+ assertTrue(r.ok)
+ assertEquals(IntegrationSettings.DEFAULT_VIKUNJA_URL, opened)
+ }
+
+ @Test
+ fun translate_usesCircledTextAndTarget() = runTest {
+ var gotText: String? = null
+ var gotTarget: String? = null
+ val exec = ActionExecutor(
+ context = context,
+ settings = settings().also { it.translateTargetLang = "ru" },
+ translateText = { text, target ->
+ gotText = text
+ gotTarget = target
+ OnDeviceTranslator.Result(true, "Translated:\nпривет")
+ }
+ )
+ val session = ActionExecutor.Session(
+ title = "Extracted text",
+ body = "Hello world\n\n—\nCommands: ignore"
+ )
+ val r = exec.execute(VoiceIntent.parse("translate"), session)
+ assertTrue(r.ok)
+ assertTrue(gotText!!.contains("Hello"))
+ assertEquals("ru", gotTarget)
+ }
+
+ @Test
+ fun copy_putsSharePayloadOnClipboard() = runTest {
+ var copied: String? = null
+ val exec = ActionExecutor(
+ context = context,
+ settings = settings(),
+ copyText = { text ->
+ copied = text
+ true
+ }
+ )
+ val r = exec.execute(VoiceIntent.parse("copy"), session())
+ assertTrue(r.ok)
+ assertTrue(r.message.contains("Copied"))
+ assertTrue(copied!!.contains("Milk"))
+ assertTrue(copied!!.contains("Eggs"))
+ }
+
+ @Test
+ fun explain_usesInjectedExplainer() = runTest {
+ var seen: String? = null
+ val exec = ActionExecutor(
+ context = context,
+ settings = settings(),
+ explainText = { text ->
+ seen = text
+ AssistEngine.AssistResponse("Explain", "test", "Plain: meeting moved.")
+ }
+ )
+ val emailSession = ActionExecutor.Session(
+ title = "Extracted text",
+ body = "From: alex@example.com\nSubject: Q2 planning moved\nHi — Thursday."
+ )
+ val r = exec.execute(VoiceIntent.parse("explain"), emailSession)
+ assertTrue(r.ok)
+ assertTrue(r.message.contains("Plain: meeting moved"))
+ assertTrue(seen!!.contains("Subject:"))
+ }
+
+ @Test
+ fun share_alwaysOpensChooserWithPayload() = runTest {
+ var launched: Intent? = null
+ val exec = ActionExecutor(
+ context = context,
+ settings = settings(),
+ launchIntent = { launched = it }
+ )
+ val prose = ActionExecutor.Session(
+ title = "Note",
+ body = "The sync is Thursday afternoon at 3pm."
+ )
+ val r = exec.execute(VoiceIntent.parse("share"), prose)
+ assertTrue(r.ok)
+ assertTrue(r.message.contains("share sheet"))
+ assertEquals(Intent.ACTION_CHOOSER, launched!!.action)
+ @Suppress("DEPRECATION")
+ val share = launched!!.getParcelableExtra(Intent.EXTRA_INTENT)
+ requireNotNull(share)
+ assertEquals(Intent.ACTION_SEND, share.action)
+ assertTrue(share.getStringExtra(Intent.EXTRA_TEXT)!!.contains("Thursday"))
+ }
+}
diff --git a/app/src/test/java/com/example/crkl/agent/AssistEngineTest.kt b/app/src/test/java/com/example/crkl/agent/AssistEngineTest.kt
new file mode 100644
index 0000000..0dad5a1
--- /dev/null
+++ b/app/src/test/java/com/example/crkl/agent/AssistEngineTest.kt
@@ -0,0 +1,57 @@
+package com.example.crkl.agent
+
+import com.example.crkl.model.LocalLlm
+import com.example.crkl.vision.RegionContentExtractor
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class AssistEngineTest {
+
+ private val sampleExtraction = RegionContentExtractor.ExtractionResult(
+ text = "Meeting moved to Thursday at 3pm. Bring the Q2 notes.",
+ nodeCount = 2,
+ bounds = android.graphics.RectF(0f, 0f, 100f, 40f),
+ packageName = "com.google.android.apps.messaging"
+ )
+
+ @Test
+ fun respond_withoutLlm_fallsBackToStubClean() = runBlocking {
+ val engine = AssistEngine(llm = null)
+ val response = engine.respond(sampleExtraction)
+ assertTrue(response.body.contains("Thursday") || response.body.contains("Meeting"))
+ assertFalse(response.body.contains("push-model"))
+ assertFalse(response.body.contains("No on-device model"))
+ assertTrue(response.meta.isEmpty())
+ }
+
+ @Test
+ fun respond_withReadyLlm_usesGeneratedText() = runBlocking {
+ val fake = object : LocalLlm {
+ override val isReady = true
+ override val displayName = "fake-llm"
+ override suspend fun generate(prompt: String): String =
+ "The message reschedules a meeting to Thursday 3pm and asks for Q2 notes."
+ override fun close() = Unit
+ }
+ val engine = AssistEngine(llm = fake)
+ val response = engine.respond(sampleExtraction)
+ assertTrue(response.title.contains("On-device", ignoreCase = true))
+ assertTrue(response.body.contains("Thursday"))
+ assertFalse(response.meta.contains("stub"))
+ }
+
+ @Test
+ fun respond_emptyExtraction_skipsLlm() = runBlocking {
+ val fake = object : LocalLlm {
+ override val isReady = true
+ override val displayName = "fake-llm"
+ override suspend fun generate(prompt: String): String = error("should not be called")
+ override fun close() = Unit
+ }
+ val empty = sampleExtraction.copy(text = " ")
+ val response = AssistEngine(fake).respond(empty)
+ assertTrue(response.title.contains("No text", ignoreCase = true))
+ }
+}
diff --git a/app/src/test/java/com/example/crkl/agent/LocalAssistStubTest.kt b/app/src/test/java/com/example/crkl/agent/LocalAssistStubTest.kt
new file mode 100644
index 0000000..62da565
--- /dev/null
+++ b/app/src/test/java/com/example/crkl/agent/LocalAssistStubTest.kt
@@ -0,0 +1,69 @@
+package com.example.crkl.agent
+
+import com.example.crkl.vision.RegionContentExtractor
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class LocalAssistStubTest {
+
+ @Test
+ fun respond_emptyExtraction_explainsNextStep() {
+ val result = RegionContentExtractor.ExtractionResult(
+ text = "",
+ nodeCount = 0,
+ bounds = android.graphics.RectF(0f, 0f, 10f, 10f),
+ packageName = "com.example.app"
+ )
+ val response = LocalAssistStub.respond(result)
+ assertTrue(response.title.contains("No text", ignoreCase = true))
+ assertTrue(response.body.contains("circle", ignoreCase = true))
+ assertTrue(response.body.contains("Lens", ignoreCase = true))
+ }
+
+ @Test
+ fun respond_withText_includesSummaryAndBody() {
+ val result = RegionContentExtractor.ExtractionResult(
+ text = "Hello world. Second sentence stays in the body.",
+ nodeCount = 3,
+ bounds = android.graphics.RectF(1f, 2f, 30f, 40f),
+ packageName = "com.android.chrome"
+ )
+ val response = LocalAssistStub.respond(result)
+ assertTrue(response.title.contains("Circled", ignoreCase = true))
+ assertTrue(response.body.contains("Hello world"))
+ assertTrue(response.meta.isEmpty())
+ }
+
+ @Test
+ fun respond_ocrJunk_treatedAsEmpty() {
+ val result = RegionContentExtractor.ExtractionResult(
+ text = "… · ·",
+ nodeCount = 0,
+ bounds = android.graphics.RectF(),
+ packageName = "com.example"
+ )
+ val response = LocalAssistStub.respond(result)
+ assertTrue(response.title.contains("No text", ignoreCase = true))
+ assertTrue(response.body.contains("Circle", ignoreCase = true) || response.body.contains("Lens", ignoreCase = true))
+ }
+
+ @Test
+ fun respond_mediaTranscript_extractsGroceryList() {
+ val result = RegionContentExtractor.ExtractionResult(
+ text = """
+ Media: Audio · Grocery
+ Kind: audio
+ Transcript:
+ Hi. Voice memo grocery list. We need milk, eggs, sourdough bread, olive oil, and two avocados. Also pick up oat milk and coffee beans.
+ """.trimIndent(),
+ nodeCount = 1,
+ bounds = android.graphics.RectF(),
+ packageName = "com.example.crkl"
+ )
+ val response = LocalAssistStub.respond(result, mode = AssistEngine.Mode.MEDIA)
+ assertTrue(response.title.contains("Audio", ignoreCase = true))
+ assertTrue(response.body.contains("Milk") || response.body.contains("milk"))
+ assertTrue(response.body.contains("Eggs") || response.body.contains("eggs"))
+ assertTrue(response.body.contains("transcript", ignoreCase = true))
+ }
+}
diff --git a/app/src/test/java/com/example/crkl/agent/VoiceIntentTest.kt b/app/src/test/java/com/example/crkl/agent/VoiceIntentTest.kt
new file mode 100644
index 0000000..5300841
--- /dev/null
+++ b/app/src/test/java/com/example/crkl/agent/VoiceIntentTest.kt
@@ -0,0 +1,130 @@
+package com.example.crkl.agent
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class VoiceIntentTest {
+
+ @Test
+ fun parse_emailMeTheList() {
+ assertEquals(VoiceIntent.Kind.EMAIL_LIST, VoiceIntent.parse("email me the list").kind)
+ }
+
+ @Test
+ fun parse_openTodos() {
+ assertEquals(VoiceIntent.Kind.OPEN_TODOS, VoiceIntent.parse("open my todos").kind)
+ }
+
+ @Test
+ fun parse_openMail() {
+ assertEquals(VoiceIntent.Kind.OPEN_MAIL, VoiceIntent.parse("open Gmail").kind)
+ assertEquals(VoiceIntent.Kind.OPEN_MAIL, VoiceIntent.parse("open mail inbox").kind)
+ }
+
+ @Test
+ fun parse_calendar() {
+ assertEquals(VoiceIntent.Kind.SHOW_CALENDAR, VoiceIntent.parse("what's on my calendar").kind)
+ assertEquals(VoiceIntent.Kind.SHOW_CALENDAR, VoiceIntent.parse("show calendar today").kind)
+ }
+
+ @Test
+ fun parse_sendMeTheList() {
+ val p = VoiceIntent.parse("send me the list")
+ assertEquals(VoiceIntent.Kind.SHARE_LIST, p.kind)
+ }
+
+ @Test
+ fun parse_addToTodo() {
+ val p = VoiceIntent.parse("add this to my todo")
+ assertEquals(VoiceIntent.Kind.ADD_TO_TODO, p.kind)
+ }
+
+ @Test
+ fun parse_addItemsToList() {
+ val p = VoiceIntent.parse("add bananas and yogurt to the list")
+ assertEquals(VoiceIntent.Kind.ADD_ITEMS, p.kind)
+ assertTrue(p.items.any { it.contains("Banana", ignoreCase = true) })
+ assertTrue(p.items.any { it.contains("Yogurt", ignoreCase = true) })
+ }
+
+ @Test
+ fun parse_showTodos() {
+ assertEquals(VoiceIntent.Kind.SHOW_TODOS, VoiceIntent.parse("show my todos").kind)
+ }
+
+ @Test
+ fun parse_summarizeYesterdayEmails() {
+ assertEquals(
+ VoiceIntent.Kind.SUMMARIZE_EMAILS,
+ VoiceIntent.parse("summarize my last 3 emails from yesterday").kind
+ )
+ }
+
+ @Test
+ fun extractListFromBody_bullets() {
+ val body = """
+ Local stub listened:
+ • Milk
+ • Eggs
+ • Olive oil
+ """.trimIndent()
+ val items = VoiceIntent.extractListFromBody(body)
+ assertEquals(listOf("Milk", "Eggs", "Olive oil"), items)
+ }
+
+ @Test
+ fun extractListFromBody_noHardcodedGroceryGuessing() {
+ val prose = "Remember milk and eggs from the store memo"
+ assertTrue(VoiceIntent.extractListFromBody(prose).isEmpty())
+ }
+
+ @Test
+ fun extractListFromBody_singleBullet() {
+ assertEquals(listOf("Only item"), VoiceIntent.extractListFromBody("• Only item"))
+ }
+
+ @Test
+ fun parse_translate() {
+ assertEquals(VoiceIntent.Kind.TRANSLATE, VoiceIntent.parse("translate this").kind)
+ val ru = VoiceIntent.parse("translate to russian")
+ assertEquals(VoiceIntent.Kind.TRANSLATE, ru.kind)
+ assertEquals("ru", ru.targetLang)
+ val he = VoiceIntent.parse("translate to hebrew")
+ assertEquals("he", he.targetLang)
+ }
+
+ @Test
+ fun parse_copy() {
+ assertEquals(VoiceIntent.Kind.COPY, VoiceIntent.parse("copy").kind)
+ assertEquals(VoiceIntent.Kind.COPY, VoiceIntent.parse("copy to clipboard").kind)
+ }
+
+ @Test
+ fun parse_explain() {
+ assertEquals(VoiceIntent.Kind.EXPLAIN, VoiceIntent.parse("explain this").kind)
+ assertEquals(VoiceIntent.Kind.EXPLAIN, VoiceIntent.parse("eli5").kind)
+ assertEquals(VoiceIntent.Kind.EXPLAIN, VoiceIntent.parse("what does this mean").kind)
+ }
+
+ @Test
+ fun parse_share_alone() {
+ assertEquals(VoiceIntent.Kind.SHARE_LIST, VoiceIntent.parse("share").kind)
+ assertEquals(VoiceIntent.Kind.SHARE_LIST, VoiceIntent.parse("share this").kind)
+ }
+
+ @Test
+ fun extractTextForTranslate_stripsChrome() {
+ val body = """
+ Short summary line
+ —
+ The meeting is postponed until Thursday afternoon.
+ —
+ Commands: speak …
+ """.trimIndent()
+ val text = VoiceIntent.extractTextForTranslate("Extracted text", body)
+ assertTrue(text.contains("postponed"))
+ assertFalse(text.contains("Commands:"))
+ }
+}
diff --git a/app/src/test/java/com/example/crkl/fixtures/FixtureCatalogTest.kt b/app/src/test/java/com/example/crkl/fixtures/FixtureCatalogTest.kt
new file mode 100644
index 0000000..a4fbee1
--- /dev/null
+++ b/app/src/test/java/com/example/crkl/fixtures/FixtureCatalogTest.kt
@@ -0,0 +1,82 @@
+package com.example.crkl.fixtures
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class FixtureCatalogTest {
+
+ @Test
+ fun catalog_coversRequestedContentKinds() {
+ val kinds = FixtureCatalog.all.map { it.kind }.toSet()
+ assertTrue(kinds.contains(FixtureCatalog.ContentKind.EMAIL))
+ assertTrue(kinds.contains(FixtureCatalog.ContentKind.IMAGE))
+ assertTrue(kinds.contains(FixtureCatalog.ContentKind.VIDEO))
+ assertTrue(kinds.contains(FixtureCatalog.ContentKind.AUDIO))
+ assertTrue(kinds.contains(FixtureCatalog.ContentKind.ARTICLE))
+ }
+
+ @Test
+ fun extractableNow_includesEmailAndDescribedImage() {
+ val ids = FixtureCatalog.extractableNow().map { it.id }.toSet()
+ assertTrue(ids.contains("email_meeting"))
+ assertTrue(ids.contains("article_paragraph"))
+ assertTrue(ids.contains("image_with_description"))
+ }
+
+ @Test
+ fun pixelsOnlyImage_markedNeedsOcr() {
+ val fixture = FixtureCatalog.byId("image_pixels_only")
+ assertEquals(FixtureCatalog.Expectation.NEEDS_OCR, fixture.expectation)
+ assertTrue(fixture.contentDescription.isNullOrBlank())
+ }
+
+ @Test
+ fun gmailInboxFixture_isExtractable() {
+ val inbox = FixtureCatalog.byId("mail_inbox_yesterday")
+ assertEquals(FixtureCatalog.Expectation.EXTRACTABLE_NOW, inbox.expectation)
+ assertTrue(inbox.body.contains("Yesterday"))
+ assertTrue(inbox.body.contains("jordan@example.com"))
+ assertFalse(
+ "howToTest must not mention deleted Fake Mail or gog bridge",
+ inbox.howToTest.contains("Fake Mail", ignoreCase = true) ||
+ inbox.howToTest.contains("gog", ignoreCase = true)
+ )
+ assertTrue(inbox.howToTest.contains("mailto"))
+ }
+
+ @Test
+ fun mediaFixtures_haveAssetsAndSummarizeExpectation() {
+ val video = FixtureCatalog.byId("video_walkthrough")
+ val audio = FixtureCatalog.byId("audio_grocery")
+ assertEquals(FixtureCatalog.Expectation.MEDIA_PLAY_AND_SUMMARIZE, video.expectation)
+ assertEquals(FixtureCatalog.Expectation.MEDIA_PLAY_AND_SUMMARIZE, audio.expectation)
+ assertNotNull(video.mediaAssetPath)
+ assertNotNull(audio.mediaAssetPath)
+ assertNotNull(video.transcriptAssetPath)
+ assertNotNull(audio.transcriptAssetPath)
+ assertEquals(2, FixtureCatalog.mediaFixtures().size)
+ }
+
+ @Test
+ fun demoScript_coversVipChipsInOrder() {
+ val steps = FixtureCatalog.demoScript()
+ assertEquals(4, steps.size)
+ assertEquals("translate_phrase", steps[0].fixtureId)
+ assertEquals("article_paragraph", steps[1].fixtureId)
+ assertEquals("email_meeting", steps[2].fixtureId)
+ assertEquals("grocery_list", steps[3].fixtureId)
+ steps.forEach { step ->
+ assertNotNull(FixtureCatalog.byId(step.fixtureId))
+ }
+ }
+
+ @Test
+ fun groceryList_hasBulletsForVikunja() {
+ val list = FixtureCatalog.byId("grocery_list")
+ assertTrue(list.body.contains("• Milk"))
+ assertTrue(list.howToTest.contains("Vikunja"))
+ }
+}
diff --git a/app/src/test/java/com/example/crkl/integrations/DeviceCalendarTest.kt b/app/src/test/java/com/example/crkl/integrations/DeviceCalendarTest.kt
new file mode 100644
index 0000000..fe58a2a
--- /dev/null
+++ b/app/src/test/java/com/example/crkl/integrations/DeviceCalendarTest.kt
@@ -0,0 +1,20 @@
+package com.example.crkl.integrations
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+
+@RunWith(RobolectricTestRunner::class)
+class DeviceCalendarTest {
+
+ @Test
+ fun today_withoutPermission_returnsClearError() {
+ val r = DeviceCalendar.today(RuntimeEnvironment.getApplication())
+ assertFalse(r.ok)
+ assertTrue(r.message.contains("permission", ignoreCase = true))
+ assertTrue(r.events.isEmpty())
+ }
+}
diff --git a/app/src/test/java/com/example/crkl/integrations/IntegrationSettingsTest.kt b/app/src/test/java/com/example/crkl/integrations/IntegrationSettingsTest.kt
new file mode 100644
index 0000000..f6471d1
--- /dev/null
+++ b/app/src/test/java/com/example/crkl/integrations/IntegrationSettingsTest.kt
@@ -0,0 +1,35 @@
+package com.example.crkl.integrations
+
+import com.example.crkl.testutil.MemorySharedPreferences
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class IntegrationSettingsTest {
+
+ @Test
+ fun defaults_includeCircleStyle() {
+ val s = IntegrationSettings(MemorySharedPreferences())
+ assertEquals(IntegrationSettings.DEFAULT_VIKUNJA_URL, s.vikunjaUrl)
+ assertEquals("cyan", s.circleColorKey)
+ assertTrue(s.circleNeon)
+ assertFalse(s.preferGogEmail)
+ assertTrue(s.statusSummary().contains("Circle: cyan"))
+ }
+
+ @Test
+ fun vikunjaReady_requiresToken() {
+ val s = IntegrationSettings(MemorySharedPreferences())
+ s.vikunjaToken = "tok"
+ assertTrue(s.vikunjaReady())
+ }
+
+ @Test
+ fun onboardingComplete_defaultsFalse() {
+ val s = IntegrationSettings(MemorySharedPreferences())
+ assertFalse(s.onboardingComplete)
+ s.onboardingComplete = true
+ assertTrue(s.onboardingComplete)
+ }
+}
diff --git a/app/src/test/java/com/example/crkl/media/ActiveMediaRegistryTest.kt b/app/src/test/java/com/example/crkl/media/ActiveMediaRegistryTest.kt
new file mode 100644
index 0000000..0c62347
--- /dev/null
+++ b/app/src/test/java/com/example/crkl/media/ActiveMediaRegistryTest.kt
@@ -0,0 +1,69 @@
+package com.example.crkl.media
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class ActiveMediaRegistryTest {
+
+ @Before
+ fun clear() {
+ ActiveMediaRegistry.clear()
+ }
+
+ @Test
+ fun findOverlapping_returnsBestOverlap() {
+ ActiveMediaRegistry.register(
+ ActiveMediaRegistry.Slot(
+ id = "audio_grocery",
+ kind = ActiveMediaRegistry.Kind.AUDIO,
+ assetPath = "media/grocery_memo.wav",
+ transcriptAssetPath = "media/grocery_memo.transcript.txt",
+ title = "Audio · Grocery",
+ screenBounds = ActiveMediaRegistry.Bounds(100f, 1000f, 900f, 1400f)
+ )
+ )
+ ActiveMediaRegistry.register(
+ ActiveMediaRegistry.Slot(
+ id = "video_walkthrough",
+ kind = ActiveMediaRegistry.Kind.VIDEO,
+ assetPath = "media/product_walkthrough.mp4",
+ transcriptAssetPath = "media/product_walkthrough.transcript.txt",
+ title = "Video · Walkthrough",
+ screenBounds = ActiveMediaRegistry.Bounds(100f, 200f, 900f, 700f)
+ )
+ )
+
+ val hit = ActiveMediaRegistry.findOverlapping(
+ ActiveMediaRegistry.Bounds(120f, 1050f, 880f, 1350f)
+ )
+ assertEquals("audio_grocery", hit?.id)
+
+ assertNull(
+ ActiveMediaRegistry.findOverlapping(
+ ActiveMediaRegistry.Bounds(0f, 0f, 10f, 10f)
+ )
+ )
+ }
+
+ @Test
+ fun updateBounds_mutatesSlot() {
+ ActiveMediaRegistry.register(
+ ActiveMediaRegistry.Slot(
+ id = "x",
+ kind = ActiveMediaRegistry.Kind.AUDIO,
+ assetPath = "a",
+ transcriptAssetPath = "t",
+ title = "t"
+ )
+ )
+ ActiveMediaRegistry.updateBounds(
+ "x",
+ ActiveMediaRegistry.Bounds(1f, 2f, 300f, 400f)
+ )
+ val slot = ActiveMediaRegistry.all().single()
+ assertTrue(slot.screenBounds.right - slot.screenBounds.left > 200f)
+ }
+}
diff --git a/app/src/test/java/com/example/crkl/model/ModelPathsTest.kt b/app/src/test/java/com/example/crkl/model/ModelPathsTest.kt
new file mode 100644
index 0000000..1cefa4d
--- /dev/null
+++ b/app/src/test/java/com/example/crkl/model/ModelPathsTest.kt
@@ -0,0 +1,29 @@
+package com.example.crkl.model
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+class ModelPathsTest {
+
+ @Test
+ fun findModel_prefersAdbPathWhenPresent() {
+ val tmp = createTempDir(prefix = "crkl-model-test")
+ try {
+ // Simulate filesDir with no app models; we only assert candidate ordering.
+ val candidates = ModelPaths.candidateFiles(tmp)
+ assertEquals(ModelPaths.ADB_MODEL_PATH, candidates.first().absolutePath)
+ assertTrue(candidates.any { it.name == "gemma-3-1b-it-int4.task" })
+ } finally {
+ tmp.deleteRecursively()
+ }
+ }
+
+ @Test
+ fun missingModelHint_mentionsMakeTarget() {
+ val hint = ModelPaths.missingModelHint()
+ assertTrue(hint.contains("push-model"))
+ assertTrue(hint.contains(ModelPaths.ADB_MODEL_PATH))
+ }
+}
diff --git a/app/src/test/java/com/example/crkl/testutil/MemorySharedPreferences.kt b/app/src/test/java/com/example/crkl/testutil/MemorySharedPreferences.kt
new file mode 100644
index 0000000..4f7987b
--- /dev/null
+++ b/app/src/test/java/com/example/crkl/testutil/MemorySharedPreferences.kt
@@ -0,0 +1,99 @@
+package com.example.crkl.testutil
+
+import android.content.SharedPreferences
+
+/** Minimal in-memory [SharedPreferences] for JVM unit tests (no Robolectric). */
+class MemorySharedPreferences : SharedPreferences {
+ private val map = mutableMapOf()
+
+ override fun getAll(): MutableMap = map.toMutableMap()
+
+ override fun getString(key: String?, defValue: String?): String? =
+ map[key] as? String ?: defValue
+
+ override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? {
+ @Suppress("UNCHECKED_CAST")
+ return (map[key] as? Set)?.toMutableSet() ?: defValues
+ }
+
+ override fun getInt(key: String?, defValue: Int): Int = map[key] as? Int ?: defValue
+
+ override fun getLong(key: String?, defValue: Long): Long = map[key] as? Long ?: defValue
+
+ override fun getFloat(key: String?, defValue: Float): Float = map[key] as? Float ?: defValue
+
+ override fun getBoolean(key: String?, defValue: Boolean): Boolean =
+ map[key] as? Boolean ?: defValue
+
+ override fun contains(key: String?): Boolean = map.containsKey(key)
+
+ override fun edit(): SharedPreferences.Editor = Editor()
+
+ override fun registerOnSharedPreferenceChangeListener(
+ listener: SharedPreferences.OnSharedPreferenceChangeListener?
+ ) = Unit
+
+ override fun unregisterOnSharedPreferenceChangeListener(
+ listener: SharedPreferences.OnSharedPreferenceChangeListener?
+ ) = Unit
+
+ private inner class Editor : SharedPreferences.Editor {
+ private val pending = mutableMapOf()
+ private val removals = mutableSetOf()
+ private var clearAll = false
+
+ override fun putString(key: String?, value: String?): SharedPreferences.Editor {
+ pending[key!!] = value
+ return this
+ }
+
+ override fun putStringSet(key: String?, values: MutableSet?): SharedPreferences.Editor {
+ pending[key!!] = values
+ return this
+ }
+
+ override fun putInt(key: String?, value: Int): SharedPreferences.Editor {
+ pending[key!!] = value
+ return this
+ }
+
+ override fun putLong(key: String?, value: Long): SharedPreferences.Editor {
+ pending[key!!] = value
+ return this
+ }
+
+ override fun putFloat(key: String?, value: Float): SharedPreferences.Editor {
+ pending[key!!] = value
+ return this
+ }
+
+ override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor {
+ pending[key!!] = value
+ return this
+ }
+
+ override fun remove(key: String?): SharedPreferences.Editor {
+ removals.add(key!!)
+ return this
+ }
+
+ override fun clear(): SharedPreferences.Editor {
+ clearAll = true
+ return this
+ }
+
+ override fun commit(): Boolean {
+ apply()
+ return true
+ }
+
+ override fun apply() {
+ if (clearAll) map.clear()
+ removals.forEach { map.remove(it) }
+ map.putAll(pending)
+ pending.clear()
+ removals.clear()
+ clearAll = false
+ }
+ }
+}
diff --git a/app/src/test/java/com/example/crkl/vision/ContentCaptureTest.kt b/app/src/test/java/com/example/crkl/vision/ContentCaptureTest.kt
new file mode 100644
index 0000000..b884aa7
--- /dev/null
+++ b/app/src/test/java/com/example/crkl/vision/ContentCaptureTest.kt
@@ -0,0 +1,48 @@
+package com.example.crkl.vision
+
+import android.graphics.RectF
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ContentCaptureTest {
+
+ private val base = ContentCapture.CaptureResult(
+ text = "Hello",
+ nodeCount = 1,
+ bounds = RectF(0f, 0f, 10f, 10f),
+ packageName = "com.example",
+ source = ContentCapture.Source.A11Y
+ )
+
+ @Test
+ fun merge_prefersOcrWhenA11yEmpty() {
+ val empty = base.copy(text = "", source = ContentCapture.Source.EMPTY)
+ val merged = ContentCapture.mergeWithOcr(empty, "From OCR", 2)
+ assertEquals(ContentCapture.Source.OCR, merged.source)
+ assertEquals("From OCR", merged.text)
+ assertEquals(2, merged.ocrBlockCount)
+ }
+
+ @Test
+ fun merge_combinesDistinctTexts() {
+ val merged = ContentCapture.mergeWithOcr(base, "World", 1)
+ assertEquals(ContentCapture.Source.MERGED, merged.source)
+ assertTrue(merged.text.contains("Hello"))
+ assertTrue(merged.text.contains("World"))
+ }
+
+ @Test
+ fun looksLikeMedia_detectsAudioMarkers() {
+ assertTrue(ContentCapture.looksLikeMedia("♫ Voice memo", null))
+ assertTrue(ContentCapture.looksLikeMedia("play", "com.example.videoplayer"))
+ }
+
+ @Test
+ fun hasUsefulText_requiresLetters() {
+ assertTrue(ContentCapture.hasUsefulText("Hello world"))
+ assertFalse(ContentCapture.hasUsefulText("… · ·"))
+ assertFalse(ContentCapture.hasUsefulText("123"))
+ }
+}
diff --git a/docs/DEMO.md b/docs/DEMO.md
new file mode 100644
index 0000000..71cd757
--- /dev/null
+++ b/docs/DEMO.md
@@ -0,0 +1,33 @@
+# DEMO — 45–60 second recording
+
+Product: **Circle** (repo `crkl`) — circle on-screen text → Translate · Copy · Explain · Share · Vikunja.
+
+## Prep (once)
+
+1. Install APK → open **Circle** → enable **Circle Overlay** (Accessibility)
+2. Integrations → Vikunja token (for Vikunja steps)
+3. Optional: **Add Quick Settings tile**
+4. Open **Test fixtures**
+
+## Take (script)
+
+| Time | Action |
+|------|--------|
+| 0:00 | Show fixtures. Tap floating **C** (or QS **Circle**). |
+| 0:05 | **Translate** fixture — closed loop → **Translate**. |
+| 0:15 | **Article** — circle paragraph → **Copy**, then **Explain**. |
+| 0:30 | **Email** — circle Subject `Q2 planning moved` → **Vikunja**. |
+| 0:45 | **List** — circle bullets → **Share** or **Vikunja**. |
+| 0:55 | Dismiss. End on chips or confirmation. |
+
+Voiceover: “Circle any text. Act without leaving the screen.”
+
+## Do not show
+
+- Debug meta, gog, model install hints
+- Image / Lens paths
+- Failed mailto on emu without a mail app
+
+## Source of truth
+
+`FixtureCatalog.demoScript()` — also printed atop Test fixtures.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..319536d
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,15 @@
+# Circle docs
+
+Product name: **Circle**. Repo / package code name: **crkl** (`com.example.crkl`).
+
+| Doc | Purpose |
+|-----|---------|
+| [DEMO.md](DEMO.md) | 45s recording script |
+| [dogfood.md](dogfood.md) | Daily 10-minute checklist |
+| [non-goals.md](non-goals.md) | Ship freeze — what we are not building |
+| [marketing.md](marketing.md) | Blurb + 3 social posts |
+| [shortcuts.md](shortcuts.md) | QS tile, Accessibility button, volume keys |
+| [RELEASE.md](RELEASE.md) | Install, tag, headless emu tips |
+| [brand/README.md](brand/README.md) | Logo concepts (shipped: **#2 lasso**) |
+
+Root also has [README.md](../README.md), [CHANGELOG.md](../CHANGELOG.md), [PHONE_SETUP.md](../PHONE_SETUP.md).
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
new file mode 100644
index 0000000..0986cfa
--- /dev/null
+++ b/docs/RELEASE.md
@@ -0,0 +1,36 @@
+# Release checklist — Circle 1.16.2
+
+## Before tag
+
+- [x] Unit tests green (`./gradlew testDebugUnitTest`)
+- [x] Logo 2 (lasso) as launcher + QS
+- [x] Display name Circle / Circle Overlay
+- [x] DEMO, dogfood, non-goals, marketing, shortcuts, brand docs
+- [x] On-device install + Circle Overlay enabled (`make smoke` on CrklEmulator)
+- [ ] Dogfood checklist once (Translate / Explain / Share / Vikunja / real app)
+- [ ] Record 45s demo
+
+## Tag (when ready)
+
+```bash
+cd ~/Documents/code/crkl
+git status
+git tag -a v1.16.2-circle -m "Circle: lasso brand, VIP chips, shortcuts, ship docs"
+git push origin main
+git push origin v1.16.2-circle
+```
+
+## Install
+
+```bash
+./gradlew assembleDebug
+adb install -r app/build/outputs/apk/debug/app-debug.apk
+adb shell am start -n com.example.crkl/.MainActivity
+```
+
+Re-enable **Circle Overlay** after every reinstall.
+
+## Emulator tips (macOS)
+
+Prefer `make emulator` (launchd) — agent/Cursor sandboxes often kill nohup qemu.
+Then: `make wait-emulator && make install && make smoke`
diff --git a/docs/brand/README.md b/docs/brand/README.md
new file mode 100644
index 0000000..690282f
--- /dev/null
+++ b/docs/brand/README.md
@@ -0,0 +1,12 @@
+# Brand / logo concepts
+
+**Shipped:** concept **2** — paper field, teal hand-drawn lasso, ink **C**
+(`ic_launcher_foreground` + QS tile rasters from `circle-logo-2-lasso-c.png`).
+
+| File | Idea | Status |
+|------|------|--------|
+| `circle-logo-1-ink-ring.png` | Ink field, white **C**, teal ring | Alternate |
+| `circle-logo-2-lasso-c.png` | Paper + teal lasso + ink **C** | **In app** |
+| `circle-logo-3-split-c.png` | Split ink / teal, bold white **C** | Alternate |
+
+Display name: **Circle** (repo/package still `crkl`).
diff --git a/docs/brand/circle-logo-1-ink-ring.png b/docs/brand/circle-logo-1-ink-ring.png
new file mode 100644
index 0000000..971909a
Binary files /dev/null and b/docs/brand/circle-logo-1-ink-ring.png differ
diff --git a/docs/brand/circle-logo-2-lasso-c.png b/docs/brand/circle-logo-2-lasso-c.png
new file mode 100644
index 0000000..ae7a079
Binary files /dev/null and b/docs/brand/circle-logo-2-lasso-c.png differ
diff --git a/docs/brand/circle-logo-3-split-c.png b/docs/brand/circle-logo-3-split-c.png
new file mode 100644
index 0000000..ab676eb
Binary files /dev/null and b/docs/brand/circle-logo-3-split-c.png differ
diff --git a/docs/demo-shots/crkl-audio-summary.png b/docs/demo-shots/crkl-audio-summary.png
new file mode 100644
index 0000000..c3573c1
Binary files /dev/null and b/docs/demo-shots/crkl-audio-summary.png differ
diff --git a/docs/demo-shots/crkl-email-result.png b/docs/demo-shots/crkl-email-result.png
new file mode 100644
index 0000000..80c2094
Binary files /dev/null and b/docs/demo-shots/crkl-email-result.png differ
diff --git a/docs/demo-shots/crkl-ocr-result.png b/docs/demo-shots/crkl-ocr-result.png
new file mode 100644
index 0000000..35d9be1
Binary files /dev/null and b/docs/demo-shots/crkl-ocr-result.png differ
diff --git a/docs/demo-shots/crkl-video-result.png b/docs/demo-shots/crkl-video-result.png
new file mode 100644
index 0000000..4d4a4f9
Binary files /dev/null and b/docs/demo-shots/crkl-video-result.png differ
diff --git a/docs/demo-shots/workflows/01-email.png b/docs/demo-shots/workflows/01-email.png
new file mode 100644
index 0000000..4a9207a
Binary files /dev/null and b/docs/demo-shots/workflows/01-email.png differ
diff --git a/docs/demo-shots/workflows/02-ocr.png b/docs/demo-shots/workflows/02-ocr.png
new file mode 100644
index 0000000..d7b2e80
Binary files /dev/null and b/docs/demo-shots/workflows/02-ocr.png differ
diff --git a/docs/demo-shots/workflows/03-audio-summary.png b/docs/demo-shots/workflows/03-audio-summary.png
new file mode 100644
index 0000000..7ef6628
Binary files /dev/null and b/docs/demo-shots/workflows/03-audio-summary.png differ
diff --git a/docs/demo-shots/workflows/04-todos.png b/docs/demo-shots/workflows/04-todos.png
new file mode 100644
index 0000000..9dc278b
Binary files /dev/null and b/docs/demo-shots/workflows/04-todos.png differ
diff --git a/docs/demo-shots/workflows/05-fake-mail.png b/docs/demo-shots/workflows/05-fake-mail.png
new file mode 100644
index 0000000..5e119c4
Binary files /dev/null and b/docs/demo-shots/workflows/05-fake-mail.png differ
diff --git a/docs/demo-shots/workflows/06-fake-compose.png b/docs/demo-shots/workflows/06-fake-compose.png
new file mode 100644
index 0000000..d6d2960
Binary files /dev/null and b/docs/demo-shots/workflows/06-fake-compose.png differ
diff --git a/docs/demo-shots/workflows/07-video.png b/docs/demo-shots/workflows/07-video.png
new file mode 100644
index 0000000..1237ad8
Binary files /dev/null and b/docs/demo-shots/workflows/07-video.png differ
diff --git a/docs/dogfood.md b/docs/dogfood.md
new file mode 100644
index 0000000..ffb4ba5
--- /dev/null
+++ b/docs/dogfood.md
@@ -0,0 +1,34 @@
+# Dogfood checklist (10 minutes)
+
+Run during ship freeze. Bugs only — no new features.
+
+## Setup
+
+- [ ] **Circle Overlay** ON (floating C visible)
+- [ ] Optional: QS **Circle** tile works
+- [ ] Vikunja token set (if testing todo)
+- [ ] Build: `1.16.2-circle-copy` (or newer)
+
+## Five circles
+
+| # | Target | Chip | Pass? |
+|---|--------|------|-------|
+| 1 | Fixtures → Translate phrase | Translate | |
+| 2 | Fixtures → Article | Copy | |
+| 3 | Fixtures → Article | Explain | |
+| 4 | Fixtures → Email subject | Vikunja | |
+| 5 | Chrome or Messages (real app) | any of 5 | |
+
+## Flow sanity
+
+- [ ] Open stroke shows “Close the loop…”
+- [ ] Closed loop → bottom sheet slides up
+- [ ] Empty / image-only → honest “No text found”
+- [ ] Panel has no debug meta (unless Developer toggle on)
+- [ ] Launcher shows **lasso** logo (paper + teal loop + ink C)
+
+## Notes
+
+_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.
+
diff --git a/docs/marketing.md b/docs/marketing.md
new file mode 100644
index 0000000..d5984ce
--- /dev/null
+++ b/docs/marketing.md
@@ -0,0 +1,33 @@
+# Marketing — Circle
+
+Code / repo: **crkl**. Product: **Circle**.
+
+## One sentence
+
+Circle text anywhere on Android and translate, copy, explain, share, or add it to Vikunja — privately, on your phone.
+
+## Landing blurb
+
+**Circle** is a privacy-first Android assist: draw a loop around on-screen text, then act. No cloud AI required. Connect Vikunja for todos; Translate runs on-device (ML Kit).
+
+## Three posts (same demo link)
+
+### 1 · Self-host / Vikunja
+
+Built a small Android tool for my homelab: circle an email subject → task lands in Vikunja. On-device OCR + a11y, no SaaS AI middleman. Demo: [link]
+
+### 2 · Bilingual / Translate
+
+Tired of copy-paste into Translate. Circle a sentence on any app → Translate chip. Sideload for now. Demo: [link]
+
+### 3 · Privacy / power users
+
+Android accessibility overlay that stays local: circle → Copy / Explain / Share / todo. Not a Lens clone — text actions only. Demo: [link]
+
+## Demo video
+
+Follow [DEMO.md](DEMO.md). Target 45–60s. Host externally or `docs/assets/demo.mp4`.
+
+## Brand
+
+Launcher: logo **#2** (teal lasso + ink C on paper). See [brand/README.md](brand/README.md).
diff --git a/docs/non-goals.md b/docs/non-goals.md
new file mode 100644
index 0000000..9473bd7
--- /dev/null
+++ b/docs/non-goals.md
@@ -0,0 +1,18 @@
+# Non-goals (ship freeze)
+
+Product: **Circle**. Locked for this release — bugs / copy / docs / logo only.
+
+| Parked | Why |
+|--------|-----|
+| Google Lens / reverse image | Not our wedge |
+| Screenshot SystemUI hook | OEM-only UX |
+| Image recrop product UI | Distracts from text loop |
+| gog as required email | Lab-only under Advanced |
+| Calendar create events | Out of scope |
+| On-device LLM required | Stub Explain is enough |
+| Double-power remapping | Not available to third parties |
+| iOS | Out of window |
+| Full Play Store polish | Sideload + demo first |
+| Rename git repo off `crkl` | Optional later; display name is already Circle |
+
+Allowed during freeze: bug fixes, copy, icons/logo, docs, dogfood.
diff --git a/docs/shortcuts.md b/docs/shortcuts.md
new file mode 100644
index 0000000..d1dc925
--- /dev/null
+++ b/docs/shortcuts.md
@@ -0,0 +1,31 @@
+# Circle shortcuts
+
+Ways to start **circle mode** without hunting for the floating C.
+
+## Quick Settings tile (recommended)
+
+1. Enable **Circle Overlay** (Accessibility).
+2. Open Circle → **Add Quick Settings tile** (Android 13+), *or* pull down QS → edit → add **Circle**.
+3. Anywhere: swipe down → tap **Circle** → draw a closed loop.
+
+If Accessibility is off, the tile opens the app and asks you to enable it.
+
+## Accessibility button
+
+1. Settings → **Accessibility** → **Accessibility button** (labels vary by OEM).
+2. Show / assign the button while Circle Overlay is enabled.
+3. Tap it → Circle enters draw mode.
+
+## Volume-key Accessibility shortcut
+
+1. Settings → **Accessibility** → **Accessibility shortcut** (or “Volume key shortcut”).
+2. Choose **Circle Overlay**.
+3. Hold **both volume keys** to trigger.
+
+## Not supported
+
+- Double / triple **power** button — reserved by Android (camera / wallet / Assistant).
+
+## Floating C
+
+Still available when Accessibility is on (bottom-right ink **C** with teal ring).
diff --git a/scripts/e2e-workflows.sh b/scripts/e2e-workflows.sh
new file mode 100755
index 0000000..9ee3b4f
--- /dev/null
+++ b/scripts/e2e-workflows.sh
@@ -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
diff --git a/scripts/gog-bridge.py b/scripts/gog-bridge.py
new file mode 100755
index 0000000..bf31d43
--- /dev/null
+++ b/scripts/gog-bridge.py
@@ -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()
diff --git a/scripts/smoke-circle.sh b/scripts/smoke-circle.sh
new file mode 100755
index 0000000..dc63d1f
--- /dev/null
+++ b/scripts/smoke-circle.sh
@@ -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)."
diff --git a/scripts/start-emulator-launchd.sh b/scripts/start-emulator-launchd.sh
new file mode 100755
index 0000000..e3215b3
--- /dev/null
+++ b/scripts/start-emulator-launchd.sh
@@ -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" <
+
+
+
+ Label${LABEL}
+ ProgramArguments
+
+ ${ANDROID_HOME}/emulator/emulator
+ -avd${AVD_NAME}
+ -memory3072
+ -cores4
+ -no-audio
+ -gpu${GPU}
+ -accelon
+ -no-snapshot-load
+ -grpc8554
+ -no-metrics
+
+ EnvironmentVariables
+
+ ANDROID_HOME${ANDROID_HOME}
+ PATH
+ ${ANDROID_HOME}/emulator:${ANDROID_HOME}/platform-tools:/usr/bin:/bin
+
+ StandardOutPath/tmp/crkl-emulator.log
+ StandardErrorPath/tmp/crkl-emulator.log
+ RunAtLoad
+
+
+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)"
diff --git a/scripts/test-env.sh b/scripts/test-env.sh
new file mode 100755
index 0000000..933162f
--- /dev/null
+++ b/scripts/test-env.sh
@@ -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 ""
diff --git a/scripts/wait-emulator.sh b/scripts/wait-emulator.sh
new file mode 100755
index 0000000..585c4cc
--- /dev/null
+++ b/scripts/wait-emulator.sh
@@ -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