v0.1 - Initial ClawdNode Android app
This commit is contained in:
commit
003d26cd4d
|
|
@ -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.
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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*
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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?) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 & 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#B45309</color>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">ClawdNode</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 "$@"
|
||||||
|
|
@ -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")
|
||||||
Loading…
Reference in New Issue