v0.1 - Initial ClawdNode Android app

This commit is contained in:
James (ClawdBot) 2026-01-28 03:49:06 +00:00
commit 003d26cd4d
28 changed files with 2401 additions and 0 deletions

173
README.md Normal file
View File

@ -0,0 +1,173 @@
# ClawdNode Android
AI-powered phone assistant that connects to Clawdbot Gateway. Enables Claude to answer calls, screen notifications, and act on your behalf.
## Features (v0.1)
### Notification Interception
- Captures all notifications from all apps
- Forwards to Gateway: app name, title, text, available actions
- Can trigger actions (Reply, Mark read, etc.) via Gateway commands
- Can dismiss notifications remotely
### Call Screening & Voice
- Intercepts incoming calls before ring
- Sends caller info to Gateway for Claude to decide
- **Answer calls programmatically**
- **Speak into calls via TTS** (Text-to-Speech)
- **Listen to caller via STT** (Speech-to-Text)
- Full voice conversation loop with Claude as the brain
### Security
- **Tailscale-only** — no public internet exposure
- Encrypted credential storage (EncryptedSharedPreferences)
- Local audit log of all actions
- All permissions clearly explained
## Protocol
### Events (Phone → Gateway)
```json
// Notification received
{"type": "notification", "id": "com.whatsapp:123:1706400000", "app": "WhatsApp", "package": "com.whatsapp", "title": "Mom", "text": "Call me when you can", "actions": ["Reply", "Mark read"]}
// Incoming call
{"type": "call_incoming", "call_id": "tel:+1234567890", "number": "+1234567890", "contact": "Mom"}
// Caller speech (transcribed)
{"type": "call_audio", "call_id": "tel:+1234567890", "transcript": "Hi, I'm calling about the appointment", "is_final": true}
// Call ended
{"type": "call_ended", "call_id": "tel:+1234567890", "duration": 45, "outcome": "completed"}
```
### Commands (Gateway → Phone)
```json
// Take screenshot
{"cmd": "screenshot"}
// Trigger notification action
{"cmd": "notification_action", "id": "com.whatsapp:123:...", "action": "Reply", "text": "I'll call you back in 30 min"}
// Dismiss notification
{"cmd": "notification_dismiss", "id": "com.whatsapp:123:..."}
// Answer incoming call with greeting
{"cmd": "call_answer", "call_id": "tel:+1234567890", "greeting": "Hello, this is Johan's phone. Who's calling?"}
// Reject call
{"cmd": "call_reject", "call_id": "tel:+1234567890"}
// Speak into active call
{"cmd": "call_speak", "call_id": "tel:+1234567890", "text": "Thank you for calling. I'll let Johan know about the appointment."}
// Hang up
{"cmd": "call_hangup", "call_id": "tel:+1234567890"}
```
## Setup
### 1. Build the APK
```bash
# Clone and build
cd clawdnode-android
./gradlew assembleDebug
# APK will be at:
# app/build/outputs/apk/debug/app-debug.apk
```
Or open in Android Studio and build.
### 2. Install on Phone
```bash
adb install app/build/outputs/apk/debug/app-debug.apk
```
Or transfer APK and install manually (enable "Unknown sources").
### 3. Configure Gateway
1. Open ClawdNode app
2. Enter Gateway URL: `http://<tailscale-ip>:18789`
3. Enter Gateway Token: (from your Clawdbot config)
4. Save Configuration
### 4. Grant Permissions
The app needs several permissions:
1. **Notification Access** — System settings, enable ClawdNode
2. **Call Screening Role** — Become the call screener
3. **Runtime Permissions**:
- Phone state
- Call log
- Answer calls
- Record audio (for STT)
- Contacts (for caller ID)
### 5. Test Connection
- Status should show "✓ Connected to Gateway"
- Send a test notification to your phone
- Check Gateway logs for the notification event
## Voice Flow Example
```
1. Call comes in from unknown number
2. ClawdNode sends: {"type": "call_incoming", "number": "+1234567890", "contact": null}
3. Claude decides to answer and screen
4. Gateway sends: {"cmd": "call_answer", "greeting": "Hi, this is Johan's assistant. Who's calling?"}
5. ClawdNode answers, plays TTS greeting
6. Caller speaks: "Hi, I'm calling from Dr. Smith's office about tomorrow's appointment"
7. ClawdNode sends: {"type": "call_audio", "transcript": "Hi, I'm calling from Dr. Smith's office..."}
8. Claude processes, decides to confirm details
9. Gateway sends: {"cmd": "call_speak", "text": "Thank you for calling. Can you confirm the time?"}
10. ... conversation continues ...
11. Gateway sends: {"cmd": "call_hangup"}
12. Claude sends summary to Johan via Signal
```
## Project Structure
```
app/src/main/java/com/inou/clawdnode/
├── ClawdNodeApp.kt # Application class, initialization
├── service/
│ ├── NodeService.kt # Foreground service, command routing
│ └── GatewayClient.kt # WebSocket connection
├── notifications/
│ └── NotificationListener.kt # Notification capture & actions
├── calls/
│ ├── CallScreener.kt # Call screening service
│ └── VoiceCallService.kt # InCallService for voice interaction
├── security/
│ ├── TokenStore.kt # Encrypted credential storage
│ └── AuditLog.kt # Local audit trail
├── protocol/
│ └── Messages.kt # Event/Command data classes
└── ui/
└── MainActivity.kt # Setup UI
```
## Requirements
- Android 10+ (API 29) — required for CallScreeningService
- Tailscale installed and connected
- Clawdbot Gateway running and accessible
## Security Notes
- Gateway connection is via Tailscale mesh network only
- Credentials are stored using Android's EncryptedSharedPreferences
- All actions are logged locally with timestamps
- No data leaves your network (except to Gateway)
## License
MIT — Use freely, contribute back.

69
app/build.gradle.kts Normal file
View File

@ -0,0 +1,69 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.inou.clawdnode"
compileSdk = 34
defaultConfig {
applicationId = "com.inou.clawdnode"
minSdk = 29 // Android 10+ for CallScreeningService
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
viewBinding = true
}
}
dependencies {
// Core Android
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-service:2.7.0")
// Security - encrypted storage
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Networking - WebSocket
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// JSON parsing
implementation("com.google.code.gson:gson:2.10.1")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}

15
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,15 @@
# ClawdNode ProGuard Rules
# Keep protocol classes for JSON serialization
-keep class com.inou.clawdnode.protocol.** { *; }
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
-keep interface okhttp3.** { *; }
# Gson
-keep class com.google.gson.** { *; }
-keepattributes Signature
-keepattributes *Annotation*

