Feat 1.16.9: Circle quest first-run #10

Merged
ilia merged 1 commits from feat/1.16.9-quest into main 2026-08-05 14:13:14 -05:00
15 changed files with 490 additions and 113 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 1.16.9-quest — 2026-08-05
- Wire playful Circle quest into first-run (MainActivity card + prefs)
- Wins: closed loop → long-press icon → Read aloud / Translate
- Tests: `CircleQuestTest` + `make smoke-quest`
## 1.16.8-hint-audio — 2026-08-05
- Slimmer draw hint; hides while finger is down so it isnt over the text
- Emulator starts **with audio** (Read aloud works on Mac speakers)
- Slightly smaller action chips so more fit on one row
## 1.16.7-read-ideas — 2026-08-05
- Long-press any action icon → name toast
+7 -2
View File
@@ -1,7 +1,7 @@
# 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
fixtures home back recents test-env integrations gog-bridge smoke smoke-quest
UNAME_S := $(shell uname -s)
GRADLEW := ./gradlew
@@ -54,6 +54,7 @@ help: ## Show available commands
@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 smoke-quest - on-device: first circle advances quest 0→1"
@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"
@@ -129,7 +130,7 @@ emulator: ## Start CrklEmulator (open gRPC so rim ◀○□ chrome works)
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 \
nohup $(EMULATOR) -avd "$(AVD_NAME)" -memory 3072 -cores 4 \
-gpu $(EMULATOR_GPU) -accel on -no-snapshot-load -no-metrics \
-grpc 8554 \
>/tmp/crkl-emulator.log 2>&1 & echo $$! > /tmp/crkl-emulator.pid; \
@@ -158,6 +159,10 @@ smoke: ## On-device smoke (FAB → circle → extract → Copy). Needs adb devic
@chmod +x scripts/smoke-circle.sh
@ADB="$(ADB)" bash scripts/smoke-circle.sh
smoke-quest: ## On-device: reset quest prefs, circle once, assert step=1
@chmod +x scripts/smoke-quest.sh
@ADB="$(ADB)" bash scripts/smoke-quest.sh
build: ## Build debug APK
@echo "Building Crkl..."
@$(GRADLEW) assembleDebug --console=plain
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.example.crkl"
minSdk = 27
targetSdk = 34
versionCode = 24
versionName = "1.16.7-read-ideas"
versionCode = 26
versionName = "1.16.9-quest"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -2,8 +2,10 @@ package com.example.crkl
import android.Manifest
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
@@ -33,6 +35,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -56,6 +59,7 @@ 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.onboarding.CircleQuest
import com.example.crkl.ui.theme.CrklTheme
import java.util.concurrent.Executor
@@ -85,6 +89,7 @@ class MainActivity : ComponentActivity() {
val settings = remember { IntegrationSettings(this) }
var a11yOn by remember { mutableStateOf(isCrklAccessibilityEnabled()) }
var onboardingDone by remember { mutableStateOf(settings.onboardingComplete) }
var questStep by remember { mutableIntStateOf(settings.questStep) }
var overlayReady by remember { mutableStateOf(CrklOverlayBridge.isReady()) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
@@ -92,19 +97,39 @@ class MainActivity : ComponentActivity() {
if (event == Lifecycle.Event.ON_RESUME) {
a11yOn = isCrklAccessibilityEnabled()
onboardingDone = settings.onboardingComplete
questStep = settings.questStep
captureReady = MediaProjectionHolder.isReady()
overlayReady = CrklOverlayBridge.isReady()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
val questReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
onboardingDone = settings.onboardingComplete
questStep = intent?.getIntExtra(
CircleQuest.EXTRA_STEP,
settings.questStep
) ?: settings.questStep
}
}
ContextCompat.registerReceiver(
this@MainActivity,
questReceiver,
IntentFilter(CircleQuest.ACTION_PROGRESS),
ContextCompat.RECEIVER_NOT_EXPORTED
)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
unregisterReceiver(questReceiver)
}
}
MainScreen(
captureReady = captureReady,
integrationHint = settings.statusSummary(),
accessibilityEnabled = a11yOn,
showOnboarding = !onboardingDone,
showQuest = !onboardingDone && questStep < CircleQuest.DONE,
questStep = questStep,
onRefreshA11y = {
a11yOn = isCrklAccessibilityEnabled()
overlayReady = CrklOverlayBridge.isReady()
@@ -120,8 +145,6 @@ class MainActivity : ComponentActivity() {
startActivity(Intent(this, IntegrationsActivity::class.java))
},
onOpenTestFixtures = {
settings.onboardingComplete = true
onboardingDone = true
startActivity(Intent(this, TestFixturesActivity::class.java))
},
onEnableCapture = {
@@ -130,9 +153,10 @@ class MainActivity : ComponentActivity() {
captureReady = MediaProjectionHolder.isReady()
}, 1500)
},
onSkipOnboarding = {
settings.onboardingComplete = true
onSkipQuest = {
CircleQuest.skip(settings)
onboardingDone = true
questStep = CircleQuest.DONE
},
onAddQsTile = { requestQsTile() },
canAddQsTile = Build.VERSION.SDK_INT >= 33,
@@ -208,13 +232,14 @@ fun MainScreen(
captureReady: Boolean,
integrationHint: String,
accessibilityEnabled: Boolean,
showOnboarding: Boolean,
showQuest: Boolean,
questStep: Int,
onRefreshA11y: () -> Unit,
onOpenAccessibilitySettings: () -> Unit,
onOpenIntegrations: () -> Unit,
onOpenTestFixtures: () -> Unit,
onEnableCapture: () -> Unit,
onSkipOnboarding: () -> Unit,
onSkipQuest: () -> Unit,
onAddQsTile: () -> Unit = {},
canAddQsTile: Boolean = false,
overlayReady: Boolean = false
@@ -278,7 +303,7 @@ fun MainScreen(
}
}
if (showOnboarding) {
if (showQuest) {
Spacer(modifier = Modifier.height(20.dp))
Card(
modifier = Modifier.fillMaxWidth(),
@@ -286,46 +311,52 @@ fun MainScreen(
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)
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
"Three steps — then circle anything on screen.",
style = MaterialTheme.typography.bodySmall
CircleQuest.title(questStep),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
OnboardStep(
number = 1,
title = "Enable Accessibility",
done = accessibilityEnabled,
actionLabel = if (accessibilityEnabled) "Enabled" else "Open settings",
onAction = {
onOpenAccessibilitySettings()
onRefreshA11y()
},
enabled = !accessibilityEnabled
Text(
CircleQuest.dots(questStep),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
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"
Text(
CircleQuest.prompt(questStep),
style = MaterialTheme.typography.bodyMedium
)
if (!accessibilityEnabled && questStep == CircleQuest.STEP_CIRCLE) {
Text(
"First: enable Accessibility so the floating C can appear.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Button(
onClick = {
onOpenAccessibilitySettings()
onRefreshA11y()
},
modifier = Modifier.fillMaxWidth()
) {
Text("Enable Accessibility")
}
} else if (questStep == CircleQuest.STEP_CIRCLE) {
OutlinedButton(
onClick = onOpenTestFixtures,
modifier = Modifier.fillMaxWidth()
) {
Text("Open test fixtures")
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onSkipOnboarding) {
TextButton(onClick = onSkipQuest) {
Text("Skip for now")
}
}
@@ -397,7 +428,7 @@ fun MainScreen(
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Panel chips: Translate · Copy · Explain · Share · Vikunja",
text = "Panel chips: Translate · Copy · Explain · Read · Share · Todo · ?",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -405,32 +436,3 @@ fun MainScreen(
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,6 +1,7 @@
package com.example.crkl.accessibility
import android.accessibilityservice.AccessibilityService
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.PixelFormat
import android.graphics.RectF
@@ -11,6 +12,7 @@ import android.util.Log
import android.view.Gravity
import android.view.WindowManager
import android.view.accessibility.AccessibilityEvent
import android.widget.Toast
import androidx.core.content.ContextCompat
import com.example.crkl.agent.ActionHints
import com.example.crkl.agent.ActionExecutor
@@ -21,6 +23,7 @@ import com.example.crkl.model.DeviceSpeechStt
import com.example.crkl.model.DeviceTts
import com.example.crkl.model.MediaPipeLocalLlm
import com.example.crkl.integrations.IntegrationSettings
import com.example.crkl.onboarding.CircleQuest
import com.example.crkl.ui.ResultPanelView
import com.example.crkl.vision.ContentCapture
import com.example.crkl.vision.ScreenOcr
@@ -32,7 +35,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* System overlay + selection pipeline.
*
@@ -233,6 +235,7 @@ class CrklAccessibilityService : AccessibilityService() {
private fun handleSelection(bounds: RectF) {
serviceScope.launch {
overlayView?.exitOverlayMode()
noteQuestWin(CircleQuest.Win.CIRCLE)
showResultPanel(
AssistEngine.AssistResponse(
title = "Reading…",
@@ -377,11 +380,18 @@ class CrklAccessibilityService : AccessibilityService() {
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")) },
onTranslate = {
runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.TRANSLATE, raw = "translate"))
noteQuestWin(CircleQuest.Win.ACTION)
},
onCopy = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.COPY, raw = "copy")) },
onExplain = { runVoiceAction(VoiceIntent.Parsed(VoiceIntent.Kind.EXPLAIN, raw = "explain")) },
onReadAloud = { readAloudFromPanel() },
onIdeas = { showIdeasFromPanel() }
onReadAloud = {
readAloudFromPanel()
noteQuestWin(CircleQuest.Win.ACTION)
},
onIdeas = { showIdeasFromPanel() },
onChipLongPress = { noteQuestWin(CircleQuest.Win.HOLD_ICON) }
)
panel.showResult(response.title, response.meta, response.body)
resultPanel = panel
@@ -404,6 +414,24 @@ class CrklAccessibilityService : AccessibilityService() {
}
}
private fun noteQuestWin(win: CircleQuest.Win) {
val settings = IntegrationSettings(this)
val adv = CircleQuest.applyTo(settings, win)
if (!adv.changed) return
Log.i(tagName, "quest: step=${adv.to} win=$win")
sendBroadcast(
Intent(CircleQuest.ACTION_PROGRESS).setPackage(packageName).apply {
putExtra(CircleQuest.EXTRA_STEP, adv.to)
putExtra(CircleQuest.EXTRA_CHEER, adv.cheer)
}
)
adv.cheer?.let { msg ->
mainHandler.post {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
}
}
}
private fun readAloudFromPanel() {
val body = resultPanel?.currentBody().orEmpty()
val speakable = body
@@ -342,25 +342,38 @@ class OverlayView(
private fun drawOverlayMode(canvas: Canvas) {
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), dimPaint)
// Hint only when idle — big center pill blocks the text youre trying to circle.
val failing = System.currentTimeMillis() < failFlashUntil
val pillW = dp(280f)
val pillH = dp(38f)
val pillTop = statusBarInset() + dp(12f)
val pill = RectF(
width / 2f - pillW / 2f,
pillTop,
width / 2f + pillW / 2f,
pillTop + 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
)
val showHint = failing || (!isDrawing && touchPath.isEmpty())
if (showHint) {
val label = if (failing) hintMessage else "Circle text · Cancel →"
val pillW = instructionPaint.measureText(label) + dp(28f)
val pillH = dp(32f)
val pillTop = statusBarInset() + dp(8f)
val pill = RectF(
width / 2f - pillW / 2f,
pillTop,
width / 2f + pillW / 2f,
pillTop + pillH
)
// Keep clear of Cancel on the right
if (pill.right > width - dp(110f)) {
pill.offset(width - dp(110f) - pill.right, 0f)
}
if (pill.left < dp(12f)) {
pill.offset(dp(12f) - pill.left, 0f)
}
canvas.drawRoundRect(pill, dp(16f), dp(16f), hintPillPaint)
canvas.drawRoundRect(pill, dp(16f), dp(16f), hintRingPaint)
instructionPaint.color = if (failing) Color.parseColor("#FFCFC4") else Color.WHITE
instructionPaint.textSize = sp(13f)
canvas.drawText(
label,
pill.centerX(),
pill.centerY() + instructionPaint.textSize / 3f,
instructionPaint
)
}
updateExitRect()
canvas.drawRoundRect(exitRect, dp(18f), dp(18f), exitBgPaint)
@@ -68,6 +68,14 @@ class IntegrationSettings(
get() = prefs.getBoolean(KEY_ONBOARDING_DONE, false)
set(value) = prefs.edit().putBoolean(KEY_ONBOARDING_DONE, value).apply()
/**
* Circle quest progress: 0 = circle, 1 = hold icon, 2 = action, 3 = done.
* Kept in sync with [onboardingComplete] by [com.example.crkl.onboarding.CircleQuest].
*/
var questStep: Int
get() = prefs.getInt(KEY_QUEST_STEP, 0).coerceIn(0, 3)
set(value) = prefs.edit().putInt(KEY_QUEST_STEP, value.coerceIn(0, 3)).apply()
/** Show capture source / OCR counts on the result panel (for debugging). */
var showDebugMeta: Boolean
get() = prefs.getBoolean(KEY_SHOW_DEBUG_META, false)
@@ -123,6 +131,7 @@ class IntegrationSettings(
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_QUEST_STEP = "quest_step"
private const val KEY_SHOW_DEBUG_META = "show_debug_meta"
const val DEFAULT_VIKUNJA_URL = "https://todo.levkin.ca"
@@ -0,0 +1,102 @@
package com.example.crkl.onboarding
import com.example.crkl.integrations.IntegrationSettings
/**
* Playful 3-step first-run quest. No XP — just progress + cheeky wins.
* See docs/onboarding-quest.md.
*/
object CircleQuest {
const val ACTION_PROGRESS = "com.example.crkl.QUEST_PROGRESS"
const val EXTRA_STEP = "quest_step"
const val EXTRA_CHEER = "quest_cheer"
const val STEP_CIRCLE = 0
const val STEP_HOLD = 1
const val STEP_ACTION = 2
const val DONE = 3
enum class Win {
/** Closed loop → result panel. */
CIRCLE,
/** Long-press an action icon. */
HOLD_ICON,
/** Translate or Read aloud. */
ACTION
}
data class Advance(
val from: Int,
val to: Int,
val cheer: String?
) {
val changed: Boolean get() = from != to
}
fun applyWin(current: Int, win: Win): Advance {
val step = current.coerceIn(STEP_CIRCLE, DONE)
val to = when {
step >= DONE -> DONE
win == Win.CIRCLE && step == STEP_CIRCLE -> STEP_HOLD
win == Win.HOLD_ICON && step == STEP_HOLD -> STEP_ACTION
win == Win.ACTION && step == STEP_ACTION -> DONE
else -> step
}
return Advance(
from = step,
to = to,
cheer = if (to != step) cheerFor(to) else null
)
}
/** Mutates settings when the win matches the current step. */
fun applyTo(settings: IntegrationSettings, win: Win): Advance {
if (settings.onboardingComplete) {
return Advance(DONE, DONE, null)
}
val adv = applyWin(settings.questStep, win)
if (adv.changed) {
settings.questStep = adv.to
if (adv.to >= DONE) {
settings.onboardingComplete = true
}
}
return adv
}
fun skip(settings: IntegrationSettings) {
settings.questStep = DONE
settings.onboardingComplete = true
}
fun isActive(settings: IntegrationSettings): Boolean =
!settings.onboardingComplete && settings.questStep < DONE
fun title(step: Int): String = when (step.coerceIn(STEP_CIRCLE, DONE)) {
STEP_CIRCLE -> "Circle quest · 1/3"
STEP_HOLD -> "Circle quest · 2/3"
STEP_ACTION -> "Circle quest · 3/3"
else -> "Quest complete"
}
fun prompt(step: Int): String = when (step.coerceIn(STEP_CIRCLE, DONE)) {
STEP_CIRCLE -> "Tap the floating C, then draw a closed loop around text."
STEP_HOLD -> "Nice loop. Press and hold any action icon to learn its name."
STEP_ACTION -> "You're circling. Try Read aloud or Translate."
else -> "You're set — circle anything, anytime."
}
/** Filled dots for completed wins (03). */
fun dots(step: Int): String {
val filled = step.coerceIn(0, 3)
return (0 until 3).joinToString(" ") { i -> if (i < filled) "" else "" }
}
private fun cheerFor(newStep: Int): String = when (newStep) {
STEP_HOLD -> "Nice loop."
STEP_ACTION -> "You're circling."
DONE -> "Quest complete — go circle the world."
else -> "Nice."
}
}
@@ -30,7 +30,8 @@ class ResultPanelView(
private val onCopy: (() -> Unit)? = null,
private val onExplain: (() -> Unit)? = null,
private val onReadAloud: (() -> Unit)? = null,
private val onIdeas: (() -> Unit)? = null
private val onIdeas: (() -> Unit)? = null,
private val onChipLongPress: (() -> Unit)? = null
) : LinearLayout(context) {
private val titleView: TextView
@@ -142,18 +143,19 @@ class ResultPanelView(
bg: Int,
onClick: () -> Unit
): ImageButton {
val size = dp(44)
// 40dp + 6 margin → ~7 chips fit on a typical phone without scrolling.
val size = dp(40)
return ImageButton(context).apply {
setImageResource(iconRes)
imageTintList = android.content.res.ColorStateList.valueOf(CrklUi.ChipFg)
scaleType = ImageView.ScaleType.CENTER_INSIDE
setPadding(dp(10), dp(10), dp(10), dp(10))
setPadding(dp(8), dp(8), dp(8), dp(8))
background = GradientDrawable().apply {
setColor(bg)
cornerRadius = dp(22).toFloat()
cornerRadius = dp(20).toFloat()
}
layoutParams = LayoutParams(size, size).apply {
marginEnd = dp(8)
marginEnd = dp(6)
}
isClickable = true
isFocusable = true
@@ -162,6 +164,7 @@ class ResultPanelView(
setOnLongClickListener {
android.widget.Toast.makeText(context, label, android.widget.Toast.LENGTH_SHORT)
.show()
onChipLongPress?.invoke()
true
}
setOnClickListener {
@@ -32,4 +32,14 @@ class IntegrationSettingsTest {
s.onboardingComplete = true
assertTrue(s.onboardingComplete)
}
@Test
fun questStep_defaultsZeroAndClamps() {
val s = IntegrationSettings(MemorySharedPreferences())
assertEquals(0, s.questStep)
s.questStep = 2
assertEquals(2, s.questStep)
s.questStep = 99
assertEquals(3, s.questStep)
}
}
@@ -0,0 +1,82 @@
package com.example.crkl.onboarding
import com.example.crkl.integrations.IntegrationSettings
import com.example.crkl.testutil.MemorySharedPreferences
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CircleQuestTest {
@Test
fun applyWin_advancesInOrderOnly() {
assertEquals(CircleQuest.STEP_HOLD, CircleQuest.applyWin(0, CircleQuest.Win.CIRCLE).to)
assertEquals(0, CircleQuest.applyWin(0, CircleQuest.Win.HOLD_ICON).to)
assertEquals(0, CircleQuest.applyWin(0, CircleQuest.Win.ACTION).to)
assertEquals(CircleQuest.STEP_ACTION, CircleQuest.applyWin(1, CircleQuest.Win.HOLD_ICON).to)
assertEquals(1, CircleQuest.applyWin(1, CircleQuest.Win.CIRCLE).to)
assertEquals(CircleQuest.DONE, CircleQuest.applyWin(2, CircleQuest.Win.ACTION).to)
assertEquals(2, CircleQuest.applyWin(2, CircleQuest.Win.HOLD_ICON).to)
}
@Test
fun applyWin_cheerOnAdvance() {
val a = CircleQuest.applyWin(0, CircleQuest.Win.CIRCLE)
assertTrue(a.changed)
assertEquals("Nice loop.", a.cheer)
val noop = CircleQuest.applyWin(0, CircleQuest.Win.ACTION)
assertFalse(noop.changed)
assertNull(noop.cheer)
}
@Test
fun applyTo_persistsAndCompletes() {
val s = IntegrationSettings(MemorySharedPreferences())
assertTrue(CircleQuest.isActive(s))
CircleQuest.applyTo(s, CircleQuest.Win.CIRCLE)
assertEquals(1, s.questStep)
assertFalse(s.onboardingComplete)
CircleQuest.applyTo(s, CircleQuest.Win.HOLD_ICON)
assertEquals(2, s.questStep)
val done = CircleQuest.applyTo(s, CircleQuest.Win.ACTION)
assertEquals(CircleQuest.DONE, done.to)
assertEquals(3, s.questStep)
assertTrue(s.onboardingComplete)
assertFalse(CircleQuest.isActive(s))
}
@Test
fun applyTo_ignoresWhenComplete() {
val s = IntegrationSettings(MemorySharedPreferences())
CircleQuest.skip(s)
val adv = CircleQuest.applyTo(s, CircleQuest.Win.CIRCLE)
assertFalse(adv.changed)
assertEquals(CircleQuest.DONE, s.questStep)
}
@Test
fun skip_marksDone() {
val s = IntegrationSettings(MemorySharedPreferences())
CircleQuest.skip(s)
assertTrue(s.onboardingComplete)
assertEquals(CircleQuest.DONE, s.questStep)
}
@Test
fun copy_hasProgressAndPrompts() {
assertTrue(CircleQuest.title(0).contains("1/3"))
assertTrue(CircleQuest.prompt(1).contains("hold", ignoreCase = true))
assertTrue(CircleQuest.prompt(2).contains("Read aloud") || CircleQuest.prompt(2).contains("Translate"))
assertEquals("○ ○ ○", CircleQuest.dots(0))
assertEquals("● ○ ○", CircleQuest.dots(1))
assertEquals("● ● ●", CircleQuest.dots(3))
}
}
+14 -9
View File
@@ -8,20 +8,25 @@ Goal: first-run feels like a tiny game, not a settings dump.
|------|--------|-----|
| 1 | “Circle anything with the floating **C**” | First closed loop |
| 2 | “Hold an icon — learn its name” | Long-press toast |
| 3 | “Try **Read aloud** or **Translate**” | One action |
| 4 | Optional: “Open fixtures → email → Todo” | Vikunja configured |
| 3 | “Try **Read aloud** or **Translate**” | One of those actions |
Optional later: fixtures → email → Todo (Vikunja) — not part of the gated quest.
## Tone
- Short, cheeky copy (“Nice loop.” / “Youre circling.”)
- Progress dots (1/3 · 2/3 · 3/3), not a points economy
- Skip always available; never block Overlay enable
- Progress dots (○ ○ ○ → ● ● ●), not a points economy
- **Skip** always available; never block Overlay enable
## Implementation
- State: `IntegrationSettings.questStep` + `onboardingComplete`
- Logic: `onboarding/CircleQuest.kt`
- Wins from `CrklAccessibilityService` (circle / long-press / translate·read)
- UI: MainActivity quest card; live updates via `CircleQuest.ACTION_PROGRESS`
- Tests: `CircleQuestTest` (JVM) · `make smoke-quest` (device step 0→1)
## Avoid
- Streaks / leaderboards / XP (wrong product)
- Streaks / leaderboards / XP
- Multi-screen wizard before Accessibility is on
## Ship next
Wire quest state into `IntegrationSettings` + MainActivity card that advances on first successful circle (service → broadcast / prefs).
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# On-device: first closed loop advances Circle quest 0 → 1.
# Hold/action steps are covered by JVM CircleQuestTest.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ADB="${ADB:-adb}"
COMP='com.example.crkl/com.example.crkl.accessibility.CrklAccessibilityService'
PKG=com.example.crkl
die() { echo "FAIL: $*" >&2; exit 1; }
pass() { echo "OK: $*"; }
"$ADB" devices | awk '/device( |$)/ && $1 !~ /List/{found=1} END{exit !found}' \
|| die "no adb device"
echo "== smoke quest on $($ADB get-serialno) =="
# Fresh quest prefs (debug builds only — run-as).
"$ADB" shell settings put secure accessibility_enabled 1
"$ADB" shell settings put secure enabled_accessibility_services "$COMP"
"$ADB" shell am force-stop "$PKG" >/dev/null 2>&1 || true
sleep 0.5
"$ADB" shell run-as "$PKG" rm -f shared_prefs/crkl_integrations.xml \
|| die "run-as failed — need debug APK"
"$ADB" shell settings put secure enabled_accessibility_services "$COMP"
"$ADB" shell settings put secure accessibility_enabled 1
sleep 1.5
"$ADB" shell am start -n "$PKG/.fixtures.TestFixturesActivity" >/dev/null
sleep 1.5
enter_circle() {
"$ADB" logcat -c
"$ADB" shell input tap 964 2200
sleep 0.4
if "$ADB" logcat -d -s OverlayView:D | grep -q 'floating button clicked'; then
return 0
fi
for y in $(seq 2140 20 2280); do
for x in $(seq 900 20 1030); do
"$ADB" shell input tap "$x" "$y"
sleep 0.05
if "$ADB" logcat -d -s OverlayView:D | grep -q 'floating button clicked'; then
return 0
fi
done
done
return 1
}
draw_loop() {
local cx="$1" cy="$2" r="$3"
python3 - "$cx" "$cy" "$r" <<'PY'
import math, subprocess, sys, time
cx, cy, r = map(int, sys.argv[1:4])
adb = lambda *a: subprocess.check_call(["adb", *a])
n = 28
pts = [
(
int(cx + r * math.cos(2 * math.pi * i / n - math.pi / 2)),
int(cy + r * math.sin(2 * math.pi * i / n - math.pi / 2)),
)
for i in range(n + 1)
]
adb("shell", "input", "motionevent", "DOWN", str(pts[0][0]), str(pts[0][1]))
for x, y in pts[1:]:
adb("shell", "input", "motionevent", "MOVE", str(x), str(y))
adb("shell", "input", "motionevent", "UP", str(pts[-1][0]), str(pts[-1][1]))
for _ in range(25):
time.sleep(0.3)
out = subprocess.check_output(["adb", "logcat", "-d"], text=True)
if "extract:" in out or "Selection ready" in out or "quest: step=" in out:
break
PY
}
"$ADB" shell uiautomator dump /sdcard/crkl-smoke.xml >/dev/null
"$ADB" pull /sdcard/crkl-smoke.xml /tmp/crkl-smoke-quest.xml >/dev/null
eval "$(python3 <<'PY'
import re, sys
xml = open("/tmp/crkl-smoke-quest.xml").read()
m = re.search(r'text="How to circle"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml)
if not m:
sys.exit("How to circle not on screen")
x1, y1, x2, y2 = map(int, m.groups())
cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
r = max(60, min(100, (x2 - x1) // 2 + 30, (y2 - y1) // 2 + 40))
print(f"HEAD_CX={cx}; HEAD_CY={cy}; HEAD_R={r}")
PY
)"
enter_circle || die "FAB not clickable — is Circle Overlay ON?"
draw_loop "$HEAD_CX" "$HEAD_CY" "$HEAD_R"
sleep 1
logs="$("$ADB" logcat -d)"
echo "$logs" | grep -q 'quest: step=1' || die "quest did not advance to step 1 (check logcat)"
pass "log: quest step=1 after first circle"
prefs="$("$ADB" shell run-as "$PKG" cat shared_prefs/crkl_integrations.xml 2>/dev/null || true)"
echo "$prefs" | grep -q 'name="quest_step" value="1"' \
|| die "prefs quest_step != 1 (got: $(echo "$prefs" | tr '\n' ' ' | head -c 200))"
pass "prefs: quest_step=1"
# Skip path via clearing + writing would need app code; JVM covers skip().
echo "== smoke quest PASSED =="
-1
View File
@@ -27,7 +27,6 @@ cat >"$PLIST" <<EOF
<string>-avd</string><string>${AVD_NAME}</string>
<string>-memory</string><string>3072</string>
<string>-cores</string><string>4</string>
<string>-no-audio</string>
<string>-gpu</string><string>${GPU}</string>
<string>-accel</string><string>on</string>
<string>-no-snapshot-load</string>
+1 -1
View File
@@ -45,7 +45,7 @@ ensure_emulator() {
chmod +x "$ROOT/scripts/start-emulator-launchd.sh"
bash "$ROOT/scripts/start-emulator-launchd.sh"
else
nohup "$EMU" -avd "$AVD_NAME" -memory 3072 -cores 4 -no-audio \
nohup "$EMU" -avd "$AVD_NAME" -memory 3072 -cores 4 \
-gpu "$GPU" -accel on -no-snapshot-load -no-metrics \
-grpc 8554 \
>"$LOG" 2>&1 &