diff --git a/apps/android/ASSISTANT-APP-PLAN.md b/apps/android/ASSISTANT-APP-PLAN.md new file mode 100644 index 000000000..a26231337 --- /dev/null +++ b/apps/android/ASSISTANT-APP-PLAN.md @@ -0,0 +1,411 @@ +# Android Assistant App Support for Clawdbot + +## Overview + +Implement Android's VoiceInteractionService to make Clawdbot appear in "Settings > Apps > Default Apps > Digital assistant app" like ChatGPT and Perplexity. When users long-press the home button, Clawdbot will launch with screen context captured via the Assist API. + +## Architecture Decisions (User-Confirmed) + +1. **UI**: Launch MainActivity with chat overlay expanded (reuse existing UI) +2. **Screen Context**: Always capture via Assist API and include in query +3. **Service**: Extend NodeForegroundService to also be a VoiceInteractionService +4. **Offline**: Show error dialog prompting user to connect to gateway + +## Implementation Flow + +``` +User long-presses Home + ↓ +Android invokes VoiceInteractionSession.onHandleAssist() + ↓ +Capture screen context (AssistStructure + AssistContent) → JSON + ↓ +Launch MainActivity with Intent (action=ACTION_ASSISTANT, context JSON) + ↓ +Check gateway connection + ├─ Connected: Open chat sheet with context message + └─ Offline: Show error dialog with "Open Settings" button +``` + +## Implementation Steps + +### 1. AndroidManifest Changes + +**File**: `/home/yishai/code_projects/clawdbot/apps/android/app/src/main/AndroidManifest.xml` + +Add permission: +```xml + +``` + +Update NodeForegroundService declaration: +```xml + + + + + + +``` + +Add VoiceInteractionSessionService: +```xml + +``` + +Update MainActivity for assistant intent: +```xml + + + + + + + +``` + +### 2. Create XML Resource + +**New File**: `/home/yishai/code_projects/clawdbot/apps/android/app/src/main/res/xml/voice_interaction_service.xml` + +```xml + + +``` + +### 3. Update NodeForegroundService + +**File**: `/home/yishai/code_projects/clawdbot/apps/android/app/src/main/java/com/clawdbot/android/NodeForegroundService.kt` + +Change line 23 from: +```kotlin +class NodeForegroundService : Service() { +``` + +To: +```kotlin +class NodeForegroundService : VoiceInteractionService() { +``` + +Add import: +```kotlin +import android.service.voice.VoiceInteractionService +``` + +Add lifecycle callbacks (after line 66, in the class body): +```kotlin +override fun onReady() { + super.onReady() + android.util.Log.i("ClawdbotVoiceInteraction", "VoiceInteractionService ready") +} + +override fun onShutdown() { + android.util.Log.i("ClawdbotVoiceInteraction", "VoiceInteractionService shutdown") + super.onShutdown() +} +``` + +### 4. Create VoiceInteractionSessionService + +**New File**: `/home/yishai/code_projects/clawdbot/apps/android/app/src/main/java/com/clawdbot/android/assistant/ClawdbotVoiceInteractionSessionService.kt` + +```kotlin +package com.clawdbot.android.assistant + +import android.os.Bundle +import android.service.voice.VoiceInteractionSession +import android.service.voice.VoiceInteractionSessionService + +class ClawdbotVoiceInteractionSessionService : VoiceInteractionSessionService() { + override fun onNewSession(args: Bundle?): VoiceInteractionSession { + android.util.Log.i("ClawdbotAssistant", "Creating new VoiceInteractionSession") + return ClawdbotVoiceInteractionSession(this) + } +} +``` + +### 5. Create VoiceInteractionSession + +**New File**: `/home/yishai/code_projects/clawdbot/apps/android/app/src/main/java/com/clawdbot/android/assistant/ClawdbotVoiceInteractionSession.kt` + +Core logic: +- Capture screen context from AssistStructure (view hierarchy text) and AssistContent (URLs, structured data) +- Serialize to JSON (timestamp, packageName, webUri, visibleText array) +- Launch MainActivity with ACTION_ASSISTANT intent + context JSON extra +- Extract up to 100 text nodes to avoid overwhelming the chat +- Finish session immediately after launching activity + +Key features: +- `onHandleAssist()`: Main entry point, captures context and launches MainActivity +- `captureScreenContext()`: Builds JSON from AssistStructure/AssistContent +- `extractTextFromViewNode()`: Recursively walks view hierarchy for text +- Error handling with try-catch and fallback + +### 6. MainActivity Integration + +**File**: `/home/yishai/code_projects/clawdbot/apps/android/app/src/main/java/com/clawdbot/android/MainActivity.kt` + +Add in `onCreate()` (after line 37): +```kotlin +handleAssistantIntent(intent) +``` + +Add `onNewIntent()` method (for singleTop launch mode): +```kotlin +override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleAssistantIntent(intent) +} +``` + +Add helper methods: +```kotlin +private fun handleAssistantIntent(intent: Intent?) { + if (intent?.action == "com.clawdbot.android.ACTION_ASSISTANT") { + val screenContext = intent.getStringExtra("screen_context") + lifecycleScope.launch { + delay(100) // Wait for runtime initialization + if (!viewModel.isConnected.value) { + showAssistantOfflineDialog() + } else { + viewModel.openChatWithContext(screenContext) + } + } + } +} + +private fun showAssistantOfflineDialog() { + androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle("Gateway Not Connected") + .setMessage("Clawdbot Assistant requires a connection to your gateway. Please connect first.") + .setPositiveButton("Open Settings") { _, _ -> + viewModel.requestShowSettings() + } + .setNegativeButton("Cancel", null) + .show() +} +``` + +Add imports: +```kotlin +import android.content.Intent +import androidx.appcompat.app.AlertDialog +import kotlinx.coroutines.delay +``` + +### 7. MainViewModel Updates + +**File**: `/home/yishai/code_projects/clawdbot/apps/android/app/src/main/java/com/clawdbot/android/MainViewModel.kt` + +Add state flows: +```kotlin +private val _assistantContext = MutableStateFlow(null) +val assistantContext: StateFlow = _assistantContext.asStateFlow() + +private val _showSettingsRequest = MutableStateFlow(false) +val showSettingsRequest: StateFlow = _showSettingsRequest.asStateFlow() +``` + +Add methods: +```kotlin +fun openChatWithContext(screenContext: String?) { + _assistantContext.value = screenContext +} + +fun requestShowSettings() { + _showSettingsRequest.value = true +} + +fun clearShowSettingsRequest() { + _showSettingsRequest.value = false +} + +fun clearAssistantContext() { + _assistantContext.value = null +} +``` + +### 8. RootScreen UI Integration + +**File**: `/home/yishai/code_projects/clawdbot/apps/android/app/src/main/java/com/clawdbot/android/ui/RootScreen.kt` + +Add state collection: +```kotlin +val assistantContext by viewModel.assistantContext.collectAsState() +val showSettingsRequest by viewModel.showSettingsRequest.collectAsState() +``` + +Add LaunchedEffect to auto-open chat: +```kotlin +LaunchedEffect(assistantContext) { + if (assistantContext != null) { + sheet = Sheet.Chat + } +} + +LaunchedEffect(showSettingsRequest) { + if (showSettingsRequest) { + sheet = Sheet.Settings + viewModel.clearShowSettingsRequest() + } +} +``` + +### 9. ChatSheet Context Formatting + +**File**: `/home/yishai/code_projects/clawdbot/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSheetContent.kt` + +Add state collection and auto-send: +```kotlin +val assistantContext by viewModel.assistantContext.collectAsState() + +LaunchedEffect(assistantContext) { + assistantContext?.let { contextJson -> + val formattedMessage = formatAssistantMessage(contextJson) + viewModel.sendChat( + message = formattedMessage, + thinking = thinkingLevel, + attachments = emptyList() + ) + viewModel.clearAssistantContext() + } +} +``` + +Add formatter helper: +```kotlin +private fun formatAssistantMessage(contextJson: String): String { + return try { + val json = Json.parseToJsonElement(contextJson).jsonObject + buildString { + appendLine("I'm looking at:") + json["packageName"]?.jsonPrimitive?.contentOrNull?.let { + appendLine("App: $it") + } + json["webUri"]?.jsonPrimitive?.contentOrNull?.let { + appendLine("URL: $it") + } + json["visibleText"]?.jsonArray?.take(20)?.forEach { + it.jsonPrimitive?.contentOrNull?.let { text -> + appendLine("- $text") + } + } + appendLine("\nScreen context JSON:") + appendLine("```json") + appendLine(contextJson) + appendLine("```") + } + } catch (e: Exception) { + "Screen context:\n```json\n$contextJson\n```" + } +} +``` + +## Testing Plan + +### Setup +1. Install debug build: `./gradlew installDebug` +2. Open Settings > Apps > Default Apps > Digital assistant app +3. Select "Clawdbot" + +### Test Cases + +**Test 1: Basic Invocation (Connected)** +- Precondition: Connected to gateway +- Open any app (Chrome, Gmail) +- Long-press Home button +- Expected: MainActivity opens, chat sheet shows, message auto-sent with screen context + +**Test 2: Offline Invocation** +- Precondition: NOT connected to gateway +- Long-press Home button +- Expected: Alert dialog "Gateway Not Connected" with "Open Settings" button + +**Test 3: Screen Context - Web Browser** +- Open Chrome with webpage +- Invoke assistant +- Expected: Context includes webUri, packageName, visible text from page + +**Test 4: Multiple Invocations** +- Invoke from one app, let response complete +- Switch to different app +- Invoke again +- Expected: New context from second app + +**Test 5: Service Persistence** +- Reboot device +- Check Settings > Apps > Default Apps +- Expected: Clawdbot still appears as option + +### Debugging +```bash +# View logs +adb logcat | grep -E "Clawdbot|VoiceInteraction|Assistant" + +# Check service status +adb shell dumpsys activity services | grep -A 20 clawdbot + +# Verify manifest +aapt dump xmltree app/build/outputs/apk/debug/app-debug.apk AndroidManifest.xml | grep -A 10 voice +``` + +## Critical Files Summary + +### To Modify (5 files) +1. `AndroidManifest.xml` - Add permissions, service declarations, metadata +2. `NodeForegroundService.kt` - Extend VoiceInteractionService, add lifecycle methods +3. `MainActivity.kt` - Handle assistant intents, show offline dialog +4. `MainViewModel.kt` - Add assistant context state flows and methods +5. `RootScreen.kt` - Auto-open chat sheet when context provided + +### To Create (3 files) +1. `res/xml/voice_interaction_service.xml` - Voice interaction metadata +2. `assistant/ClawdbotVoiceInteractionSessionService.kt` - Session service (lightweight) +3. `assistant/ClawdbotVoiceInteractionSession.kt` - Core session logic (screen context capture) + +### Optional Enhancement +- `ChatSheetContent.kt` - Format context message for better UX (can use basic formatting initially) + +## Key Technical Notes + +- **VoiceInteractionService** is a Service subclass, so extending it from NodeForegroundService is fully compatible +- **BIND_VOICE_INTERACTION** permission is automatically granted when app is set as default assistant +- **AssistStructure** can be large; extract data immediately and discard object +- **Screen context** limited to 100 text nodes to prevent overwhelming chat +- **Intent flags** use SINGLE_TOP to reuse existing MainActivity instance +- **Error handling**: Wrap all Assist API calls in try-catch for robustness + +## Security & Privacy + +- Screen context may contain sensitive data (passwords, PII) +- Data only sent to user's own gateway (existing trust model) +- No local storage of screen context (memory only) +- User must explicitly invoke assistant (intentional action) +- Offline mode prevents transmission if not connected + +## Success Criteria + +✅ Clawdbot appears in "Digital assistant app" settings +✅ Long-press home launches Clawdbot with chat +✅ Screen context captured and formatted in message +✅ Offline handling shows error dialog +✅ Existing app functionality unaffected +✅ Service lifecycle stable across reboots diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 85dc9c566..3cd1622e1 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -115,6 +115,9 @@ dependencies { // Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains. implementation("dnsjava:dnsjava:3.6.4") + // BouncyCastle for Ed25519 (software provider, no hardware dependency) + implementation("org.bouncycastle:bcprov-jdk18on:1.79") + testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7") diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 1ab2541b6..327a502c0 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ + @@ -38,8 +39,11 @@ android:name=".NodeForegroundService" android:exported="false" android:foregroundServiceType="dataSync|microphone|mediaProjection" /> + @@ -51,6 +55,7 @@ - - - - - - - + + + + + + + + + diff --git a/apps/android/app/src/main/java/com/clawdbot/android/assistant/ClawdbotRecognitionService.kt b/apps/android/app/src/main/java/com/clawdbot/android/assistant/ClawdbotRecognitionService.kt new file mode 100644 index 000000000..4ac49eb03 --- /dev/null +++ b/apps/android/app/src/main/java/com/clawdbot/android/assistant/ClawdbotRecognitionService.kt @@ -0,0 +1,44 @@ +package com.clawdbot.android.assistant + +import android.content.Intent +import android.os.Bundle +import android.speech.RecognitionService +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import android.util.Log + +/** + * RecognitionService implementation required for VoiceInteractionService. + * + * Per Android documentation: "Given that all VIAs are also voice recognizer services, + * you must also include a RecognitionService in your manifest." + * + * This is a minimal implementation that satisfies the system requirement. + * Actual speech recognition is handled by VoiceInteractionSession. + */ +class ClawdbotRecognitionService : RecognitionService() { + + companion object { + private const val TAG = "ClawdbotRecognition" + } + + override fun onStartListening(recognizerIntent: Intent?, listener: Callback?) { + Log.i(TAG, "onStartListening called") + + // This is a minimal implementation - actual recognition happens in VoiceInteractionSession + // We just need this service to exist for the VoiceInteractionService to be valid + + // Notify that we're not implementing speech recognition here + listener?.error(SpeechRecognizer.ERROR_SERVER) + } + + override fun onCancel(listener: Callback?) { + Log.i(TAG, "onCancel called") + listener?.endOfSpeech() + } + + override fun onStopListening(listener: Callback?) { + Log.i(TAG, "onStopListening called") + listener?.endOfSpeech() + } +} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/assistant/ClawdbotVoiceInteractionService.kt b/apps/android/app/src/main/java/com/clawdbot/android/assistant/ClawdbotVoiceInteractionService.kt new file mode 100644 index 000000000..05c2bf55a --- /dev/null +++ b/apps/android/app/src/main/java/com/clawdbot/android/assistant/ClawdbotVoiceInteractionService.kt @@ -0,0 +1,35 @@ +package com.clawdbot.android.assistant + +import android.os.Bundle +import android.service.voice.VoiceInteractionService +import android.util.Log + +/** + * Standalone VoiceInteractionService that makes Clawdbot appear in + * "Settings > Apps > Default Apps > Digital assistant app". + * + * This service must be separate from NodeForegroundService because + * VoiceInteractionService cannot have foregroundServiceType attributes. + */ +class ClawdbotVoiceInteractionService : VoiceInteractionService() { + + companion object { + private const val TAG = "ClawdbotVoiceInteraction" + } + + override fun onCreate() { + super.onCreate() + Log.i(TAG, "VoiceInteractionService created") + } + + override fun onReady() { + super.onReady() + Log.i(TAG, "VoiceInteractionService ready") + // The service should now be ready to handle showSession() calls from the system + } + + override fun onShutdown() { + Log.i(TAG, "VoiceInteractionService shutdown") + super.onShutdown() + } +} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/assistant/ClawdbotVoiceInteractionSession.kt b/apps/android/app/src/main/java/com/clawdbot/android/assistant/ClawdbotVoiceInteractionSession.kt new file mode 100644 index 000000000..6785750e0 --- /dev/null +++ b/apps/android/app/src/main/java/com/clawdbot/android/assistant/ClawdbotVoiceInteractionSession.kt @@ -0,0 +1,149 @@ +package com.clawdbot.android.assistant + +import android.app.assist.AssistContent +import android.app.assist.AssistStructure +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.os.Bundle +import android.service.voice.VoiceInteractionSession +import android.util.Log +import com.clawdbot.android.MainActivity +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +class ClawdbotVoiceInteractionSession(context: Context) : VoiceInteractionSession(context) { + + companion object { + private const val TAG = "ClawdbotAssistSession" + const val ACTION_ASSISTANT = "com.clawdbot.android.ACTION_ASSISTANT" + const val EXTRA_SCREEN_CONTEXT = "screen_context" + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun onHandleAssist( + data: Bundle?, + structure: AssistStructure?, + content: AssistContent? + ) { + try { + Log.i(TAG, "onHandleAssist called") + + // Capture screen context + val screenContext = captureScreenContext(structure, content) + + // Launch MainActivity with context + val intent = Intent(context, MainActivity::class.java).apply { + action = ACTION_ASSISTANT + putExtra(EXTRA_SCREEN_CONTEXT, screenContext) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + + context.startActivity(intent) + } catch (e: Exception) { + Log.e(TAG, "Error handling assist", e) + // Show error toast + android.widget.Toast.makeText( + context, + "Clawdbot Assistant error: ${e.message}", + android.widget.Toast.LENGTH_LONG + ).show() + } finally { + // Close the session (we're delegating to MainActivity) + finish() + } + } + + override fun onHandleScreenshot(screenshot: Bitmap?) { + Log.i(TAG, "Screenshot captured: ${screenshot?.width}x${screenshot?.height}") + // Optional: Could save screenshot and include in context + // For now, we rely on AssistStructure text extraction + } + + private fun captureScreenContext( + structure: AssistStructure?, + content: AssistContent? + ): String { + val contextData = buildJsonObject { + // Basic metadata + put("timestamp", System.currentTimeMillis()) + + // Content data (URLs, structured data) + content?.let { assistContent -> + assistContent.webUri?.let { uri -> + put("webUri", uri.toString()) + } + assistContent.clipData?.let { clip -> + if (clip.itemCount > 0) { + put("clipText", clip.getItemAt(0).text?.toString() ?: "") + } + } + // JSON-LD structured data (if available) + assistContent.structuredData?.let { jsonLd -> + put("structuredData", jsonLd) + } + } + + // Structure data (view hierarchy, text content) + structure?.let { assistStruct -> + put("packageName", assistStruct.activityComponent.packageName) + put("className", assistStruct.activityComponent.className) + + // Extract visible text from view hierarchy + val textNodes = mutableListOf() + extractTextFromStructure(assistStruct, textNodes) + + put("visibleText", buildJsonArray { + textNodes.forEach { text -> add(JsonPrimitive(text)) } + }) + } + } + + return contextData.toString() + } + + private fun extractTextFromStructure( + structure: AssistStructure, + textNodes: MutableList, + maxNodes: Int = 100 + ) { + if (textNodes.size >= maxNodes) return + + val windowCount = structure.windowNodeCount + for (i in 0 until windowCount) { + val windowNode = structure.getWindowNodeAt(i) + val rootViewNode = windowNode.rootViewNode + extractTextFromViewNode(rootViewNode, textNodes, maxNodes) + } + } + + private fun extractTextFromViewNode( + viewNode: AssistStructure.ViewNode, + textNodes: MutableList, + maxNodes: Int + ) { + if (textNodes.size >= maxNodes) return + + // Extract text from this node + viewNode.text?.toString()?.trim()?.takeIf { it.isNotEmpty() }?.let { + textNodes.add(it) + } + + // Extract content description + viewNode.contentDescription?.toString()?.trim()?.takeIf { it.isNotEmpty() }?.let { + if (!textNodes.contains(it)) { + textNodes.add(it) + } + } + + // Recursively process children + val childCount = viewNode.childCount + for (i in 0 until childCount) { + viewNode.getChildAt(i)?.let { child -> + extractTextFromViewNode(child, textNodes, maxNodes) + } + } + } +} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/assistant/ClawdbotVoiceInteractionSessionService.kt b/apps/android/app/src/main/java/com/clawdbot/android/assistant/ClawdbotVoiceInteractionSessionService.kt new file mode 100644 index 000000000..40b8f4f07 --- /dev/null +++ b/apps/android/app/src/main/java/com/clawdbot/android/assistant/ClawdbotVoiceInteractionSessionService.kt @@ -0,0 +1,12 @@ +package com.clawdbot.android.assistant + +import android.os.Bundle +import android.service.voice.VoiceInteractionSession +import android.service.voice.VoiceInteractionSessionService + +class ClawdbotVoiceInteractionSessionService : VoiceInteractionSessionService() { + override fun onNewSession(args: Bundle?): VoiceInteractionSession { + android.util.Log.i("ClawdbotAssistant", "Creating new VoiceInteractionSession") + return ClawdbotVoiceInteractionSession(this) + } +} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceIdentityStore.kt b/apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceIdentityStore.kt index 72500b750..a09a085d9 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceIdentityStore.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceIdentityStore.kt @@ -2,10 +2,12 @@ package com.clawdbot.android.gateway import android.content.Context import android.util.Base64 +import org.bouncycastle.jce.provider.BouncyCastleProvider import java.io.File import java.security.KeyFactory import java.security.KeyPairGenerator import java.security.MessageDigest +import java.security.Security import java.security.Signature import java.security.spec.PKCS8EncodedKeySpec import kotlinx.serialization.Serializable @@ -23,6 +25,21 @@ class DeviceIdentityStore(context: Context) { private val json = Json { ignoreUnknownKeys = true } private val identityFile = File(context.filesDir, "clawdbot/identity/device.json") + companion object { + // Use our own provider instance to avoid conflicts with Android's built-in BC + private val bcProvider = BouncyCastleProvider() + private val ED25519_SPKI_PREFIX = byteArrayOf( + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + ) + + init { + // Remove any conflicting BC provider and add our full BouncyCastle + Security.removeProvider("BC") + Security.insertProviderAt(bcProvider, 1) + android.util.Log.d("DeviceIdentityStore", "Registered BouncyCastle provider: ${bcProvider.info}") + } + } + @Synchronized fun loadOrCreate(): DeviceIdentity { val existing = load() @@ -44,13 +61,14 @@ class DeviceIdentityStore(context: Context) { return try { val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT) val keySpec = PKCS8EncodedKeySpec(privateKeyBytes) - val keyFactory = KeyFactory.getInstance("Ed25519") + val keyFactory = KeyFactory.getInstance("Ed25519", bcProvider) val privateKey = keyFactory.generatePrivate(keySpec) - val signature = Signature.getInstance("Ed25519") + val signature = Signature.getInstance("Ed25519", bcProvider) signature.initSign(privateKey) signature.update(payload.toByteArray(Charsets.UTF_8)) base64UrlEncode(signature.sign()) - } catch (_: Throwable) { + } catch (e: Throwable) { + android.util.Log.e("DeviceIdentityStore", "signPayload failed", e) null } } @@ -93,11 +111,18 @@ class DeviceIdentityStore(context: Context) { } private fun generate(): DeviceIdentity { - val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + android.util.Log.d("DeviceIdentityStore", "Generating new Ed25519 key using BouncyCastle") + + val kpg = KeyPairGenerator.getInstance("Ed25519", bcProvider) + val keyPair = kpg.generateKeyPair() + val spki = keyPair.public.encoded val rawPublic = stripSpkiPrefix(spki) val deviceId = sha256Hex(rawPublic) val privateKey = keyPair.private.encoded + + android.util.Log.d("DeviceIdentityStore", "Generated Ed25519 key, deviceId=${deviceId.take(8)}..., pubKeyLen=${rawPublic.size}") + return DeviceIdentity( deviceId = deviceId, publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP), @@ -136,11 +161,4 @@ class DeviceIdentityStore(context: Context) { private fun base64UrlEncode(data: ByteArray): String { return Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) } - - companion object { - private val ED25519_SPKI_PREFIX = - byteArrayOf( - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ) - } } diff --git a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewaySession.kt index ddd249a8e..a22ffe453 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewaySession.kt @@ -253,11 +253,16 @@ class GatewaySession( private inner class Listener : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d("ClawdbotGateway", "[$loggerTag] WebSocket opened, endpoint=${endpoint.host}:${endpoint.port}") scope.launch { try { + Log.d("ClawdbotGateway", "[$loggerTag] Awaiting connect nonce...") val nonce = awaitConnectNonce() + Log.d("ClawdbotGateway", "[$loggerTag] Got nonce=$nonce, sending connect...") sendConnect(nonce) + Log.d("ClawdbotGateway", "[$loggerTag] Connect completed successfully") } catch (err: Throwable) { + Log.e("ClawdbotGateway", "[$loggerTag] Connect failed: ${err.message}", err) connectDeferred.completeExceptionally(err) closeQuietly() } @@ -373,6 +378,7 @@ class GatewaySession( ) val signature = identityStore.signPayload(payload, identity) val publicKey = identityStore.publicKeyBase64Url(identity) + Log.d("ClawdbotGateway", "[$loggerTag] Device identity: id=${identity.deviceId.take(8)}..., signature=${signature?.take(20) ?: "NULL"}, publicKey=${publicKey?.take(20) ?: "NULL"}") val deviceJson = if (!signature.isNullOrBlank() && !publicKey.isNullOrBlank()) { buildJsonObject {