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