View File

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Network -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Foreground service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Phone/Calls -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<!-- Audio for voice -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- Contacts for caller ID -->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<!-- Notifications (bind to notification listener) -->
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
tools:ignore="ProtectedPermissions" />
<application
android:name=".ClawdNodeApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ClawdNode"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="34">
<!-- Main Activity -->
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.ClawdNode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Core Foreground Service -->
<service
android:name=".service.NodeService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="AI phone assistant connecting to personal gateway" />
</service>
<!-- Notification Listener Service -->
<service
android:name=".notifications.NotificationListener"
android:exported="true"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<!-- Call Screening Service -->
<service
android:name=".calls.CallScreener"
android:exported="true"
android:permission="android.permission.BIND_SCREENING_SERVICE">
<intent-filter>
<action android:name="android.telecom.CallScreeningService" />
</intent-filter>
</service>
<!-- InCall Service for voice interaction -->
<service
android:name=".calls.VoiceCallService"
android:exported="true"
android:permission="android.permission.BIND_INCALL_SERVICE">
<meta-data
android:name="android.telecom.IN_CALL_SERVICE_UI"
android:value="false" />
<meta-data
android:name="android.telecom.IN_CALL_SERVICE_RINGING"
android:value="false" />
<intent-filter>
<action android:name="android.telecom.InCallService" />
</intent-filter>
</service>
</application>
</manifest>

View File

@ -0,0 +1,80 @@
package com.inou.clawdnode
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import com.inou.clawdnode.security.AuditLog
import com.inou.clawdnode.security.TokenStore
/**
* ClawdNode Application
*
* AI-powered phone assistant that connects to Clawdbot Gateway.
* Enables Claude to answer calls, screen notifications, and act on your behalf.
*/
class ClawdNodeApp : Application() {
lateinit var tokenStore: TokenStore
private set
lateinit var auditLog: AuditLog
private set
override fun onCreate() {
super.onCreate()
instance = this
// Initialize security components
tokenStore = TokenStore(this)
auditLog = AuditLog(this)
// Create notification channels
createNotificationChannels()
auditLog.log("APP_START", "ClawdNode v${BuildConfig.VERSION_NAME} started")
}
private fun createNotificationChannels() {
val manager = getSystemService(NotificationManager::class.java)
// Foreground service channel (persistent)
val serviceChannel = NotificationChannel(
CHANNEL_SERVICE,
"Connection Status",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Shows ClawdNode connection status"
setShowBadge(false)
}
// Alerts channel (for important events)
val alertChannel = NotificationChannel(
CHANNEL_ALERTS,
"Alerts",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Important events requiring attention"
}
// Call events channel
val callChannel = NotificationChannel(
CHANNEL_CALLS,
"Call Activity",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Call screening and voice interaction events"
}
manager.createNotificationChannels(listOf(serviceChannel, alertChannel, callChannel))
}
companion object {
const val CHANNEL_SERVICE = "clawdnode_service"
const val CHANNEL_ALERTS = "clawdnode_alerts"
const val CHANNEL_CALLS = "clawdnode_calls"
lateinit var instance: ClawdNodeApp
private set
}
}

View File

@ -0,0 +1,107 @@
package com.inou.clawdnode.calls
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.telecom.Call
import android.telecom.CallScreeningService
import android.util.Log
import com.inou.clawdnode.ClawdNodeApp
import com.inou.clawdnode.protocol.CallIncomingEvent
import com.inou.clawdnode.service.NodeService
/**
* Screens incoming calls before they ring.
* Sends call info to Gateway for Claude to decide: answer, reject, or let ring.
*/
class CallScreener : CallScreeningService() {
private val tag = "CallScreener"
private var nodeService: NodeService? = null
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
nodeService = (service as NodeService.LocalBinder).getService()
}
override fun onServiceDisconnected(name: ComponentName?) {
nodeService = null
}
}
override fun onCreate() {
super.onCreate()
Log.i(tag, "CallScreener created")
Intent(this, NodeService::class.java).also { intent ->
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
}
override fun onDestroy() {
unbindService(serviceConnection)
super.onDestroy()
}
override fun onScreenCall(callDetails: Call.Details) {
val number = callDetails.handle?.schemeSpecificPart ?: "Unknown"
val callId = callDetails.handle?.toString() ?: System.currentTimeMillis().toString()
Log.i(tag, "Screening call from: $number")
// Look up contact name
val contactName = lookupContact(number)
// Store call details for later action
ActiveCalls.add(callId, callDetails)
// Send event to Gateway
val event = CallIncomingEvent(
callId = callId,
number = number,
contact = contactName
)
nodeService?.sendEvent(event)
ClawdNodeApp.instance.auditLog.logCall(
"CALL_INCOMING",
number,
contactName,
"screening"
)
// Default: let the call ring through
// Gateway can send call_reject or call_silence command
// For v0.1, we allow all calls but notify Gateway
respondToCall(callDetails, CallResponse.Builder()
.setDisallowCall(false)
.setSkipCallLog(false)
.setSkipNotification(false)
.build()
)
}
private fun lookupContact(number: String): String? {
// TODO: Look up in contacts
// For now, return null (unknown caller)
return null
}
}
/**
* Tracks active calls for command handling.
*/
object ActiveCalls {
private val calls = mutableMapOf<String, Call.Details>()
fun add(callId: String, details: Call.Details) {
calls[callId] = details
}
fun get(callId: String): Call.Details? = calls[callId]
fun remove(callId: String) {
calls.remove(callId)
}
}

View File

