commit 003d26cd4d94f866223133646c1f3e1ee4024900 Author: James (ClawdBot) Date: Wed Jan 28 03:49:06 2026 +0000 v0.1 - Initial ClawdNode Android app diff --git a/README.md b/README.md new file mode 100644 index 0000000..784a4fc --- /dev/null +++ b/README.md @@ -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://: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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..000277d --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..c661751 --- /dev/null +++ b/app/proguard-rules.pro @@ -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* diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ea169d2 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/inou/clawdnode/ClawdNodeApp.kt b/app/src/main/java/com/inou/clawdnode/ClawdNodeApp.kt new file mode 100644 index 0000000..3c0e85b --- /dev/null +++ b/app/src/main/java/com/inou/clawdnode/ClawdNodeApp.kt @@ -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 + } +} diff --git a/app/src/main/java/com/inou/clawdnode/calls/CallScreener.kt b/app/src/main/java/com/inou/clawdnode/calls/CallScreener.kt new file mode 100644 index 0000000..f29174f --- /dev/null +++ b/app/src/main/java/com/inou/clawdnode/calls/CallScreener.kt @@ -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() + + 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) + } +} diff --git a/app/src/main/java/com/inou/clawdnode/calls/VoiceCallService.kt b/app/src/main/java/com/inou/clawdnode/calls/VoiceCallService.kt new file mode 100644 index 0000000..b23427b --- /dev/null +++ b/app/src/main/java/com/inou/clawdnode/calls/VoiceCallService.kt @@ -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() + 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?) {} + } +} diff --git a/app/src/main/java/com/inou/clawdnode/notifications/NotificationListener.kt b/app/src/main/java/com/inou/clawdnode/notifications/NotificationListener.kt new file mode 100644 index 0000000..d618028 --- /dev/null +++ b/app/src/main/java/com/inou/clawdnode/notifications/NotificationListener.kt @@ -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() + + 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 + } +} diff --git a/app/src/main/java/com/inou/clawdnode/protocol/Messages.kt b/app/src/main/java/com/inou/clawdnode/protocol/Messages.kt new file mode 100644 index 0000000..f3ab888 --- /dev/null +++ b/app/src/main/java/com/inou/clawdnode/protocol/Messages.kt @@ -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 = 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 +) : 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() diff --git a/app/src/main/java/com/inou/clawdnode/security/AuditLog.kt b/app/src/main/java/com/inou/clawdnode/security/AuditLog.kt new file mode 100644 index 0000000..b0d374e --- /dev/null +++ b/app/src/main/java/com/inou/clawdnode/security/AuditLog.kt @@ -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() + 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? = 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 { + 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? = null + ) +} diff --git a/app/src/main/java/com/inou/clawdnode/security/TokenStore.kt b/app/src/main/java/com/inou/clawdnode/security/TokenStore.kt new file mode 100644 index 0000000..e65c710 --- /dev/null +++ b/app/src/main/java/com/inou/clawdnode/security/TokenStore.kt @@ -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" + } +} diff --git a/app/src/main/java/com/inou/clawdnode/service/GatewayClient.kt b/app/src/main/java/com/inou/clawdnode/service/GatewayClient.kt new file mode 100644 index 0000000..9332194 --- /dev/null +++ b/app/src/main/java/com/inou/clawdnode/service/GatewayClient.kt @@ -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 +} diff --git a/app/src/main/java/com/inou/clawdnode/service/NodeService.kt b/app/src/main/java/com/inou/clawdnode/service/NodeService.kt new file mode 100644 index 0000000..1b6bc3c --- /dev/null +++ b/app/src/main/java/com/inou/clawdnode/service/NodeService.kt @@ -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) + } +} diff --git a/app/src/main/java/com/inou/clawdnode/ui/MainActivity.kt b/app/src/main/java/com/inou/clawdnode/ui/MainActivity.kt new file mode 100644 index 0000000..d3e2a8d --- /dev/null +++ b/app/src/main/java/com/inou/clawdnode/ui/MainActivity.kt @@ -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 + ) + } +} diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..14f28d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/input_background.xml b/app/src/main/res/drawable/input_background.xml new file mode 100644 index 0000000..8e3825d --- /dev/null +++ b/app/src/main/res/drawable/input_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..98223ce --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,265 @@ + + + + + + + + + + + + + + + + + + + + +