diff --git a/README.md b/README.md index 784a4fc..f76b1ed 100644 --- a/README.md +++ b/README.md @@ -1,173 +1,112 @@ -# ClawdNode Android +# MoltMobile 📱 -AI-powered phone assistant that connects to Clawdbot Gateway. Enables Claude to answer calls, screen notifications, and act on your behalf. +**Molt in your pocket** — The mobile extension for Molt AI assistant. -## Features (v0.1) +MoltMobile gives Molt physical presence through your Android phone: +- 👀 See (camera, screenshots) +- 👂 Hear (microphone, call audio) +- 🗣️ Speak (TTS via MiniMax) +- 📍 Know where you are (location) +- 🔔 See your notifications +- 📞 Handle your calls +- 🚨 Get your attention when it matters -### 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 +## Features -### 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 +### 📞 Smart Call Handling +- Answer/reject calls via gateway command +- Spam detection and auto-response (TTS) +- Known caller greeting +- Call audio streaming (coming soon) -### Security -- **Tailscale-only** — no public internet exposure -- Encrypted credential storage (EncryptedSharedPreferences) -- Local audit log of all actions -- All permissions clearly explained +### 🔔 Notification Relay +- All notifications forwarded to Molt +- Action execution (reply, dismiss, etc.) +- Smart filtering -## Protocol +### 🔊 Voice (MiniMax Integration) +- TTS playback on device +- Audio recording for STT (coming soon) +- Real-time voice conversations (coming soon) -### Events (Phone → Gateway) +### 🚨 SUPER ATTENTION MODE +When Molt needs you NOW: +- Volume MAX (bypasses DND) +- Screen strobe (red/white) +- Camera flash strobe +- Continuous vibration +- Alarm sound loop +- Full-screen alert +- Only stops when you acknowledge -```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"]} +### 🌐 Remote Browser +Molt can control Chrome on your phone: +- Open URLs +- Execute JavaScript +- Take screenshots +- Navigate pages -// Incoming call -{"type": "call_incoming", "call_id": "tel:+1234567890", "number": "+1234567890", "contact": "Mom"} +## Architecture -// 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"} +Your Phone (MoltMobile app) + ↓ WebSocket +MoltMobile Gateway (server) + ↓ +MiniMax API (LLM + TTS) + ↓ +Molt/Clawdbot (escalation) ``` ## Setup -### 1. Build the APK +1. Install the app on your Android phone (Android 10+) +2. Grant permissions (notifications, calls, microphone, camera) +3. Ensure phone is on Tailscale network +4. App auto-connects to gateway at `ws://100.123.216.65:9878` + +## Commands (from Gateway) + +| Command | Description | +|---------|-------------| +| `audio.play` | Play TTS audio (hex or base64) | +| `audio.stop` | Stop playback | +| `audio.volume.max` | Set volume to maximum | +| `attention.super` | 🚨 SUPER ATTENTION MODE | +| `attention.stop` | Stop attention mode | +| `browser.open` | Open URL in remote browser | +| `browser.navigate` | Navigate to URL | +| `browser.js` | Execute JavaScript | +| `browser.screenshot` | Capture screenshot | +| `browser.close` | Close browser | +| `call.answer` | Answer incoming call | +| `call.reject` | Reject incoming call | +| `call.hangup` | Hang up active call | +| `notification.action` | Trigger notification action | + +## Permissions Required + +- **Internet** — Gateway connection +- **Notifications** — Relay to Molt +- **Phone/Calls** — Call handling +- **Microphone** — Voice recording +- **Camera** — Flash for attention mode +- **Vibrate** — Attention mode + +## Building ```bash -# Clone and build -cd clawdnode-android +cd moltmobile-android ./gradlew assembleDebug - -# APK will be at: -# app/build/outputs/apk/debug/app-debug.apk ``` -Or open in Android Studio and build. +APK will be at `app/build/outputs/apk/debug/app-debug.apk` -### 2. Install on Phone +## Version History -```bash -adb install app/build/outputs/apk/debug/app-debug.apk -``` +- **0.2.0** — Renamed from ClawdNode, added MiniMax audio, SUPER ATTENTION MODE, remote browser +- **0.1.0** — Initial release (as ClawdNode) -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. +*MoltMobile — Molt in your pocket.* diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dcf66b6..2fb30dc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,15 +4,15 @@ plugins { } android { - namespace = "com.inou.clawdnode" + namespace = "com.inou.moltmobile" compileSdk = 34 defaultConfig { - applicationId = "com.inou.clawdnode" + applicationId = "com.inou.moltmobile" minSdk = 29 // Android 10+ for CallScreeningService targetSdk = 34 versionCode = 1 - versionName = "0.1.0" + versionName = "0.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ea169d2..0532ffd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,15 +29,24 @@ + + + + + + + + + @@ -45,12 +54,27 @@ + android:theme="@style/Theme.MoltMobile"> + + + + + + + android:value="AI mobile assistant - MoltMobile" /> diff --git a/app/src/main/java/com/inou/clawdnode/gateway/DirectGateway.kt b/app/src/main/java/com/inou/clawdnode/gateway/DirectGateway.kt deleted file mode 100644 index c2a2eb9..0000000 --- a/app/src/main/java/com/inou/clawdnode/gateway/DirectGateway.kt +++ /dev/null @@ -1,266 +0,0 @@ -package com.inou.clawdnode.gateway - -import android.util.Log -import com.inou.clawdnode.ClawdNodeApp -import com.inou.clawdnode.debug.DebugClient -import kotlinx.coroutines.* -import okhttp3.* -import org.json.JSONObject -import java.util.concurrent.TimeUnit - -/** - * Direct WebSocket connection to our own ClawdNode Gateway. - * No authentication, no restrictions - full bidirectional control. - */ -object DirectGateway { - private const val TAG = "DirectGateway" - - // Our gateway - Tailscale IP of james server - private const val GATEWAY_URL = "ws://100.123.216.65:9878" - - private val client = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(0, TimeUnit.SECONDS) - .pingInterval(30, TimeUnit.SECONDS) - .build() - - private var webSocket: WebSocket? = null - private var isConnected = false - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - // Command handlers - var onNotificationAction: ((notificationId: String, action: String, replyText: String?) -> Unit)? = null - var onCallAnswer: ((callId: String) -> Unit)? = null - var onCallReject: ((callId: String) -> Unit)? = null - var onCallHangup: ((callId: String) -> Unit)? = null - - fun connect() { - if (webSocket != null) { - Log.d(TAG, "Already connected or connecting") - return - } - - Log.i(TAG, "Connecting to $GATEWAY_URL") - DebugClient.lifecycle("DIRECT_GATEWAY", "Connecting to $GATEWAY_URL") - - val request = Request.Builder() - .url(GATEWAY_URL) - .build() - - webSocket = client.newWebSocket(request, object : WebSocketListener() { - override fun onOpen(ws: WebSocket, response: Response) { - Log.i(TAG, "Connected to gateway") - isConnected = true - DebugClient.lifecycle("DIRECT_GATEWAY", "Connected") - - // Send hello - send(mapOf( - "type" to "hello", - "client" to "clawdnode-android", - "version" to "0.1.0" - )) - } - - override fun onMessage(ws: WebSocket, text: String) { - Log.d(TAG, "Received: $text") - handleMessage(text) - } - - override fun onClosing(ws: WebSocket, code: Int, reason: String) { - Log.i(TAG, "Connection closing: $code $reason") - ws.close(1000, null) - } - - override fun onClosed(ws: WebSocket, code: Int, reason: String) { - Log.i(TAG, "Connection closed: $code $reason") - isConnected = false - webSocket = null - DebugClient.lifecycle("DIRECT_GATEWAY", "Disconnected: $code $reason") - scheduleReconnect() - } - - override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { - Log.e(TAG, "Connection failed", t) - isConnected = false - webSocket = null - DebugClient.error("DirectGateway connection failed", t) - scheduleReconnect() - } - }) - } - - fun disconnect() { - webSocket?.close(1000, "Client disconnect") - webSocket = null - isConnected = false - } - - private fun scheduleReconnect() { - scope.launch { - delay(5000) - if (webSocket == null) { - connect() - } - } - } - - private fun handleMessage(text: String) { - try { - val json = JSONObject(text) - val type = json.optString("type", "") - - when (type) { - "hello" -> { - Log.i(TAG, "Received hello from server") - DebugClient.log("DirectGateway hello received", mapOf( - "clientId" to json.optString("clientId") - )) - } - - "command" -> { - val command = json.optString("command") - val params = json.optJSONObject("params") ?: JSONObject() - val commandId = json.optString("commandId") - - Log.i(TAG, "Received command: $command") - DebugClient.log("Command received", mapOf( - "command" to command, - "commandId" to commandId - )) - - handleCommand(command, params, commandId) - } - } - } catch (e: Exception) { - Log.e(TAG, "Error parsing message", e) - DebugClient.error("DirectGateway parse error", e) - } - } - - private fun handleCommand(command: String, params: JSONObject, commandId: String) { - try { - when (command) { - "notification.action" -> { - val notificationId = params.optString("notificationId") - val action = params.optString("action") - val replyText = params.optString("replyText", null) - - Log.i(TAG, "Triggering notification action: $action on $notificationId") - onNotificationAction?.invoke(notificationId, action, replyText) - - sendResponse(commandId, true) - ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "notification.action: $action") - } - - "call.answer" -> { - val callId = params.optString("callId") - Log.i(TAG, "Answering call: $callId") - onCallAnswer?.invoke(callId) - - sendResponse(commandId, true) - ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "call.answer") - } - - "call.reject" -> { - val callId = params.optString("callId") - Log.i(TAG, "Rejecting call: $callId") - onCallReject?.invoke(callId) - - sendResponse(commandId, true) - ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "call.reject") - } - - "call.hangup" -> { - val callId = params.optString("callId") - Log.i(TAG, "Hanging up call: $callId") - onCallHangup?.invoke(callId) - - sendResponse(commandId, true) - ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "call.hangup") - } - - else -> { - Log.w(TAG, "Unknown command: $command") - sendResponse(commandId, false, "Unknown command: $command") - } - } - } catch (e: Exception) { - Log.e(TAG, "Error executing command", e) - sendResponse(commandId, false, e.message) - DebugClient.error("Command execution failed", e) - } - } - - private fun sendResponse(commandId: String, success: Boolean, error: String? = null) { - send(mapOf( - "type" to "response", - "commandId" to commandId, - "success" to success, - "error" to error - )) - } - - // ======================================== - // Outgoing events - // ======================================== - - fun sendNotification( - id: String, - app: String, - packageName: String, - title: String?, - text: String?, - actions: List - ) { - send(mapOf( - "type" to "notification", - "id" to id, - "app" to app, - "packageName" to packageName, - "title" to title, - "text" to text, - "actions" to actions, - "timestamp" to System.currentTimeMillis() - )) - } - - fun sendCall( - callId: String, - number: String?, - contact: String?, - state: String - ) { - send(mapOf( - "type" to "call", - "callId" to callId, - "number" to number, - "contact" to contact, - "state" to state, - "timestamp" to System.currentTimeMillis() - )) - } - - fun sendLog(message: String, data: Map = emptyMap()) { - send(mapOf("type" to "log", "message" to message) + data) - } - - fun sendLifecycle(event: String, message: String = "") { - send(mapOf("type" to "lifecycle", "event" to event, "message" to message)) - } - - private fun send(data: Map) { - if (!isConnected) { - Log.d(TAG, "Not connected, cannot send") - return - } - - try { - val json = JSONObject(data).toString() - webSocket?.send(json) - } catch (e: Exception) { - Log.e(TAG, "Send failed", e) - } - } - - fun isConnected() = isConnected -} diff --git a/app/src/main/java/com/inou/clawdnode/ClawdNodeApp.kt b/app/src/main/java/com/inou/moltmobile/MoltMobileApp.kt similarity index 78% rename from app/src/main/java/com/inou/clawdnode/ClawdNodeApp.kt rename to app/src/main/java/com/inou/moltmobile/MoltMobileApp.kt index 43313c5..b1bd065 100644 --- a/app/src/main/java/com/inou/clawdnode/ClawdNodeApp.kt +++ b/app/src/main/java/com/inou/moltmobile/MoltMobileApp.kt @@ -1,23 +1,23 @@ -package com.inou.clawdnode +package com.inou.moltmobile import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.os.Build -import com.inou.clawdnode.debug.DebugClient -import com.inou.clawdnode.gateway.DirectGateway -import com.inou.clawdnode.security.AuditLog -import com.inou.clawdnode.security.DeviceIdentity -import com.inou.clawdnode.security.TokenStore -import com.inou.clawdnode.service.NotificationManager as AppNotificationManager +import com.inou.moltmobile.debug.DebugClient +import com.inou.moltmobile.gateway.DirectGateway +import com.inou.moltmobile.security.AuditLog +import com.inou.moltmobile.security.DeviceIdentity +import com.inou.moltmobile.security.TokenStore +import com.inou.moltmobile.service.NotificationManager as AppNotificationManager /** - * ClawdNode Application + * MoltMobile 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() { +class MoltMobileApp : Application() { lateinit var tokenStore: TokenStore private set @@ -36,10 +36,10 @@ class ClawdNodeApp : Application() { // Create notification channels createNotificationChannels() - auditLog.log("APP_START", "ClawdNode v0.1.0 started") + auditLog.log("APP_START", "MoltMobile v0.2.0 started") // Initialize debug client and log startup - DebugClient.lifecycle("APP_CREATE", "ClawdNode v0.1.0 started") + DebugClient.lifecycle("APP_CREATE", "MoltMobile v0.2.0 started") DebugClient.log("App initialized", mapOf( "gatewayUrl" to (tokenStore.gatewayUrl ?: "not set"), "hasToken" to (tokenStore.gatewayToken != null) @@ -71,6 +71,9 @@ class ClawdNodeApp : Application() { // TODO: Implement call hangup } + // Initialize with context (for AudioManager) + DirectGateway.initialize(this) + // Connect DirectGateway.connect() DebugClient.lifecycle("DIRECT_GATEWAY_SETUP", "Command handlers registered, connecting...") @@ -85,7 +88,7 @@ class ClawdNodeApp : Application() { "Connection Status", NotificationManager.IMPORTANCE_LOW ).apply { - description = "Shows ClawdNode connection status" + description = "Shows MoltMobile connection status" setShowBadge(false) } @@ -111,11 +114,11 @@ class ClawdNodeApp : Application() { } companion object { - const val CHANNEL_SERVICE = "clawdnode_service" - const val CHANNEL_ALERTS = "clawdnode_alerts" - const val CHANNEL_CALLS = "clawdnode_calls" + const val CHANNEL_SERVICE = "moltmobile_service" + const val CHANNEL_ALERTS = "moltmobile_alerts" + const val CHANNEL_CALLS = "moltmobile_calls" - lateinit var instance: ClawdNodeApp + lateinit var instance: MoltMobileApp private set } } diff --git a/app/src/main/java/com/inou/moltmobile/attention/SuperAttentionActivity.kt b/app/src/main/java/com/inou/moltmobile/attention/SuperAttentionActivity.kt new file mode 100644 index 0000000..2f663b6 --- /dev/null +++ b/app/src/main/java/com/inou/moltmobile/attention/SuperAttentionActivity.kt @@ -0,0 +1,304 @@ +package com.inou.moltmobile.attention + +import android.app.KeyguardManager +import android.content.Context +import android.graphics.Color +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.MediaPlayer +import android.media.RingtoneManager +import android.os.* +import android.util.Log +import android.view.View +import android.view.WindowManager +import android.widget.Button +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import kotlinx.coroutines.* + +/** + * SUPER ATTENTION MODE + * + * Makes the phone go absolutely crazy to get attention: + * - Full screen alert + * - Volume MAX + * - Screen strobe (red/white) + * - Camera flash strobe + * - Continuous vibration + * - Alarm sound loop + * + * Only stops when user acknowledges or gateway sends stop command. + */ +class SuperAttentionActivity : AppCompatActivity() { + + companion object { + private const val TAG = "MoltMobile.ATTENTION" + const val EXTRA_MESSAGE = "message" + + // Singleton to track if attention mode is active + var isActive = false + private set + var currentInstance: SuperAttentionActivity? = null + private set + } + + private var vibrator: Vibrator? = null + private var mediaPlayer: MediaPlayer? = null + private var cameraManager: CameraManager? = null + private var cameraId: String? = null + private var flashOn = false + + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var strobeJob: Job? = null + private var flashJob: Job? = null + + private lateinit var rootView: View + private lateinit var messageText: TextView + private lateinit var ackButton: Button + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Wake up and show over lock screen + setupWindowFlags() + + // Simple layout - just a message and ACK button + setupUI() + + val message = intent.getStringExtra(EXTRA_MESSAGE) ?: "URGENT ALERT" + messageText.text = message + + Log.w(TAG, "🚨 SUPER ATTENTION MODE ACTIVATED: $message") + + isActive = true + currentInstance = this + + // GO CRAZY + startMaxVolume() + startAlarmSound() + startVibration() + startScreenStrobe() + startFlashStrobe() + } + + private fun setupWindowFlags() { + // Show over lock screen, turn screen on, keep screen on + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + keyguardManager.requestDismissKeyguard(this, null) + } else { + @Suppress("DEPRECATION") + window.addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + ) + } + + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + // Full brightness + val layoutParams = window.attributes + layoutParams.screenBrightness = 1f + window.attributes = layoutParams + } + + private fun setupUI() { + // Create views programmatically to avoid resource dependencies + rootView = View(this).apply { + setBackgroundColor(Color.RED) + } + + val container = android.widget.LinearLayout(this).apply { + orientation = android.widget.LinearLayout.VERTICAL + gravity = android.view.Gravity.CENTER + setPadding(48, 48, 48, 48) + } + + TextView(this).apply { + text = "🚨 ALERT 🚨" + textSize = 48f + setTextColor(Color.WHITE) + gravity = android.view.Gravity.CENTER + container.addView(this) + } + + messageText = TextView(this).apply { + textSize = 24f + setTextColor(Color.WHITE) + gravity = android.view.Gravity.CENTER + setPadding(0, 32, 0, 64) + container.addView(this) + } + + ackButton = Button(this).apply { + text = "ACKNOWLEDGE" + textSize = 20f + setBackgroundColor(Color.WHITE) + setTextColor(Color.RED) + setPadding(64, 32, 64, 32) + setOnClickListener { acknowledge() } + container.addView(this) + } + + rootView = container + setContentView(rootView) + } + + private fun startMaxVolume() { + val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_ALARM) + audioManager.setStreamVolume( + AudioManager.STREAM_ALARM, + maxVolume, + AudioManager.FLAG_ALLOW_RINGER_MODES + ) + Log.d(TAG, "Volume set to MAX: $maxVolume") + } + + private fun startAlarmSound() { + try { + val alarmUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM) + ?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + + mediaPlayer = MediaPlayer().apply { + setDataSource(this@SuperAttentionActivity, alarmUri) + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_ALARM) + .build() + ) + isLooping = true + prepare() + start() + } + Log.d(TAG, "Alarm sound started") + } catch (e: Exception) { + Log.e(TAG, "Failed to start alarm: ${e.message}") + } + } + + private fun startVibration() { + vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + + // Continuous strong vibration pattern + val pattern = longArrayOf(0, 500, 200, 500, 200, 1000) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator?.vibrate(VibrationEffect.createWaveform(pattern, 0)) + } else { + @Suppress("DEPRECATION") + vibrator?.vibrate(pattern, 0) + } + Log.d(TAG, "Vibration started") + } + + private fun startScreenStrobe() { + strobeJob = scope.launch { + var red = true + while (isActive) { + rootView.setBackgroundColor(if (red) Color.RED else Color.WHITE) + red = !red + delay(200) + } + } + Log.d(TAG, "Screen strobe started") + } + + private fun startFlashStrobe() { + cameraManager = getSystemService(Context.CAMERA_SERVICE) as CameraManager + + try { + for (id in cameraManager!!.cameraIdList) { + val characteristics = cameraManager!!.getCameraCharacteristics(id) + val hasFlash = characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) ?: false + if (hasFlash) { + cameraId = id + break + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to get camera: ${e.message}") + return + } + + if (cameraId == null) { + Log.w(TAG, "No camera flash available") + return + } + + flashJob = scope.launch { + while (isActive) { + try { + flashOn = !flashOn + cameraManager?.setTorchMode(cameraId!!, flashOn) + } catch (e: Exception) { + Log.e(TAG, "Flash toggle failed: ${e.message}") + } + delay(300) + } + } + Log.d(TAG, "Flash strobe started") + } + + private fun acknowledge() { + Log.w(TAG, "🚨 SUPER ATTENTION MODE ACKNOWLEDGED") + stopEverything() + finish() + } + + fun stopFromGateway() { + Log.w(TAG, "🚨 SUPER ATTENTION MODE STOPPED BY GATEWAY") + runOnUiThread { + stopEverything() + finish() + } + } + + private fun stopEverything() { + isActive = false + currentInstance = null + + strobeJob?.cancel() + flashJob?.cancel() + + // Stop sound + mediaPlayer?.stop() + mediaPlayer?.release() + mediaPlayer = null + + // Stop vibration + vibrator?.cancel() + + // Turn off flash + try { + cameraId?.let { cameraManager?.setTorchMode(it, false) } + } catch (e: Exception) { + Log.e(TAG, "Failed to turn off flash: ${e.message}") + } + + Log.d(TAG, "All attention effects stopped") + } + + override fun onDestroy() { + super.onDestroy() + stopEverything() + scope.cancel() + } + + override fun onBackPressed() { + // Prevent back button from dismissing - must acknowledge + // Do nothing + } +} diff --git a/app/src/main/java/com/inou/moltmobile/audio/AudioManager.kt b/app/src/main/java/com/inou/moltmobile/audio/AudioManager.kt new file mode 100644 index 0000000..73586f6 --- /dev/null +++ b/app/src/main/java/com/inou/moltmobile/audio/AudioManager.kt @@ -0,0 +1,209 @@ +package com.inou.moltmobile.audio + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.AudioTrack +import android.media.MediaPlayer +import android.media.MediaRecorder +import android.util.Base64 +import android.util.Log +import kotlinx.coroutines.* +import java.io.File +import java.io.FileOutputStream + +/** + * Audio manager for MoltMobile + * Handles recording (for STT) and playback (for TTS) + */ +class AudioManager(private val context: Context) { + + companion object { + private const val TAG = "MoltMobile.Audio" + private const val SAMPLE_RATE = 24000 + private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO + private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT + } + + private var mediaPlayer: MediaPlayer? = null + private var audioRecord: AudioRecord? = null + private var isRecording = false + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + /** + * Play audio from hex-encoded MP3 data (from MiniMax TTS) + */ + fun playHexAudio(hexData: String, onComplete: () -> Unit = {}) { + scope.launch { + try { + // Decode hex to bytes + val audioBytes = hexStringToByteArray(hexData) + + // Write to temp file + val tempFile = File(context.cacheDir, "tts_${System.currentTimeMillis()}.mp3") + FileOutputStream(tempFile).use { it.write(audioBytes) } + + // Play on main thread + withContext(Dispatchers.Main) { + mediaPlayer?.release() + mediaPlayer = MediaPlayer().apply { + setDataSource(tempFile.absolutePath) + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .build() + ) + setOnCompletionListener { + tempFile.delete() + onComplete() + } + prepare() + start() + } + Log.d(TAG, "Playing TTS audio: ${audioBytes.size} bytes") + } + } catch (e: Exception) { + Log.e(TAG, "Error playing audio: ${e.message}") + } + } + } + + /** + * Play audio from Base64-encoded data + */ + fun playBase64Audio(base64Data: String, onComplete: () -> Unit = {}) { + scope.launch { + try { + val audioBytes = Base64.decode(base64Data, Base64.DEFAULT) + val tempFile = File(context.cacheDir, "tts_${System.currentTimeMillis()}.mp3") + FileOutputStream(tempFile).use { it.write(audioBytes) } + + withContext(Dispatchers.Main) { + mediaPlayer?.release() + mediaPlayer = MediaPlayer().apply { + setDataSource(tempFile.absolutePath) + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .build() + ) + setOnCompletionListener { + tempFile.delete() + onComplete() + } + prepare() + start() + } + } + } catch (e: Exception) { + Log.e(TAG, "Error playing base64 audio: ${e.message}") + } + } + } + + /** + * Start recording audio (for STT) + * Returns a callback to stop recording and get the data + */ + fun startRecording(onData: (ByteArray) -> Unit): () -> Unit { + val bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT) + + audioRecord = AudioRecord( + MediaRecorder.AudioSource.VOICE_COMMUNICATION, + SAMPLE_RATE, + CHANNEL_CONFIG, + AUDIO_FORMAT, + bufferSize * 2 + ) + + isRecording = true + val audioData = mutableListOf() + + scope.launch { + audioRecord?.startRecording() + val buffer = ByteArray(bufferSize) + + while (isRecording) { + val read = audioRecord?.read(buffer, 0, buffer.size) ?: 0 + if (read > 0) { + audioData.addAll(buffer.take(read)) + } + } + + // Send recorded data + if (audioData.isNotEmpty()) { + onData(audioData.toByteArray()) + } + } + + return { + isRecording = false + audioRecord?.stop() + audioRecord?.release() + audioRecord = null + } + } + + /** + * Record for a fixed duration and return data + */ + suspend fun recordForDuration(durationMs: Long): ByteArray { + return withContext(Dispatchers.IO) { + val audioData = mutableListOf() + var stopRecording: (() -> Unit)? = null + + stopRecording = startRecording { data -> + audioData.addAll(data.toList()) + } + + delay(durationMs) + stopRecording() + + audioData.toByteArray() + } + } + + /** + * Stop any current playback + */ + fun stopPlayback() { + mediaPlayer?.stop() + mediaPlayer?.release() + mediaPlayer = null + } + + /** + * Set volume to maximum (for SUPER ATTENTION MODE) + */ + fun setMaxVolume() { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager + val maxVolume = audioManager.getStreamMaxVolume(android.media.AudioManager.STREAM_ALARM) + audioManager.setStreamVolume( + android.media.AudioManager.STREAM_ALARM, + maxVolume, + android.media.AudioManager.FLAG_ALLOW_RINGER_MODES + ) + Log.d(TAG, "Volume set to MAX: $maxVolume") + } + + fun release() { + stopPlayback() + isRecording = false + audioRecord?.release() + scope.cancel() + } + + private fun hexStringToByteArray(hex: String): ByteArray { + val len = hex.length + val data = ByteArray(len / 2) + var i = 0 + while (i < len) { + data[i / 2] = ((Character.digit(hex[i], 16) shl 4) + Character.digit(hex[i + 1], 16)).toByte() + i += 2 + } + return data + } +} diff --git a/app/src/main/java/com/inou/moltmobile/browser/RemoteBrowserActivity.kt b/app/src/main/java/com/inou/moltmobile/browser/RemoteBrowserActivity.kt new file mode 100644 index 0000000..e37003d --- /dev/null +++ b/app/src/main/java/com/inou/moltmobile/browser/RemoteBrowserActivity.kt @@ -0,0 +1,230 @@ +package com.inou.moltmobile.browser + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Bundle +import android.util.Base64 +import android.util.Log +import android.view.ViewGroup +import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.FrameLayout +import android.widget.ProgressBar +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import java.io.ByteArrayOutputStream + +/** + * Remote Browser Activity + * + * Allows Molt to control a browser on the phone: + * - Open URLs + * - Execute JavaScript + * - Take screenshots + * - Navigate + */ +class RemoteBrowserActivity : AppCompatActivity() { + + companion object { + private const val TAG = "MoltMobile.Browser" + const val EXTRA_URL = "url" + + // Singleton access + var currentInstance: RemoteBrowserActivity? = null + private set + } + + private lateinit var webView: WebView + private lateinit var statusBar: TextView + private lateinit var progressBar: ProgressBar + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + currentInstance = this + + // Create layout programmatically + val container = FrameLayout(this) + + // Status bar at top + statusBar = TextView(this).apply { + text = "🌐 Molt is browsing..." + setPadding(16, 8, 16, 8) + setBackgroundColor(0xFF1976D2.toInt()) + setTextColor(0xFFFFFFFF.toInt()) + textSize = 14f + } + container.addView(statusBar, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )) + + // Progress bar + progressBar = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply { + isIndeterminate = false + max = 100 + progress = 0 + } + container.addView(progressBar, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 8 + ).apply { + topMargin = 48 // Below status bar + }) + + // WebView + webView = WebView(this).apply { + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + cacheMode = WebSettings.LOAD_DEFAULT + setSupportZoom(true) + builtInZoomControls = true + displayZoomControls = false + useWideViewPort = true + loadWithOverviewMode = true + mediaPlaybackRequiresUserGesture = false + } + + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + statusBar.text = "🌐 Loading: ${url?.take(50)}..." + progressBar.progress = 0 + } + + override fun onPageFinished(view: WebView?, url: String?) { + statusBar.text = "🌐 Molt is browsing: ${view?.title?.take(40) ?: url?.take(40)}" + progressBar.progress = 100 + Log.d(TAG, "Page loaded: $url") + } + } + + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + progressBar.progress = newProgress + } + } + } + + val webViewParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ).apply { + topMargin = 56 // Below status bar and progress + } + container.addView(webView, webViewParams) + + setContentView(container) + + // Load initial URL + intent.getStringExtra(EXTRA_URL)?.let { url -> + navigateTo(url) + } + + Log.d(TAG, "Remote browser started") + } + + /** + * Navigate to URL + */ + fun navigateTo(url: String) { + val fullUrl = if (url.startsWith("http://") || url.startsWith("https://")) { + url + } else { + "https://$url" + } + + Log.d(TAG, "Navigating to: $fullUrl") + webView.loadUrl(fullUrl) + } + + /** + * Execute JavaScript and return result + */ + fun executeJs(script: String, callback: (String) -> Unit) { + Log.d(TAG, "Executing JS: ${script.take(100)}...") + webView.evaluateJavascript(script) { result -> + Log.d(TAG, "JS result: ${result.take(200)}") + callback(result) + } + } + + /** + * Capture screenshot as Base64 PNG + */ + fun captureScreenshot(): String { + val bitmap = Bitmap.createBitmap( + webView.width, + webView.height, + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + webView.draw(canvas) + + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 90, stream) + val base64 = Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP) + + Log.d(TAG, "Screenshot captured: ${base64.length} chars") + return base64 + } + + /** + * Get current URL + */ + fun getCurrentUrl(): String { + return webView.url ?: "" + } + + /** + * Get page title + */ + fun getTitle(): String { + return webView.title ?: "" + } + + /** + * Go back + */ + fun goBack(): Boolean { + return if (webView.canGoBack()) { + webView.goBack() + true + } else false + } + + /** + * Go forward + */ + fun goForward(): Boolean { + return if (webView.canGoForward()) { + webView.goForward() + true + } else false + } + + /** + * Refresh page + */ + fun refresh() { + webView.reload() + } + + override fun onDestroy() { + super.onDestroy() + currentInstance = null + webView.destroy() + Log.d(TAG, "Remote browser closed") + } + + override fun onBackPressed() { + if (webView.canGoBack()) { + webView.goBack() + } else { + super.onBackPressed() + } + } +} diff --git a/app/src/main/java/com/inou/clawdnode/calls/CallScreener.kt b/app/src/main/java/com/inou/moltmobile/calls/CallScreener.kt similarity index 92% rename from app/src/main/java/com/inou/clawdnode/calls/CallScreener.kt rename to app/src/main/java/com/inou/moltmobile/calls/CallScreener.kt index f25778d..ec07e9c 100644 --- a/app/src/main/java/com/inou/clawdnode/calls/CallScreener.kt +++ b/app/src/main/java/com/inou/moltmobile/calls/CallScreener.kt @@ -1,4 +1,4 @@ -package com.inou.clawdnode.calls +package com.inou.moltmobile.calls import android.content.ComponentName import android.content.Context @@ -8,11 +8,11 @@ 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.debug.DebugClient -import com.inou.clawdnode.gateway.DirectGateway -import com.inou.clawdnode.protocol.CallIncomingEvent -import com.inou.clawdnode.service.NodeService +import com.inou.moltmobile.MoltMobileApp +import com.inou.moltmobile.debug.DebugClient +import com.inou.moltmobile.gateway.DirectGateway +import com.inou.moltmobile.protocol.CallIncomingEvent +import com.inou.moltmobile.service.NodeService /** * Screens incoming calls before they ring. @@ -84,7 +84,7 @@ class CallScreener : CallScreeningService() { DebugClient.error("Failed to send call event", e) } - ClawdNodeApp.instance.auditLog.logCall( + MoltMobileApp.instance.auditLog.logCall( "CALL_INCOMING", number, contactName, diff --git a/app/src/main/java/com/inou/clawdnode/calls/VoiceCallService.kt b/app/src/main/java/com/inou/moltmobile/calls/VoiceCallService.kt similarity index 95% rename from app/src/main/java/com/inou/clawdnode/calls/VoiceCallService.kt rename to app/src/main/java/com/inou/moltmobile/calls/VoiceCallService.kt index b23427b..2aafe80 100644 --- a/app/src/main/java/com/inou/clawdnode/calls/VoiceCallService.kt +++ b/app/src/main/java/com/inou/moltmobile/calls/VoiceCallService.kt @@ -1,4 +1,4 @@ -package com.inou.clawdnode.calls +package com.inou.moltmobile.calls import android.content.ComponentName import android.content.Context @@ -16,11 +16,11 @@ 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 com.inou.moltmobile.MoltMobileApp +import com.inou.moltmobile.protocol.CallAudioEvent +import com.inou.moltmobile.protocol.CallEndedEvent +import com.inou.moltmobile.service.CallManager +import com.inou.moltmobile.service.NodeService import java.util.* /** @@ -148,7 +148,7 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener { ) nodeService?.sendEvent(event) - ClawdNodeApp.instance.auditLog.logCall( + MoltMobileApp.instance.auditLog.logCall( "CALL_ENDED", call.details.handle?.schemeSpecificPart, null, @@ -171,7 +171,7 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener { Log.i(tag, "Answering call: $callId") call.answer(VideoProfile.STATE_AUDIO_ONLY) - ClawdNodeApp.instance.auditLog.logCall( + MoltMobileApp.instance.auditLog.logCall( "CALL_ANSWERED", call.details.handle?.schemeSpecificPart, null, @@ -192,7 +192,7 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener { Log.i(tag, "Rejecting call: $callId") call.reject(false, null) - ClawdNodeApp.instance.auditLog.logCall( + MoltMobileApp.instance.auditLog.logCall( "CALL_REJECTED", call.details.handle?.schemeSpecificPart, null, @@ -239,7 +239,7 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener { } }) - ClawdNodeApp.instance.auditLog.log( + MoltMobileApp.instance.auditLog.log( "CALL_SPEAK", "TTS: $text", mapOf("call_id" to callId) diff --git a/app/src/main/java/com/inou/clawdnode/debug/DebugClient.kt b/app/src/main/java/com/inou/moltmobile/debug/DebugClient.kt similarity index 99% rename from app/src/main/java/com/inou/clawdnode/debug/DebugClient.kt rename to app/src/main/java/com/inou/moltmobile/debug/DebugClient.kt index 26fa7a6..d41d199 100644 --- a/app/src/main/java/com/inou/clawdnode/debug/DebugClient.kt +++ b/app/src/main/java/com/inou/moltmobile/debug/DebugClient.kt @@ -1,4 +1,4 @@ -package com.inou.clawdnode.debug +package com.inou.moltmobile.debug import android.util.Log import kotlinx.coroutines.* diff --git a/app/src/main/java/com/inou/moltmobile/gateway/DirectGateway.kt b/app/src/main/java/com/inou/moltmobile/gateway/DirectGateway.kt new file mode 100644 index 0000000..fa77307 --- /dev/null +++ b/app/src/main/java/com/inou/moltmobile/gateway/DirectGateway.kt @@ -0,0 +1,392 @@ +package com.inou.moltmobile.gateway + +import android.content.Context +import android.content.Intent +import android.util.Log +import com.inou.moltmobile.MoltMobileApp +import com.inou.moltmobile.attention.SuperAttentionActivity +import com.inou.moltmobile.audio.AudioManager +import com.inou.moltmobile.browser.RemoteBrowserActivity +import com.inou.moltmobile.debug.DebugClient +import kotlinx.coroutines.* +import okhttp3.* +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +/** + * Direct WebSocket connection to MoltMobile Gateway. + * Full bidirectional control for Molt. + */ +object DirectGateway { + private const val TAG = "MoltMobile.Gateway" + + // Gateway address - Tailscale IP + private const val GATEWAY_URL = "ws://100.123.216.65:9878" + + private val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) + .build() + + private var webSocket: WebSocket? = null + private var isConnected = false + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // Audio manager for TTS playback + private var audioManager: AudioManager? = null + + // Command handlers (for other components to register) + var onNotificationAction: ((notificationId: String, action: String, replyText: String?) -> Unit)? = null + var onCallAnswer: ((callId: String) -> Unit)? = null + var onCallReject: ((callId: String) -> Unit)? = null + var onCallHangup: ((callId: String) -> Unit)? = null + + fun initialize(context: Context) { + audioManager = AudioManager(context) + } + + fun connect() { + if (webSocket != null) { + Log.d(TAG, "Already connected or connecting") + return + } + + Log.i(TAG, "Connecting to $GATEWAY_URL") + DebugClient.lifecycle("GATEWAY", "Connecting to $GATEWAY_URL") + + val request = Request.Builder() + .url(GATEWAY_URL) + .build() + + webSocket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(ws: WebSocket, response: Response) { + Log.i(TAG, "✅ Connected to gateway") + isConnected = true + DebugClient.lifecycle("GATEWAY", "Connected") + + send(mapOf( + "type" to "hello", + "client" to "moltmobile-android", + "version" to "0.2.0", + "capabilities" to listOf("audio", "browser", "attention", "calls", "notifications") + )) + } + + override fun onMessage(ws: WebSocket, text: String) { + Log.d(TAG, "📨 Received: ${text.take(200)}") + handleMessage(text) + } + + override fun onClosing(ws: WebSocket, code: Int, reason: String) { + Log.i(TAG, "Connection closing: $code $reason") + ws.close(1000, null) + } + + override fun onClosed(ws: WebSocket, code: Int, reason: String) { + Log.i(TAG, "Connection closed: $code $reason") + isConnected = false + webSocket = null + scheduleReconnect() + } + + override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { + Log.e(TAG, "❌ Connection failed", t) + isConnected = false + webSocket = null + scheduleReconnect() + } + }) + } + + fun disconnect() { + webSocket?.close(1000, "Client disconnect") + webSocket = null + isConnected = false + } + + private fun scheduleReconnect() { + scope.launch { + delay(5000) + if (webSocket == null) { + connect() + } + } + } + + private fun handleMessage(text: String) { + try { + val json = JSONObject(text) + val type = json.optString("type", "") + + when (type) { + "hello" -> { + Log.i(TAG, "Hello from server: ${json.optString("clientId")}") + } + + "command" -> { + val command = json.optString("command") + val params = json.optJSONObject("params") ?: JSONObject() + val commandId = json.optString("commandId") + + Log.i(TAG, "📥 Command: $command") + handleCommand(command, params, commandId) + } + } + } catch (e: Exception) { + Log.e(TAG, "Parse error", e) + } + } + + private fun handleCommand(command: String, params: JSONObject, commandId: String) { + val context = MoltMobileApp.instance.applicationContext + + try { + when (command) { + // ======================================== + // AUDIO COMMANDS + // ======================================== + "audio.play" -> { + val audioHex = params.optString("audioHex", "") + val audioBase64 = params.optString("audioBase64", "") + + when { + audioHex.isNotEmpty() -> { + Log.i(TAG, "🔊 Playing hex audio (${audioHex.length} chars)") + audioManager?.playHexAudio(audioHex) { + sendResponse(commandId, true, data = mapOf("status" to "completed")) + } + sendResponse(commandId, true, data = mapOf("status" to "playing")) + } + audioBase64.isNotEmpty() -> { + Log.i(TAG, "🔊 Playing base64 audio") + audioManager?.playBase64Audio(audioBase64) { + sendResponse(commandId, true, data = mapOf("status" to "completed")) + } + sendResponse(commandId, true, data = mapOf("status" to "playing")) + } + else -> { + sendResponse(commandId, false, "No audio data provided") + } + } + } + + "audio.stop" -> { + Log.i(TAG, "🔇 Stopping audio") + audioManager?.stopPlayback() + sendResponse(commandId, true) + } + + "audio.volume.max" -> { + Log.i(TAG, "🔊 Setting volume to MAX") + audioManager?.setMaxVolume() + sendResponse(commandId, true) + } + + // ======================================== + // SUPER ATTENTION MODE 🚨 + // ======================================== + "attention.super" -> { + val message = params.optString("message", "URGENT ALERT") + Log.w(TAG, "🚨 SUPER ATTENTION MODE: $message") + + val intent = Intent(context, SuperAttentionActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(SuperAttentionActivity.EXTRA_MESSAGE, message) + } + context.startActivity(intent) + + sendResponse(commandId, true) + MoltMobileApp.instance.auditLog.log("ATTENTION_SUPER", message) + } + + "attention.stop" -> { + Log.i(TAG, "🚨 Stopping attention mode") + SuperAttentionActivity.currentInstance?.stopFromGateway() + sendResponse(commandId, true) + } + + // ======================================== + // BROWSER COMMANDS + // ======================================== + "browser.open" -> { + val url = params.optString("url", "https://google.com") + Log.i(TAG, "🌐 Opening browser: $url") + + val intent = Intent(context, RemoteBrowserActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtra(RemoteBrowserActivity.EXTRA_URL, url) + } + context.startActivity(intent) + + sendResponse(commandId, true) + } + + "browser.navigate" -> { + val url = params.optString("url") + Log.i(TAG, "🌐 Navigating to: $url") + + RemoteBrowserActivity.currentInstance?.let { browser -> + scope.launch(Dispatchers.Main) { + browser.navigateTo(url) + sendResponse(commandId, true) + } + } ?: sendResponse(commandId, false, "Browser not open") + } + + "browser.js" -> { + val script = params.optString("script") + Log.i(TAG, "🌐 Executing JS: ${script.take(50)}...") + + RemoteBrowserActivity.currentInstance?.let { browser -> + scope.launch(Dispatchers.Main) { + browser.executeJs(script) { result -> + sendResponse(commandId, true, data = mapOf("result" to result)) + } + } + } ?: sendResponse(commandId, false, "Browser not open") + } + + "browser.screenshot" -> { + Log.i(TAG, "🌐 Taking screenshot") + + RemoteBrowserActivity.currentInstance?.let { browser -> + scope.launch(Dispatchers.Main) { + val screenshot = browser.captureScreenshot() + sendResponse(commandId, true, data = mapOf( + "screenshot" to screenshot, + "url" to browser.getCurrentUrl(), + "title" to browser.getTitle() + )) + } + } ?: sendResponse(commandId, false, "Browser not open") + } + + "browser.close" -> { + Log.i(TAG, "🌐 Closing browser") + RemoteBrowserActivity.currentInstance?.finish() + sendResponse(commandId, true) + } + + // ======================================== + // NOTIFICATION COMMANDS + // ======================================== + "notification.action" -> { + val notificationId = params.optString("notificationId") + val action = params.optString("action") + val replyText = params.optString("replyText", null) + + Log.i(TAG, "🔔 Notification action: $action on $notificationId") + onNotificationAction?.invoke(notificationId, action, replyText) + + sendResponse(commandId, true) + } + + // ======================================== + // CALL COMMANDS + // ======================================== + "call.answer" -> { + val callId = params.optString("callId") + Log.i(TAG, "📞 Answering call: $callId") + onCallAnswer?.invoke(callId) + sendResponse(commandId, true) + } + + "call.reject" -> { + val callId = params.optString("callId") + Log.i(TAG, "📞 Rejecting call: $callId") + onCallReject?.invoke(callId) + sendResponse(commandId, true) + } + + "call.hangup" -> { + val callId = params.optString("callId") + Log.i(TAG, "📞 Hanging up: $callId") + onCallHangup?.invoke(callId) + sendResponse(commandId, true) + } + + else -> { + Log.w(TAG, "❓ Unknown command: $command") + sendResponse(commandId, false, "Unknown command: $command") + } + } + } catch (e: Exception) { + Log.e(TAG, "❌ Command failed", e) + sendResponse(commandId, false, e.message) + } + } + + private fun sendResponse(commandId: String, success: Boolean, error: String? = null, data: Map = emptyMap()) { + send(mapOf( + "type" to "response", + "commandId" to commandId, + "success" to success, + "error" to error + ) + data) + } + + // ======================================== + // Outgoing events + // ======================================== + + fun sendNotification( + id: String, + app: String, + packageName: String, + title: String?, + text: String?, + actions: List + ) { + send(mapOf( + "type" to "notification", + "id" to id, + "app" to app, + "packageName" to packageName, + "title" to title, + "text" to text, + "actions" to actions, + "timestamp" to System.currentTimeMillis() + )) + } + + fun sendCall(callId: String, number: String?, contact: String?, state: String) { + send(mapOf( + "type" to "call", + "callId" to callId, + "number" to number, + "contact" to contact, + "state" to state, + "timestamp" to System.currentTimeMillis() + )) + } + + fun sendLog(message: String, data: Map = emptyMap()) { + send(mapOf("type" to "log", "message" to message) + data) + } + + fun sendLifecycle(event: String, message: String = "") { + send(mapOf("type" to "lifecycle", "event" to event, "message" to message)) + } + + private fun send(data: Map) { + if (!isConnected) return + + try { + val json = JSONObject(data).toString() + webSocket?.send(json) + } catch (e: Exception) { + Log.e(TAG, "Send failed", e) + } + } + + fun isConnected() = isConnected + + fun release() { + audioManager?.release() + disconnect() + scope.cancel() + } +} diff --git a/app/src/main/java/com/inou/clawdnode/notifications/NotificationListener.kt b/app/src/main/java/com/inou/moltmobile/notifications/NotificationListener.kt similarity index 96% rename from app/src/main/java/com/inou/clawdnode/notifications/NotificationListener.kt rename to app/src/main/java/com/inou/moltmobile/notifications/NotificationListener.kt index 22a8543..a352fbe 100644 --- a/app/src/main/java/com/inou/clawdnode/notifications/NotificationListener.kt +++ b/app/src/main/java/com/inou/moltmobile/notifications/NotificationListener.kt @@ -1,4 +1,4 @@ -package com.inou.clawdnode.notifications +package com.inou.moltmobile.notifications import android.app.Notification import android.app.RemoteInput @@ -11,12 +11,12 @@ 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.debug.DebugClient -import com.inou.clawdnode.gateway.DirectGateway -import com.inou.clawdnode.protocol.NotificationEvent -import com.inou.clawdnode.service.NodeService -import com.inou.clawdnode.service.NotificationManager +import com.inou.moltmobile.MoltMobileApp +import com.inou.moltmobile.debug.DebugClient +import com.inou.moltmobile.gateway.DirectGateway +import com.inou.moltmobile.protocol.NotificationEvent +import com.inou.moltmobile.service.NodeService +import com.inou.moltmobile.service.NotificationManager /** * Listens to all notifications and forwards them to Gateway. @@ -160,7 +160,7 @@ class NotificationListener : NotificationListenerService() { // Also log to local audit try { - ClawdNodeApp.instance.auditLog.logNotification( + MoltMobileApp.instance.auditLog.logNotification( "NOTIFICATION_POSTED", sbn.packageName, title @@ -235,7 +235,7 @@ class NotificationListener : NotificationListenerService() { "isReply" to (replyText != null) )) - ClawdNodeApp.instance.auditLog.log( + MoltMobileApp.instance.auditLog.log( "NOTIFICATION_ACTION", "Triggered action: $actionTitle", mapOf("notification_id" to notificationId, "reply" to (replyText != null)) diff --git a/app/src/main/java/com/inou/clawdnode/protocol/Messages.kt b/app/src/main/java/com/inou/moltmobile/protocol/Messages.kt similarity index 99% rename from app/src/main/java/com/inou/clawdnode/protocol/Messages.kt rename to app/src/main/java/com/inou/moltmobile/protocol/Messages.kt index af1f88d..628973d 100644 --- a/app/src/main/java/com/inou/clawdnode/protocol/Messages.kt +++ b/app/src/main/java/com/inou/moltmobile/protocol/Messages.kt @@ -1,4 +1,4 @@ -package com.inou.clawdnode.protocol +package com.inou.moltmobile.protocol import com.google.gson.Gson import com.google.gson.JsonObject @@ -6,7 +6,7 @@ import com.google.gson.JsonParser import com.google.gson.annotations.SerializedName /** - * Protocol messages between ClawdNode and Gateway. + * Protocol messages between MoltMobile and Gateway. * * Gateway Protocol v3: * - Connect to /ws endpoint diff --git a/app/src/main/java/com/inou/clawdnode/security/AuditLog.kt b/app/src/main/java/com/inou/moltmobile/security/AuditLog.kt similarity index 97% rename from app/src/main/java/com/inou/clawdnode/security/AuditLog.kt rename to app/src/main/java/com/inou/moltmobile/security/AuditLog.kt index b0d374e..5fdc03a 100644 --- a/app/src/main/java/com/inou/clawdnode/security/AuditLog.kt +++ b/app/src/main/java/com/inou/moltmobile/security/AuditLog.kt @@ -1,4 +1,4 @@ -package com.inou.clawdnode.security +package com.inou.moltmobile.security import android.content.Context import com.google.gson.Gson @@ -9,7 +9,7 @@ import java.util.* import java.util.concurrent.ConcurrentLinkedQueue /** - * Local audit log for all ClawdNode actions. + * Local audit log for all MoltMobile actions. * Security-first: every action is logged with timestamp. */ class AuditLog(private val context: Context) { diff --git a/app/src/main/java/com/inou/clawdnode/security/DeviceIdentity.kt b/app/src/main/java/com/inou/moltmobile/security/DeviceIdentity.kt similarity index 97% rename from app/src/main/java/com/inou/clawdnode/security/DeviceIdentity.kt rename to app/src/main/java/com/inou/moltmobile/security/DeviceIdentity.kt index 2c66e53..a99b9bb 100644 --- a/app/src/main/java/com/inou/clawdnode/security/DeviceIdentity.kt +++ b/app/src/main/java/com/inou/moltmobile/security/DeviceIdentity.kt @@ -1,4 +1,4 @@ -package com.inou.clawdnode.security +package com.inou.moltmobile.security import android.content.Context import android.util.Base64 @@ -25,7 +25,7 @@ import java.security.SecureRandom class DeviceIdentity(context: Context) { private val tag = "DeviceIdentity" - private val prefsName = "clawdnode_device_identity" + private val prefsName = "moltmobile_device_identity" private val keyPrivate = "private_key" private val keyPublic = "public_key" @@ -74,7 +74,7 @@ class DeviceIdentity(context: Context) { * v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce * * @param nonce Challenge nonce from gateway - * @param clientId Client identifier (e.g., "clawdnode-android") + * @param clientId Client identifier (e.g., "moltmobile-android") * @param clientMode Client mode ("node") * @param role Role ("node") * @param scopes Comma-separated scopes (empty for nodes) diff --git a/app/src/main/java/com/inou/clawdnode/security/TokenStore.kt b/app/src/main/java/com/inou/moltmobile/security/TokenStore.kt similarity index 96% rename from app/src/main/java/com/inou/clawdnode/security/TokenStore.kt rename to app/src/main/java/com/inou/moltmobile/security/TokenStore.kt index d7abc66..71bc2ca 100644 --- a/app/src/main/java/com/inou/clawdnode/security/TokenStore.kt +++ b/app/src/main/java/com/inou/moltmobile/security/TokenStore.kt @@ -1,4 +1,4 @@ -package com.inou.clawdnode.security +package com.inou.moltmobile.security import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences @@ -16,7 +16,7 @@ class TokenStore(context: Context) { private val prefs = EncryptedSharedPreferences.create( context, - "clawdnode_secure_prefs", + "moltmobile_secure_prefs", masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM diff --git a/app/src/main/java/com/inou/clawdnode/service/GatewayClient.kt b/app/src/main/java/com/inou/moltmobile/service/GatewayClient.kt similarity index 96% rename from app/src/main/java/com/inou/clawdnode/service/GatewayClient.kt rename to app/src/main/java/com/inou/moltmobile/service/GatewayClient.kt index d5fcb82..6a7f320 100644 --- a/app/src/main/java/com/inou/clawdnode/service/GatewayClient.kt +++ b/app/src/main/java/com/inou/moltmobile/service/GatewayClient.kt @@ -1,13 +1,13 @@ -package com.inou.clawdnode.service +package com.inou.moltmobile.service import android.util.Log import com.google.gson.Gson import com.google.gson.JsonObject -import com.inou.clawdnode.BuildConfig -import com.inou.clawdnode.ClawdNodeApp -import com.inou.clawdnode.protocol.Protocol -import com.inou.clawdnode.protocol.* -import com.inou.clawdnode.security.DeviceIdentity +import com.inou.moltmobile.BuildConfig +import com.inou.moltmobile.MoltMobileApp +import com.inou.moltmobile.protocol.Protocol +import com.inou.moltmobile.protocol.* +import com.inou.moltmobile.security.DeviceIdentity import kotlinx.coroutines.* import okhttp3.* import java.util.UUID @@ -62,10 +62,10 @@ class GatewayClient( private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val requestIdCounter = AtomicInteger(0) - private val auditLog get() = ClawdNodeApp.instance.auditLog - private val tokenStore get() = ClawdNodeApp.instance.tokenStore + private val auditLog get() = MoltMobileApp.instance.auditLog + private val tokenStore get() = MoltMobileApp.instance.tokenStore - private val deviceIdentity by lazy { DeviceIdentity(ClawdNodeApp.instance) } + private val deviceIdentity by lazy { DeviceIdentity(MoltMobileApp.instance) } // Node capabilities private val caps = listOf("notifications", "calls", "voice") @@ -266,7 +266,7 @@ class GatewayClient( "calls.speak" to true ), auth = AuthInfo(token = token), - userAgent = "clawdnode-android/${getAppVersion()}", + userAgent = "moltmobile-android/${getAppVersion()}", device = DeviceInfo( id = deviceId, publicKey = publicKey, diff --git a/app/src/main/java/com/inou/clawdnode/service/NodeService.kt b/app/src/main/java/com/inou/moltmobile/service/NodeService.kt similarity index 90% rename from app/src/main/java/com/inou/clawdnode/service/NodeService.kt rename to app/src/main/java/com/inou/moltmobile/service/NodeService.kt index 587090d..d71a0d8 100644 --- a/app/src/main/java/com/inou/clawdnode/service/NodeService.kt +++ b/app/src/main/java/com/inou/moltmobile/service/NodeService.kt @@ -1,4 +1,4 @@ -package com.inou.clawdnode.service +package com.inou.moltmobile.service import android.app.Notification import android.app.PendingIntent @@ -8,14 +8,14 @@ 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 +import com.inou.moltmobile.MoltMobileApp +import com.inou.moltmobile.R +import com.inou.moltmobile.protocol.* +import com.inou.moltmobile.ui.MainActivity /** * Main foreground service that maintains Gateway connection - * and coordinates all ClawdNode functionality. + * and coordinates all MoltMobile functionality. */ class NodeService : Service() { @@ -58,7 +58,7 @@ class NodeService : Service() { startForeground(NOTIFICATION_ID, createNotification()) // Connect to gateway - if (ClawdNodeApp.instance.tokenStore.isConfigured) { + if (MoltMobileApp.instance.tokenStore.isConfigured) { gatewayClient.connect() } else { Log.w(tag, "Gateway not configured, waiting for setup") @@ -70,7 +70,7 @@ class NodeService : Service() { override fun onDestroy() { Log.i(tag, "NodeService destroyed") gatewayClient.disconnect() - ClawdNodeApp.instance.auditLog.log("SERVICE_STOP", "NodeService destroyed") + MoltMobileApp.instance.auditLog.log("SERVICE_STOP", "NodeService destroyed") super.onDestroy() } @@ -189,8 +189,8 @@ class NodeService : Service() { val status = if (isConnected) "Connected to Gateway" else "Disconnected" - return NotificationCompat.Builder(this, ClawdNodeApp.CHANNEL_SERVICE) - .setContentTitle("ClawdNode") + return NotificationCompat.Builder(this, MoltMobileApp.CHANNEL_SERVICE) + .setContentTitle("MoltMobile") .setContentText(status) .setSmallIcon(R.drawable.ic_notification) .setOngoing(true) @@ -215,9 +215,9 @@ class NodeService : Service() { * These get populated by the respective listener services. */ object NotificationManager { - private var listener: com.inou.clawdnode.notifications.NotificationListener? = null + private var listener: com.inou.moltmobile.notifications.NotificationListener? = null - fun register(listener: com.inou.clawdnode.notifications.NotificationListener) { + fun register(listener: com.inou.moltmobile.notifications.NotificationListener) { this.listener = listener } @@ -231,9 +231,9 @@ object NotificationManager { } object CallManager { - private var callService: com.inou.clawdnode.calls.VoiceCallService? = null + private var callService: com.inou.moltmobile.calls.VoiceCallService? = null - fun register(service: com.inou.clawdnode.calls.VoiceCallService) { + fun register(service: com.inou.moltmobile.calls.VoiceCallService) { this.callService = service } diff --git a/app/src/main/java/com/inou/clawdnode/ui/MainActivity.kt b/app/src/main/java/com/inou/moltmobile/ui/MainActivity.kt similarity index 95% rename from app/src/main/java/com/inou/clawdnode/ui/MainActivity.kt rename to app/src/main/java/com/inou/moltmobile/ui/MainActivity.kt index bf849c6..a37d2b9 100644 --- a/app/src/main/java/com/inou/clawdnode/ui/MainActivity.kt +++ b/app/src/main/java/com/inou/moltmobile/ui/MainActivity.kt @@ -1,4 +1,4 @@ -package com.inou.clawdnode.ui +package com.inou.moltmobile.ui import android.Manifest import android.app.role.RoleManager @@ -16,9 +16,9 @@ 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 +import com.inou.moltmobile.MoltMobileApp +import com.inou.moltmobile.databinding.ActivityMainBinding +import com.inou.moltmobile.service.NodeService /** * Main setup and status activity. @@ -95,7 +95,7 @@ class MainActivity : AppCompatActivity() { } // Load existing config - val tokenStore = ClawdNodeApp.instance.tokenStore + val tokenStore = MoltMobileApp.instance.tokenStore binding.etGatewayUrl.setText(tokenStore.gatewayUrl ?: "") binding.etGatewayToken.setText(tokenStore.gatewayToken ?: "") @@ -149,11 +149,11 @@ class MainActivity : AppCompatActivity() { return } - val tokenStore = ClawdNodeApp.instance.tokenStore + val tokenStore = MoltMobileApp.instance.tokenStore tokenStore.gatewayUrl = url tokenStore.gatewayToken = token - ClawdNodeApp.instance.auditLog.log( + MoltMobileApp.instance.auditLog.log( "CONFIG_SAVED", "Gateway configuration updated", mapOf("url" to url) @@ -245,7 +245,7 @@ class MainActivity : AppCompatActivity() { } private fun showAuditLog() { - val entries = ClawdNodeApp.instance.auditLog.getRecentEntries(50) + val entries = MoltMobileApp.instance.auditLog.getRecentEntries(50) val text = entries.joinToString("\n\n") { entry -> "${entry.timestamp}\n${entry.action}: ${entry.details}" } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f0a636..92fb382 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ - - ClawdNode + MoltMobile + MoltMobile Service + MoltMobile Alerts diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 70db2c4..da973b5 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,10 +1,21 @@ - + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index d1612f2..6f6d2dd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,5 +13,5 @@ dependencyResolutionManagement { } } -rootProject.name = "ClawdNode" +rootProject.name = "MoltMobile" include(":app")