@ -0,0 +1,343 @@
package com.inou.clawdnode.calls
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.media.AudioManager
import android.os.Bundle
import android.os.IBinder
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.telecom.Call
import android.telecom.InCallService
import android.telecom.VideoProfile
import android.util.Log
import com.inou.clawdnode.ClawdNodeApp
import com.inou.clawdnode.protocol.CallAudioEvent
import com.inou.clawdnode.protocol.CallEndedEvent
import com.inou.clawdnode.service.CallManager
import com.inou.clawdnode.service.NodeService
import java.util.*
/**
* Handles voice interaction with active calls.
* - Answers calls programmatically
* - Speaks via TTS into the call
* - Listens and transcribes caller speech via STT
* - Routes audio events to Gateway for Claude to respond
*/
class VoiceCallService : InCallService(), TextToSpeech.OnInitListener {
private val tag = "VoiceCallService"
private var tts: TextToSpeech? = null
private var speechRecognizer: SpeechRecognizer? = null
private var audioManager: AudioManager? = null
private val activeCalls = mutableMapOf<String, Call>()
private var currentCallId: String? = null
private var isListening = false
private var nodeService: NodeService? = null
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
nodeService = (service as NodeService.LocalBinder).getService()
}
override fun onServiceDisconnected(name: ComponentName?) {
nodeService = null
}
}
override fun onCreate() {
super.onCreate()
Log.i(tag, "VoiceCallService created")
CallManager.register(this)
// Initialize TTS
tts = TextToSpeech(this, this)
// Initialize STT
if (SpeechRecognizer.isRecognitionAvailable(this)) {
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(this)
speechRecognizer?.setRecognitionListener(createRecognitionListener())
}
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
// Bind to NodeService
Intent(this, NodeService::class.java).also { intent ->
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
}
override fun onDestroy() {
Log.i(tag, "VoiceCallService destroyed")
tts?.shutdown()
speechRecognizer?.destroy()
unbindService(serviceConnection)
super.onDestroy()
}
// ========================================
// TTS INITIALIZATION
// ========================================
override fun onInit(status: Int) {
if (status == TextToSpeech.SUCCESS) {
tts?.language = Locale.US
Log.i(tag, "TTS initialized")
} else {
Log.e(tag, "TTS initialization failed")
}
}
// ========================================
// CALL LIFECYCLE
// ========================================
override fun onCallAdded(call: Call) {
Log.i(tag, "Call added: ${call.details.handle}")
val callId = call.details.handle?.toString() ?: System.currentTimeMillis().toString()
activeCalls[callId] = call
// Register callback for call state changes
call.registerCallback(object : Call.Callback() {
override fun onStateChanged(call: Call, state: Int) {
handleCallStateChange(callId, call, state)
}
})
}
override fun onCallRemoved(call: Call) {
Log.i(tag, "Call removed")
val callId = activeCalls.entries.find { it.value == call }?.key
if (callId != null) {
activeCalls.remove(callId)
ActiveCalls.remove(callId)
if (currentCallId == callId) {
stopListening()
currentCallId = null
}
}
}
private fun handleCallStateChange(callId: String, call: Call, state: Int) {
Log.d(tag, "Call $callId state: $state")
when (state) {
Call.STATE_ACTIVE -> {
// Call is active, start listening
currentCallId = callId
startListening()
}
Call.STATE_DISCONNECTED -> {
// Call ended
val duration = 0 // TODO: Calculate actual duration
val event = CallEndedEvent(
callId = callId,
durationSeconds = duration,
outcome = "completed"
)
nodeService?.sendEvent(event)
ClawdNodeApp.instance.auditLog.logCall(
"CALL_ENDED",
call.details.handle?.schemeSpecificPart,
null,
"completed"
)
}
}
}
// ========================================
// CALL CONTROL (called by NodeService via CallManager)
// ========================================
fun answerCall(callId: String, greeting: String?) {
val call = activeCalls[callId] ?: run {
Log.w(tag, "Call not found: $callId")
return
}
Log.i(tag, "Answering call: $callId")
call.answer(VideoProfile.STATE_AUDIO_ONLY)
ClawdNodeApp.instance.auditLog.logCall(
"CALL_ANSWERED",
call.details.handle?.schemeSpecificPart,
null,
"ai_answered"
)
// Speak greeting after answer
if (greeting != null) {
// Small delay to let call connect
android.os.Handler(mainLooper).postDelayed({
speakIntoCall(callId, greeting)
}, 500)
}
}
fun rejectCall(callId: String) {
val call = activeCalls[callId] ?: return
Log.i(tag, "Rejecting call: $callId")
call.reject(false, null)
ClawdNodeApp.instance.auditLog.logCall(
"CALL_REJECTED",
call.details.handle?.schemeSpecificPart,
null,
"ai_rejected"
)
}
fun silenceCall(callId: String) {
// Silence the ringer but let it continue
audioManager?.ringerMode = AudioManager.RINGER_MODE_SILENT
Log.i(tag, "Silenced call: $callId")
}
fun hangupCall(callId: String) {
val call = activeCalls[callId] ?: return
Log.i(tag, "Hanging up call: $callId")
call.disconnect()
}
fun speakIntoCall(callId: String, text: String) {
if (currentCallId != callId) {
Log.w(tag, "Not the active call: $callId")
return
}
Log.i(tag, "Speaking: $text")
// Pause listening while speaking
stopListening()
val params = Bundle()
params.putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, AudioManager.STREAM_VOICE_CALL)
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, params, "speak_$callId")
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) {
// Resume listening after speaking
startListening()
}
override fun onError(utteranceId: String?) {
startListening()
}
})
ClawdNodeApp.instance.auditLog.log(
"CALL_SPEAK",
"TTS: $text",
mapOf("call_id" to callId)
)
}
// ========================================
// SPEECH RECOGNITION
// ========================================
private fun startListening() {
if (isListening || speechRecognizer == null) return
Log.d(tag, "Starting speech recognition")
isListening = true
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.US)
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
}
speechRecognizer?.startListening(intent)
}
private fun stopListening() {
if (!isListening) return
Log.d(tag, "Stopping speech recognition")
isListening = false
speechRecognizer?.stopListening()
}
private fun createRecognitionListener() = object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
Log.d(tag, "STT ready")
}
override fun onBeginningOfSpeech() {
Log.d(tag, "STT speech started")
}
override fun onRmsChanged(rmsdB: Float) {}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {
Log.d(tag, "STT speech ended")
}
override fun onError(error: Int) {
Log.e(tag, "STT error: $error")
// Restart listening on most errors
if (isListening && currentCallId != null) {
android.os.Handler(mainLooper).postDelayed({
if (isListening) startListening()
}, 500)
}
}
override fun onResults(results: Bundle?) {
val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
val transcript = matches?.firstOrNull() ?: return
Log.i(tag, "STT result: $transcript")
currentCallId?.let { callId ->
val event = CallAudioEvent(
callId = callId,
transcript = transcript,
isFinal = true
)
nodeService?.sendEvent(event)
}
// Continue listening
if (isListening) {
startListening()
}
}
override fun onPartialResults(partialResults: Bundle?) {
val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
val transcript = matches?.firstOrNull() ?: return
Log.d(tag, "STT partial: $transcript")
currentCallId?.let { callId ->
val event = CallAudioEvent(
callId = callId,
transcript = transcript,
isFinal = false
)
nodeService?.sendEvent(event)
}
}
override fun onEvent(eventType: Int, params: Bundle?) {}
}
}

View File

