Merge pull request 'Ship Circle 1.16.2' (#4) from release/1.16.2-circle into main
CI / skip-ci-check (push) Successful in 34s
CI / secret-scan (push) Successful in 29s

This commit was merged in pull request #4.
This commit is contained in:
2026-08-05 11:31:28 -05:00
116 changed files with 7561 additions and 715 deletions
+9
View File
@@ -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=
+8 -2
View File
@@ -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/
# Models (pushed to device; never commit weights)
*.task
*.litertlm
*.gguf
models/
+25 -28
View File
@@ -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
+32
View File
@@ -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:**
+12 -9
View File
@@ -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)
+20 -9
View File
@@ -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
+5 -2
View File
@@ -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
+52
View File
@@ -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.
+10 -13
View File
@@ -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!
+4 -3
View File
@@ -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
+201 -101
View File
@@ -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)'"
+51 -72
View File
@@ -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.
+73 -76
View File
@@ -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
See repository license file.
+23 -6
View File
@@ -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"))
+41 -9
View File
@@ -2,15 +2,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Accessibility service permission -->
<uses-permission android:name="android.permission.BIND_ACCESSIBILITY_SERVICE"
tools:ignore="ProtectedPermissions" />
<!-- System alert window for overlay (fallback) -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- For capturing screen content if needed -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<application
android:allowBackup="true"
@@ -19,9 +19,9 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Crkl"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<!-- Main Activity (for settings/permissions) -->
<activity
android:name=".MainActivity"
android:exported="true"
@@ -33,7 +33,24 @@
</intent-filter>
</activity>
<!-- Accessibility Service -->
<activity
android:name=".IntegrationsActivity"
android:exported="true"
android:label="Circle Integrations"
android:theme="@style/Theme.Crkl" />
<activity
android:name=".fixtures.TestFixturesActivity"
android:exported="true"
android:label="@string/test_fixtures_label"
android:theme="@style/Theme.Crkl" />
<activity
android:name=".capture.ProjectionPermissionActivity"
android:exported="false"
android:excludeFromRecents="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<service
android:name=".accessibility.CrklAccessibilityService"
android:exported="false"
@@ -46,7 +63,22 @@
android:resource="@xml/accessibility_service_config" />
</service>
<service
android:name=".accessibility.CrklCircleTileService"
android:exported="true"
android:icon="@drawable/ic_qs_circle"
android:label="@string/qs_tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<service
android:name=".capture.CaptureForegroundService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
</application>
</manifest>
@@ -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.
Binary file not shown.
Binary file not shown.
@@ -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.
@@ -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)
}
}
}
}
}
}
@@ -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 wont 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)
}
}
}
@@ -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<android.view.accessibility.AccessibilityWindowInfo>? = 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) · “whats 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()
}
@@ -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)
}
}
}
@@ -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
}
}
@@ -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<PointF>()
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)
}
}
}
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
)
}
@@ -1,2 +0,0 @@
# Dialogue state management
@@ -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<String>, 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<String> = mutableListOf()
) {
fun listItems(): List<String> {
if (workingList.isNotEmpty()) return workingList.toList()
return VoiceIntent.extractListFromBody(body + "\n" + transcript)
}
fun todoItems(): List<String> {
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,
"Couldnt 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<String>): Result {
if (items.isEmpty()) {
return Result(false, "Didnt 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}")
}
}
}
@@ -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"
}
@@ -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<String> {
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
}
}
@@ -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<String> = 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<String>? {
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<String> {
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<String> {
val items = mutableListOf<String>()
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<String> {
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)
}
}
@@ -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))
}
}
}
@@ -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<MediaProjection?>(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
}
@@ -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<ByteArray>()
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<ByteArray>, 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)
}
}
@@ -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)
}
}
}
@@ -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<Fixture> = 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<Fixture> =
all.filter { it.expectation == Expectation.EXTRACTABLE_NOW }
fun mediaFixtures(): List<Fixture> =
all.filter { it.mediaAssetPath != null }
/** Ordered VIP demo path for ship / recording (see docs/DEMO.md). */
fun demoScript(): List<DemoStep> = 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
)
}
@@ -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<MediaPlayer?>(null) }
var videoView by remember { mutableStateOf<VideoView?>(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
}
@@ -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()
}
@@ -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
}
}
)
}
@@ -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<Event> = 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<Event>()
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("Todays 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}")
}
}
}
@@ -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()
}
}
}
@@ -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")
}
}
@@ -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,
"Couldnt 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 <T> awaitTaskValue(task: com.google.android.gms.tasks.Task<T>): 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<Void>): 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
}
}
}
}
@@ -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}"
}
}
}
@@ -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<String>, 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<String>()
val errors = mutableListOf<String>()
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()
}
}
}
@@ -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<Slot>()
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<Slot> = 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
}
}
@@ -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
}
}
}
@@ -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
)
}
}
@@ -1,2 +0,0 @@
# STT/LLM wrappers, inference runners
@@ -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
}
}
@@ -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()
}
@@ -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()
}
@@ -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<LlmInference?>(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
}
}
@@ -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<File> {
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.")
}
}
@@ -1,2 +0,0 @@
# Jetpack Compose overlays and feedback UIs
@@ -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")
}
@@ -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("Couldnt", 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()
}
@@ -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
)
}
@@ -1,2 +0,0 @@
# Content classification and ML components
@@ -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<AccessibilityWindowInfo>?,
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 }
}
}
@@ -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<AccessibilityWindowInfo>?,
rootFallback: AccessibilityNodeInfo?,
selection: RectF
): ExtractionResult {
val selectionRect = Rect().also { selection.round(it) }
val snippets = linkedSetOf<String>()
var nodesHit = 0
var packageName: String? = null
val roots = mutableListOf<AccessibilityNodeInfo>()
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<String>,
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()
}
}
@@ -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)
}
}
}
@@ -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
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="0.5"
android:scaleY="0.5"
android:translateX="27"
android:translateY="27">
<!-- Circle shape representing the "circle to select" gesture -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,10 A44,44 0 1,1 54,98 A44,44 0 1,1 54,10 Z M54,20 A34,34 0 1,0 54,88 A34,34 0 1,0 54,20 Z"/>
<!-- AI sparkle/star in center -->
<path
android:fillColor="#03DAC6"
android:pathData="M54,40 L58,50 L68,54 L58,58 L54,68 L50,58 L40,54 L50,50 Z"/>
</group>
</vector>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+8 -7
View File
@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#6200EE</color>
<color name="primary_variant">#3700B3</color>
<color name="secondary">#03DAC6</color>
<color name="secondary_variant">#018786</color>
<!-- Brand: ink + teal (matches CrklUi / Circle mark) -->
<color name="ic_launcher_background">#F3F5F2</color>
<color name="primary">#157F7A</color>
<color name="primary_variant">#0E1A24</color>
<color name="secondary">#1FA7A0</color>
<color name="secondary_variant">#157F7A</color>
<color name="on_primary">#FFFFFF</color>
<color name="on_secondary">#000000</color>
<color name="overlay_highlight">#8003DAC6</color>
<color name="on_secondary">#0E1A24</color>
<color name="overlay_highlight">#801FA7A0</color>
</resources>
+13 -8
View File
@@ -1,14 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Crkl</string>
<string name="accessibility_service_name">Crkl Overlay Service</string>
<string name="accessibility_service_description">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.</string>
<string name="enable_service">Enable Crkl Accessibility Service</string>
<string name="app_name">Circle</string>
<string name="accessibility_service_name">Circle Overlay</string>
<string name="accessibility_service_description">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.</string>
<string name="enable_service">Enable Circle Overlay</string>
<string name="service_enabled">Service Enabled</string>
<string name="service_disabled">Service Disabled</string>
<string name="open_settings">Open Accessibility Settings</string>
<string name="welcome_message">Welcome to Crkl</string>
<string name="privacy_notice">Privacy-first, on-device AI assistant. No data leaves your device.</string>
<string name="setup_instructions">To get started, enable the Crkl Accessibility Service in Settings.</string>
<string name="welcome_message">Welcome to Circle</string>
<string name="privacy_notice">Privacy-first, on-device assist. Circle text → act.</string>
<string name="setup_instructions">Enable Circle Overlay in Accessibility Settings to show the floating C.</string>
<string name="test_fixtures_label">Circle Test Fixtures</string>
<string name="open_test_fixtures">Open test fixtures</string>
<string name="qs_tile_label">Circle</string>
<string name="qs_tile_subtitle_ready">Draw on screen</string>
<string name="qs_tile_subtitle_off">Enable Accessibility</string>
<string name="add_qs_tile">Add Quick Settings tile</string>
</resources>
@@ -2,9 +2,11 @@
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/accessibility_service_description"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows"
android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows|flagReportViewIds|flagIncludeNotImportantViews|flagRequestAccessibilityButton"
android:accessibilityFeedbackType="feedbackGeneric"
android:canRetrieveWindowContent="true"
android:canTakeScreenshot="true"
android:canRequestTouchExplorationMode="false"
android:notificationTimeout="100" />
@@ -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())
}
}
@@ -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<String> = 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<String> = 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, "Todays 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>(Intent.EXTRA_INTENT)
requireNotNull(share)
assertEquals(Intent.ACTION_SEND, share.action)
assertTrue(share.getStringExtra(Intent.EXTRA_TEXT)!!.contains("Thursday"))
}
}
@@ -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))
}
}
@@ -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))
}
}
@@ -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:"))
}
}
@@ -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"))
}
}
@@ -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())
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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))
}
}
@@ -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<String, Any?>()
override fun getAll(): MutableMap<String, *> = map.toMutableMap()
override fun getString(key: String?, defValue: String?): String? =
map[key] as? String ?: defValue
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? {
@Suppress("UNCHECKED_CAST")
return (map[key] as? Set<String>)?.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<String, Any?>()
private val removals = mutableSetOf<String>()
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<String>?): 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
}
}
}
@@ -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"))
}
}
+33
View File
@@ -0,0 +1,33 @@
# DEMO — 4560 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.
+15
View File
@@ -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).
+36
View File
@@ -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`
+12
View File
@@ -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`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 872 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Some files were not shown because too many files have changed in this diff Show More