@ -0,0 +1,190 @@
package com.inou.clawdnode.notifications
import android.app.Notification
import android.app.RemoteInput
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
import com.inou.clawdnode.ClawdNodeApp
import com.inou.clawdnode.protocol.NotificationEvent
import com.inou.clawdnode.service.NodeService
import com.inou.clawdnode.service.NotificationManager
/**
* Listens to all notifications and forwards them to Gateway.
* Can also trigger notification actions (reply, dismiss, etc.)
*/
class NotificationListener : NotificationListenerService() {
private val tag = "NotificationListener"
// Track notifications for action triggering
private val activeNotifications = mutableMapOf<String, StatusBarNotification>()
private var nodeService: NodeService? = null
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
nodeService = (service as NodeService.LocalBinder).getService()
}
override fun onServiceDisconnected(name: ComponentName?) {
nodeService = null
}
}
override fun onCreate() {
super.onCreate()
Log.i(tag, "NotificationListener created")
NotificationManager.register(this)
// Bind to NodeService
Intent(this, NodeService::class.java).also { intent ->
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
}
override fun onDestroy() {
Log.i(tag, "NotificationListener destroyed")
unbindService(serviceConnection)
super.onDestroy()
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
// Skip our own notifications
if (sbn.packageName == packageName) return
// Skip ongoing/persistent notifications (media players, etc.)
if (sbn.isOngoing && !isImportantOngoing(sbn)) return
Log.d(tag, "Notification posted: ${sbn.packageName}")
val notificationId = generateNotificationId(sbn)
activeNotifications[notificationId] = sbn
val event = createNotificationEvent(sbn, notificationId)
nodeService?.sendEvent(event)
ClawdNodeApp.instance.auditLog.logNotification(
"NOTIFICATION_POSTED",
sbn.packageName,
event.title
)
}
override fun onNotificationRemoved(sbn: StatusBarNotification) {
val notificationId = generateNotificationId(sbn)
activeNotifications.remove(notificationId)
Log.d(tag, "Notification removed: ${sbn.packageName}")
}
// ========================================
// ACTION TRIGGERING
// ========================================
fun triggerAction(notificationId: String, actionTitle: String, replyText: String?) {
val sbn = activeNotifications[notificationId] ?: run {
Log.w(tag, "Notification not found: $notificationId")
return
}
val notification = sbn.notification
val actions = notification.actions ?: return
val action = actions.find { it.title.toString().equals(actionTitle, ignoreCase = true) }
if (action == null) {
Log.w(tag, "Action not found: $actionTitle")
return
}
try {
if (replyText != null && action.remoteInputs?.isNotEmpty() == true) {
// This is a reply action
sendReply(action, replyText)
} else {
// Regular action
action.actionIntent.send()
}
ClawdNodeApp.instance.auditLog.log(
"NOTIFICATION_ACTION",
"Triggered action: $actionTitle",
mapOf("notification_id" to notificationId, "reply" to (replyText != null))
)
} catch (e: Exception) {
Log.e(tag, "Failed to trigger action", e)
}
}
fun dismissNotification(notificationId: String) {
val sbn = activeNotifications[notificationId] ?: return
cancelNotification(sbn.key)
ClawdNodeApp.instance.auditLog.log(
"NOTIFICATION_DISMISS",
"Dismissed notification",
mapOf("notification_id" to notificationId)
)
}
private fun sendReply(action: Notification.Action, text: String) {
val remoteInput = action.remoteInputs?.firstOrNull() ?: return
val intent = Intent()
val bundle = Bundle()
bundle.putCharSequence(remoteInput.resultKey, text)
RemoteInput.addResultsToIntent(arrayOf(remoteInput), intent, bundle)
action.actionIntent.send(this, 0, intent)
}
// ========================================
// HELPERS
// ========================================
private fun generateNotificationId(sbn: StatusBarNotification): String {
return "${sbn.packageName}:${sbn.id}:${sbn.postTime}"
}
private fun createNotificationEvent(sbn: StatusBarNotification, id: String): NotificationEvent {
val notification = sbn.notification
val extras = notification.extras
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
val text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()
?: extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString()
val actions = notification.actions?.map { it.title.toString() } ?: emptyList()
val appName = try {
val appInfo = packageManager.getApplicationInfo(sbn.packageName, 0)
packageManager.getApplicationLabel(appInfo).toString()
} catch (e: Exception) {
sbn.packageName
}
return NotificationEvent(
id = id,
app = appName,
packageName = sbn.packageName,
title = title,
text = text,
actions = actions,
timestamp = sbn.postTime
)
}
private fun isImportantOngoing(sbn: StatusBarNotification): Boolean {
// Whitelist certain ongoing notifications we care about
val importantPackages = setOf(
"com.whatsapp",
"org.telegram.messenger",
"com.google.android.apps.messaging"
)
return sbn.packageName in importantPackages
}
}

View File

@ -0,0 +1,146 @@
package com.inou.clawdnode.protocol
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
/**
* Protocol messages between ClawdNode and Gateway.
*
* Phone Gateway: Events (notifications, calls, etc.)
* Gateway Phone: Commands (answer call, screenshot, etc.)
*/
// ============================================
// EVENTS (Phone → Gateway)
// ============================================
sealed class NodeEvent {
abstract val type: String
fun toJson(): String = Gson().toJson(this)
}
data class NotificationEvent(
@SerializedName("type") override val type: String = "notification",
@SerializedName("id") val id: String,
@SerializedName("app") val app: String,
@SerializedName("package") val packageName: String,
@SerializedName("title") val title: String?,
@SerializedName("text") val text: String?,
@SerializedName("actions") val actions: List<String> = emptyList(),
@SerializedName("timestamp") val timestamp: Long = System.currentTimeMillis()
) : NodeEvent()
data class CallIncomingEvent(
@SerializedName("type") override val type: String = "call_incoming",
@SerializedName("call_id") val callId: String,
@SerializedName("number") val number: String?,
@SerializedName("contact") val contact: String?,
@SerializedName("timestamp") val timestamp: Long = System.currentTimeMillis()
) : NodeEvent()
data class CallEndedEvent(
@SerializedName("type") override val type: String = "call_ended",
@SerializedName("call_id") val callId: String,
@SerializedName("duration") val durationSeconds: Int,
@SerializedName("outcome") val outcome: String, // answered, rejected, missed, voicemail
@SerializedName("transcript") val transcript: String? = null
) : NodeEvent()
data class CallAudioEvent(
@SerializedName("type") override val type: String = "call_audio",
@SerializedName("call_id") val callId: String,
@SerializedName("transcript") val transcript: String,
@SerializedName("is_final") val isFinal: Boolean = false
) : NodeEvent()
data class ScreenshotEvent(
@SerializedName("type") override val type: String = "screenshot",
@SerializedName("width") val width: Int,
@SerializedName("height") val height: Int,
@SerializedName("base64") val base64: String
) : NodeEvent()
data class StatusEvent(
@SerializedName("type") override val type: String = "status",
@SerializedName("connected") val connected: Boolean,
@SerializedName("battery") val batteryPercent: Int,
@SerializedName("permissions") val permissions: Map<String, Boolean>
) : NodeEvent()
// ============================================
// COMMANDS (Gateway → Phone)
// ============================================
sealed class NodeCommand {
companion object {
fun fromJson(json: String): NodeCommand? {
return try {
val base = Gson().fromJson(json, BaseCommand::class.java)
when (base.cmd) {
"screenshot" -> Gson().fromJson(json, ScreenshotCommand::class.java)
"notification_action" -> Gson().fromJson(json, NotificationActionCommand::class.java)
"notification_dismiss" -> Gson().fromJson(json, NotificationDismissCommand::class.java)
"call_answer" -> Gson().fromJson(json, CallAnswerCommand::class.java)
"call_reject" -> Gson().fromJson(json, CallRejectCommand::class.java)
"call_silence" -> Gson().fromJson(json, CallSilenceCommand::class.java)
"call_speak" -> Gson().fromJson(json, CallSpeakCommand::class.java)
"call_hangup" -> Gson().fromJson(json, CallHangupCommand::class.java)
else -> null
}
} catch (e: Exception) {
null
}
}
}
}
data class BaseCommand(
@SerializedName("cmd") val cmd: String,
@SerializedName("id") val id: String? = null
)
data class ScreenshotCommand(
@SerializedName("cmd") val cmd: String = "screenshot"
) : NodeCommand()
data class NotificationActionCommand(
@SerializedName("cmd") val cmd: String = "notification_action",
@SerializedName("id") val notificationId: String,
@SerializedName("action") val action: String,
@SerializedName("text") val text: String? = null // For reply actions
) : NodeCommand()
data class NotificationDismissCommand(
@SerializedName("cmd") val cmd: String = "notification_dismiss",
@SerializedName("id") val notificationId: String
) : NodeCommand()
data class CallAnswerCommand(
@SerializedName("cmd") val cmd: String = "call_answer",
@SerializedName("call_id") val callId: String,
@SerializedName("greeting") val greeting: String? = null // TTS greeting to play
) : NodeCommand()
data class CallRejectCommand(
@SerializedName("cmd") val cmd: String = "call_reject",
@SerializedName("call_id") val callId: String,
@SerializedName("reason") val reason: String? = null
) : NodeCommand()
data class CallSilenceCommand(
@SerializedName("cmd") val cmd: String = "call_silence",
@SerializedName("call_id") val callId: String
) : NodeCommand()
data class CallSpeakCommand(
@SerializedName("cmd") val cmd: String = "call_speak",
@SerializedName("call_id") val callId: String,
@SerializedName("text") val text: String, // Text to speak via TTS
@SerializedName("voice") val voice: String? = null // TTS voice preference
) : NodeCommand()
data class CallHangupCommand(
@SerializedName("cmd") val cmd: String = "call_hangup",
@SerializedName("call_id") val callId: String
) : NodeCommand()

View File

@ -0,0 +1,108 @@
package com.inou.clawdnode.security
import android.content.Context
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue
/**
* Local audit log for all ClawdNode actions.
* Security-first: every action is logged with timestamp.
*/
class AuditLog(private val context: Context) {
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private val pendingEntries = ConcurrentLinkedQueue<AuditEntry>()
private val logFile: File
get() {
val dir = File(context.filesDir, "audit")
if (!dir.exists()) dir.mkdirs()
val today = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
return File(dir, "audit-$today.jsonl")
}
fun log(action: String, details: String, metadata: Map<String, Any>? = null) {
val entry = AuditEntry(
timestamp = dateFormat.format(Date()),
action = action,
details = details,
metadata = metadata
)
pendingEntries.add(entry)
flushToFile()
}
fun logCall(action: String, number: String?, contact: String?, outcome: String?) {
log(
action = action,
details = "Call ${action.lowercase()}",
metadata = mapOf(
"number" to (number ?: "unknown"),
"contact" to (contact ?: "unknown"),
"outcome" to (outcome ?: "pending")
).filterValues { it != "unknown" && it != "pending" }
)
}
fun logNotification(action: String, app: String, title: String?) {
log(
action = action,
details = "Notification from $app",
metadata = mapOf(
"app" to app,
"title" to (title ?: "")
).filterValues { it.isNotEmpty() }
)
}
fun logCommand(command: String, source: String, success: Boolean) {
log(
action = "COMMAND_${if (success) "OK" else "FAIL"}",
details = "Executed: $command",
metadata = mapOf(
"command" to command,
"source" to source,
"success" to success
)
)
}
private fun flushToFile() {
try {
logFile.appendText(
pendingEntries.map { gson.toJson(it) }.joinToString("\n") + "\n"
)
pendingEntries.clear()
} catch (e: Exception) {
// Don't crash on log failure, but keep entries for retry
}
}
fun getRecentEntries(limit: Int = 100): List<AuditEntry> {
return try {
logFile.readLines()
.takeLast(limit)
.mapNotNull {
try { gson.fromJson(it, AuditEntry::class.java) }
catch (e: Exception) { null }
}
} catch (e: Exception) {
emptyList()
}
}
data class AuditEntry(
val timestamp: String,
val action: String,
val details: String,
val metadata: Map<String, Any>? = null
)
}

View File

@ -0,0 +1,53 @@
package com.inou.clawdnode.security
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/**
* Secure token storage using Android EncryptedSharedPreferences.
* Hardware-backed encryption on supported devices.
*/
class TokenStore(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"clawdnode_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
var gatewayUrl: String?
get() = prefs.getString(KEY_GATEWAY_URL, null)
set(value) = prefs.edit().putString(KEY_GATEWAY_URL, value).apply()
var gatewayToken: String?
get() = prefs.getString(KEY_GATEWAY_TOKEN, null)
set(value) = prefs.edit().putString(KEY_GATEWAY_TOKEN, value).apply()
var nodeId: String?
get() = prefs.getString(KEY_NODE_ID, null)
set(value) = prefs.edit().putString(KEY_NODE_ID, value).apply()
val isConfigured: Boolean
get() = !gatewayUrl.isNullOrEmpty() && !gatewayToken.isNullOrEmpty()
fun clear() {
prefs.edit()
.remove(KEY_GATEWAY_URL)
.remove(KEY_GATEWAY_TOKEN)
.remove(KEY_NODE_ID)
.apply()
}
companion object {
private const val KEY_GATEWAY_URL = "gateway_url"
private const val KEY_GATEWAY_TOKEN = "gateway_token"
private const val KEY_NODE_ID = "node_id"
}
}

View File

@ -0,0 +1,143 @@
package com.inou.clawdnode.service
import android.util.Log
import com.inou.clawdnode.ClawdNodeApp
import com.inou.clawdnode.protocol.NodeCommand
import com.inou.clawdnode.protocol.NodeEvent
import kotlinx.coroutines.*
import okhttp3.*
import java.util.concurrent.TimeUnit
/**
* WebSocket client for Clawdbot Gateway connection.
* Handles reconnection, authentication, and message routing.
*/
class GatewayClient(
private val onCommand: (NodeCommand) -> Unit,
private val onConnectionChange: (Boolean) -> Unit
) {
private val tag = "GatewayClient"
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // No timeout for WebSocket
.writeTimeout(10, TimeUnit.SECONDS)
.pingInterval(30, TimeUnit.SECONDS)
.build()
private var webSocket: WebSocket? = null
private var isConnected = false
private var shouldReconnect = true
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val auditLog get() = ClawdNodeApp.instance.auditLog
private val tokenStore get() = ClawdNodeApp.instance.tokenStore
fun connect() {
val url = tokenStore.gatewayUrl ?: run {
Log.w(tag, "No gateway URL configured")
return
}
val token = tokenStore.gatewayToken ?: run {
Log.w(tag, "No gateway token configured")
return
}
shouldReconnect = true
// Build WebSocket URL with auth
val wsUrl = buildWsUrl(url, token)
Log.d(tag, "Connecting to $wsUrl")
val request = Request.Builder()
.url(wsUrl)
.build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.i(tag, "Connected to Gateway")
isConnected = true
onConnectionChange(true)
auditLog.log("GATEWAY_CONNECTED", "WebSocket connection established")
}
override fun onMessage(webSocket: WebSocket, text: String) {
Log.d(tag, "Received: $text")
handleMessage(text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Log.i(tag, "Connection closing: $code $reason")
webSocket.close(1000, null)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.i(tag, "Connection closed: $code $reason")
handleDisconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(tag, "Connection failed", t)
auditLog.log("GATEWAY_ERROR", "Connection failed: ${t.message}")
handleDisconnect()
}
})
}
fun disconnect() {
shouldReconnect = false
webSocket?.close(1000, "Client disconnect")
webSocket = null
isConnected = false
onConnectionChange(false)
}
fun send(event: NodeEvent) {
val json = event.toJson()
Log.d(tag, "Sending: $json")
if (isConnected) {
webSocket?.send(json)
} else {
Log.w(tag, "Not connected, queuing event")
// TODO: Queue for retry
}
}
private fun buildWsUrl(baseUrl: String, token: String): String {
// Convert http(s) to ws(s)
val wsBase = baseUrl
.replace("http://", "ws://")
.replace("https://", "wss://")
.trimEnd('/')
return "$wsBase/ws/node?token=$token"
}
private fun handleMessage(json: String) {
val command = NodeCommand.fromJson(json)
if (command != null) {
auditLog.logCommand(command::class.simpleName ?: "unknown", "gateway", true)
onCommand(command)
} else {
Log.w(tag, "Unknown command: $json")
}
}
private fun handleDisconnect() {
isConnected = false
onConnectionChange(false)
if (shouldReconnect) {
auditLog.log("GATEWAY_RECONNECT", "Scheduling reconnect in 5s")
scope.launch {
delay(5000)
if (shouldReconnect) {
connect()
}
}
}
}
fun isConnected() = isConnected
}

View File

@ -0,0 +1,226 @@
package com.inou.clawdnode.service
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.inou.clawdnode.ClawdNodeApp
import com.inou.clawdnode.R
import com.inou.clawdnode.protocol.*
import com.inou.clawdnode.ui.MainActivity
/**
* Main foreground service that maintains Gateway connection
* and coordinates all ClawdNode functionality.
*/
class NodeService : Service() {
private val tag = "NodeService"
private val binder = LocalBinder()
private lateinit var gatewayClient: GatewayClient
private var isConnected = false
// Callbacks for UI updates
var onConnectionChange: ((Boolean) -> Unit)? = null
inner class LocalBinder : Binder() {
fun getService(): NodeService = this@NodeService
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onCreate() {
super.onCreate()
Log.i(tag, "NodeService created")
gatewayClient = GatewayClient(
onCommand = { command -> handleCommand(command) },
onConnectionChange = { connected ->
isConnected = connected
updateNotification()
onConnectionChange?.invoke(connected)
}
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(tag, "NodeService starting")
// Start as foreground service
startForeground(NOTIFICATION_ID, createNotification())
// Connect to gateway
if (ClawdNodeApp.instance.tokenStore.isConfigured) {
gatewayClient.connect()
} else {
Log.w(tag, "Gateway not configured, waiting for setup")
}
return START_STICKY
}
override fun onDestroy() {
Log.i(tag, "NodeService destroyed")
gatewayClient.disconnect()
ClawdNodeApp.instance.auditLog.log("SERVICE_STOP", "NodeService destroyed")
super.onDestroy()
}
// ========================================
// PUBLIC API
// ========================================
fun connect() {
gatewayClient.connect()
}
fun disconnect() {
gatewayClient.disconnect()
}
fun isConnected() = isConnected
fun sendEvent(event: NodeEvent) {
gatewayClient.send(event)
}
// ========================================
// COMMAND HANDLING
// ========================================
private fun handleCommand(command: NodeCommand) {
Log.d(tag, "Handling command: ${command::class.simpleName}")
when (command) {
is ScreenshotCommand -> handleScreenshot()
is NotificationActionCommand -> handleNotificationAction(command)
is NotificationDismissCommand -> handleNotificationDismiss(command)
is CallAnswerCommand -> handleCallAnswer(command)
is CallRejectCommand -> handleCallReject(command)
is CallSilenceCommand -> handleCallSilence(command)
is CallSpeakCommand -> handleCallSpeak(command)
is CallHangupCommand -> handleCallHangup(command)
}
}
private fun handleScreenshot() {
// TODO: Implement screenshot capture via MediaProjection
Log.d(tag, "Screenshot requested - not yet implemented")
}
private fun handleNotificationAction(cmd: NotificationActionCommand) {
// Delegate to NotificationListener
NotificationManager.triggerAction(cmd.notificationId, cmd.action, cmd.text)
}
private fun handleNotificationDismiss(cmd: NotificationDismissCommand) {
NotificationManager.dismiss(cmd.notificationId)
}
private fun handleCallAnswer(cmd: CallAnswerCommand) {
CallManager.answer(cmd.callId, cmd.greeting)
}
private fun handleCallReject(cmd: CallRejectCommand) {
CallManager.reject(cmd.callId, cmd.reason)
}
private fun handleCallSilence(cmd: CallSilenceCommand) {
CallManager.silence(cmd.callId)
}
private fun handleCallSpeak(cmd: CallSpeakCommand) {
CallManager.speak(cmd.callId, cmd.text, cmd.voice)
}
private fun handleCallHangup(cmd: CallHangupCommand) {
CallManager.hangup(cmd.callId)
}
// ========================================
// NOTIFICATION
// ========================================
private fun createNotification(): Notification {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val status = if (isConnected) "Connected to Gateway" else "Disconnected"
return NotificationCompat.Builder(this, ClawdNodeApp.CHANNEL_SERVICE)
.setContentTitle("ClawdNode")
.setContentText(status)
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.setContentIntent(pendingIntent)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
}
private fun updateNotification() {
val notification = createNotification()
val manager = getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager
manager.notify(NOTIFICATION_ID, notification)
}
companion object {
const val NOTIFICATION_ID = 1001
}
}
/**
* Singleton managers for coordinating with system services.
* These get populated by the respective listener services.
*/
object NotificationManager {
private var listener: com.inou.clawdnode.notifications.NotificationListener? = null
fun register(listener: com.inou.clawdnode.notifications.NotificationListener) {
this.listener = listener
}
fun triggerAction(notificationId: String, action: String, text: String?) {
listener?.triggerAction(notificationId, action, text)
}
fun dismiss(notificationId: String) {
listener?.dismissNotification(notificationId)
}
}
object CallManager {
private var callService: com.inou.clawdnode.calls.VoiceCallService? = null
fun register(service: com.inou.clawdnode.calls.VoiceCallService) {
this.callService = service
}
fun answer(callId: String, greeting: String?) {
callService?.answerCall(callId, greeting)
}
fun reject(callId: String, reason: String?) {
callService?.rejectCall(callId)
}
fun silence(callId: String) {
callService?.silenceCall(callId)
}
fun speak(callId: String, text: String, voice: String?) {
callService?.speakIntoCall(callId, text)
}
fun hangup(callId: String) {
callService?.hangupCall(callId)
}
}

View File

@ -0,0 +1,265 @@
package com.inou.clawdnode.ui
import android.Manifest
import android.app.role.RoleManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.provider.Settings
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.inou.clawdnode.ClawdNodeApp
import com.inou.clawdnode.databinding.ActivityMainBinding
import com.inou.clawdnode.service.NodeService
/**
* Main setup and status activity.
* - Configure Gateway connection
* - Grant required permissions
* - Show connection status
* - View audit log
*/
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private var nodeService: NodeService? = null
private var serviceBound = false
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
nodeService = (service as NodeService.LocalBinder).getService()
serviceBound = true
updateUI()
nodeService?.onConnectionChange = { connected ->
runOnUiThread { updateConnectionStatus(connected) }
}
}
override fun onServiceDisconnected(name: ComponentName?) {
nodeService = null
serviceBound = false
}
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
updatePermissionStatus()
}
private val callScreeningRoleLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
updatePermissionStatus()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupUI()
startAndBindService()
}
override fun onResume() {
super.onResume()
updateUI()
}
override fun onDestroy() {
super.onDestroy()
if (serviceBound) {
unbindService(serviceConnection)
}
}
private fun setupUI() {
// Gateway configuration
binding.btnSaveGateway.setOnClickListener {
saveGatewayConfig()
}
// Load existing config
val tokenStore = ClawdNodeApp.instance.tokenStore
binding.etGatewayUrl.setText(tokenStore.gatewayUrl ?: "")
binding.etGatewayToken.setText(tokenStore.gatewayToken ?: "")
// Permission buttons
binding.btnGrantNotifications.setOnClickListener {
openNotificationListenerSettings()
}
binding.btnGrantCallScreening.setOnClickListener {
requestCallScreeningRole()
}
binding.btnGrantPermissions.setOnClickListener {
requestRuntimePermissions()
}
// Connection control
binding.btnConnect.setOnClickListener {
nodeService?.connect()
}
binding.btnDisconnect.setOnClickListener {
nodeService?.disconnect()
}
// Audit log
binding.btnViewAuditLog.setOnClickListener {
showAuditLog()
}
}
private fun startAndBindService() {
// Start foreground service
val intent = Intent(this, NodeService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
// Bind to service
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
private fun saveGatewayConfig() {
val url = binding.etGatewayUrl.text.toString().trim()
val token = binding.etGatewayToken.text.toString().trim()
if (url.isEmpty() || token.isEmpty()) {
Toast.makeText(this, "Please enter Gateway URL and token", Toast.LENGTH_SHORT).show()
return
}
val tokenStore = ClawdNodeApp.instance.tokenStore
tokenStore.gatewayUrl = url
tokenStore.gatewayToken = token
ClawdNodeApp.instance.auditLog.log(
"CONFIG_SAVED",
"Gateway configuration updated",
mapOf("url" to url)
)
Toast.makeText(this, "Configuration saved", Toast.LENGTH_SHORT).show()
// Reconnect with new config
nodeService?.disconnect()
nodeService?.connect()
}
private fun updateUI() {
updatePermissionStatus()
updateConnectionStatus(nodeService?.isConnected() ?: false)
}
private fun updateConnectionStatus(connected: Boolean) {
binding.tvConnectionStatus.text = if (connected) {
"✓ Connected to Gateway"
} else {
"✗ Disconnected"
}
binding.tvConnectionStatus.setTextColor(
ContextCompat.getColor(this,
if (connected) android.R.color.holo_green_dark
else android.R.color.holo_red_dark
)
)
}
private fun updatePermissionStatus() {
// Notification listener
val notificationEnabled = isNotificationListenerEnabled()
binding.tvNotificationStatus.text = if (notificationEnabled) "✓ Granted" else "✗ Not granted"
// Call screening
val callScreeningEnabled = isCallScreeningRoleHeld()
binding.tvCallScreeningStatus.text = if (callScreeningEnabled) "✓ Granted" else "✗ Not granted"
// Runtime permissions
val permissionsGranted = areRuntimePermissionsGranted()
binding.tvPermissionsStatus.text = if (permissionsGranted) "✓ All granted" else "✗ Some missing"
}
// ========================================
// PERMISSIONS
// ========================================
private fun isNotificationListenerEnabled(): Boolean {
val flat = Settings.Secure.getString(contentResolver, "enabled_notification_listeners")
return flat?.contains(packageName) == true
}
private fun openNotificationListenerSettings() {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
}
private fun isCallScreeningRoleHeld(): Boolean {
val roleManager = getSystemService(RoleManager::class.java)
return roleManager.isRoleHeld(RoleManager.ROLE_CALL_SCREENING)
}
private fun requestCallScreeningRole() {
val roleManager = getSystemService(RoleManager::class.java)
if (roleManager.isRoleAvailable(RoleManager.ROLE_CALL_SCREENING)) {
val intent = roleManager.createRequestRoleIntent(RoleManager.ROLE_CALL_SCREENING)
callScreeningRoleLauncher.launch(intent)
} else {
Toast.makeText(this, "Call screening not available", Toast.LENGTH_SHORT).show()
}
}
private fun areRuntimePermissionsGranted(): Boolean {
return REQUIRED_PERMISSIONS.all {
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
}
}
private fun requestRuntimePermissions() {
val missing = REQUIRED_PERMISSIONS.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isNotEmpty()) {
permissionLauncher.launch(missing.toTypedArray())
}
}
private fun showAuditLog() {
val entries = ClawdNodeApp.instance.auditLog.getRecentEntries(50)
val text = entries.joinToString("\n\n") { entry ->
"${entry.timestamp}\n${entry.action}: ${entry.details}"
}
AlertDialog.Builder(this)
.setTitle("Audit Log (last 50)")
.setMessage(text.ifEmpty { "No entries yet" })
.setPositiveButton("OK", null)
.show()
}
companion object {
private val REQUIRED_PERMISSIONS = arrayOf(
Manifest.permission.READ_PHONE_STATE,
Manifest.permission.READ_CALL_LOG,
Manifest.permission.ANSWER_PHONE_CALLS,
Manifest.permission.RECORD_AUDIO,
Manifest.permission.READ_CONTACTS,
Manifest.permission.POST_NOTIFICATIONS
)
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Simple phone icon -->
<path
android:fillColor="#FFFFFF"
android:pathData="M6.62,10.79c1.44,2.83 3.76,5.15 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1V20c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.25 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z" />
</vector>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFFFFF" />
<stroke
android:width="1dp"
android:color="#E5E2DE" />
<corners android:radius="6dp" />
</shape>

View File

@ -0,0 +1,265 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp"
android:background="#F8F7F6"
tools:context=".ui.MainActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Header -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ClawdNode"
android:textSize="28sp"
android:textStyle="bold"
android:textColor="#B45309"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="AI-powered phone assistant"
android:textSize="14sp"
android:textColor="#78716C"
android:layout_marginBottom="32dp" />
<!-- Connection Status -->
<TextView
android:id="@+id/tvConnectionStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="✗ Disconnected"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="#DC2626"
android:layout_marginBottom="24dp" />
<!-- Gateway Configuration -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Gateway Configuration"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="#1C1917"
android:layout_marginBottom="12dp" />
<EditText
android:id="@+id/etGatewayUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Gateway URL (e.g., http://100.x.x.x:18789)"
android:inputType="textUri"
android:padding="12dp"
android:background="@drawable/input_background"
android:layout_marginBottom="8dp" />
<EditText
android:id="@+id/etGatewayToken"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Gateway Token"
android:inputType="textPassword"
android:padding="12dp"
android:background="@drawable/input_background"
android:layout_marginBottom="12dp" />
<Button
android:id="@+id/btnSaveGateway"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Save Configuration"
android:backgroundTint="#B45309"
android:textColor="#FFFFFF"
android:layout_marginBottom="24dp" />
<!-- Connection Controls -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="32dp">
<Button
android:id="@+id/btnConnect"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Connect"
android:backgroundTint="#059669"
android:textColor="#FFFFFF"
android:layout_marginEnd="8dp" />
<Button
android:id="@+id/btnDisconnect"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Disconnect"
android:backgroundTint="#DC2626"
android:textColor="#FFFFFF" />
</LinearLayout>
<!-- Permissions Section -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Permissions"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="#1C1917"
android:layout_marginBottom="12dp" />
<!-- Notification Listener -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="8dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Notification Access"
android:textSize="14sp"
android:textColor="#1C1917" />
<TextView
android:id="@+id/tvNotificationStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="✗ Not granted"
android:textSize="12sp"
android:textColor="#78716C" />
</LinearLayout>
<Button
android:id="@+id/btnGrantNotifications"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Grant"
android:backgroundTint="#E5E2DE"
android:textColor="#1C1917" />
</LinearLayout>
<!-- Call Screening -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="8dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Call Screening"
android:textSize="14sp"
android:textColor="#1C1917" />
<TextView
android:id="@+id/tvCallScreeningStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="✗ Not granted"
android:textSize="12sp"
android:textColor="#78716C" />
</LinearLayout>
<Button
android:id="@+id/btnGrantCallScreening"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Grant"
android:backgroundTint="#E5E2DE"
android:textColor="#1C1917" />
</LinearLayout>
<!-- Runtime Permissions -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="24dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Phone &amp; Audio Permissions"
android:textSize="14sp"
android:textColor="#1C1917" />
<TextView
android:id="@+id/tvPermissionsStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="✗ Some missing"
android:textSize="12sp"
android:textColor="#78716C" />
</LinearLayout>
<Button
android:id="@+id/btnGrantPermissions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Grant"
android:backgroundTint="#E5E2DE"
android:textColor="#1C1917" />
</LinearLayout>
<!-- Audit Log -->
<Button
android:id="@+id/btnViewAuditLog"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="View Audit Log"
android:backgroundTint="#E5E2DE"
android:textColor="#1C1917"
android:layout_marginBottom="16dp" />
<!-- Version -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="v0.1.0 • Security-first design"
android:textSize="12sp"
android:textColor="#A8A29E"
android:layout_gravity="center_horizontal" />
</LinearLayout>
</ScrollView>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_notification"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_notification"/>
</adaptive-icon>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#B45309</color>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ClawdNode</string>
</resources>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.ClawdNode" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">#B45309</item>
<item name="colorPrimaryDark">#92400E</item>
<item name="colorAccent">#B45309</item>
<item name="android:statusBarColor">#F8F7F6</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Default: require HTTPS -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- Allow cleartext for local network (Tailscale) -->
<domain-config cleartextTrafficPermitted="true">
<!-- Tailscale CGNAT range -->
<domain includeSubdomains="true">100.64.0.0</domain>
<domain includeSubdomains="true">100.127.255.255</domain>
<!-- Local network -->
<domain includeSubdomains="true">192.168.0.0</domain>
<domain includeSubdomains="true">192.168.255.255</domain>
<domain includeSubdomains="true">10.0.0.0</domain>
<domain includeSubdomains="true">10.255.255.255</domain>
<!-- Localhost -->
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>

5
build.gradle.kts Normal file
View File

@ -0,0 +1,5 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}

5
gradle.properties Normal file
View File

@ -0,0 +1,5 @@
# Project-wide Gradle settings
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

7
gradlew vendored Executable file
View File

@ -0,0 +1,7 @@
#!/bin/bash
# Gradle wrapper - run gradle init if missing
if [ ! -f gradle/wrapper/gradle-wrapper.jar ]; then
echo "Gradle wrapper not initialized. Run: gradle wrapper"
exit 1
fi
exec java -jar gradle/wrapper/gradle-wrapper.jar "$@"

17
settings.gradle.kts Normal file
View File

@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ClawdNode"
include(":app")