Rename package from clawdnode to moltmobile

- Move all classes from com.inou.clawdnode to com.inou.moltmobile
- Update app name and package references
- Keep all existing functionality
This commit is contained in:
James (ClawdBot) 2026-02-04 22:58:46 -05:00
parent b8d1705c9c
commit 9d308bc35c
23 changed files with 1357 additions and 510 deletions

237
README.md
View File

@ -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 ## Features
- 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 ### 📞 Smart Call Handling
- Intercepts incoming calls before ring - Answer/reject calls via gateway command
- Sends caller info to Gateway for Claude to decide - Spam detection and auto-response (TTS)
- **Answer calls programmatically** - Known caller greeting
- **Speak into calls via TTS** (Text-to-Speech) - Call audio streaming (coming soon)
- **Listen to caller via STT** (Speech-to-Text)
- Full voice conversation loop with Claude as the brain
### Security ### 🔔 Notification Relay
- **Tailscale-only** — no public internet exposure - All notifications forwarded to Molt
- Encrypted credential storage (EncryptedSharedPreferences) - Action execution (reply, dismiss, etc.)
- Local audit log of all actions - Smart filtering
- All permissions clearly explained
## 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 ### 🌐 Remote Browser
// Notification received Molt can control Chrome on your phone:
{"type": "notification", "id": "com.whatsapp:123:1706400000", "app": "WhatsApp", "package": "com.whatsapp", "title": "Mom", "text": "Call me when you can", "actions": ["Reply", "Mark read"]} - Open URLs
- Execute JavaScript
- Take screenshots
- Navigate pages
// Incoming call ## Architecture
{"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"}
``` ```
Your Phone (MoltMobile app)
### Commands (Gateway → Phone) ↓ WebSocket
MoltMobile Gateway (server)
```json
// Take screenshot MiniMax API (LLM + TTS)
{"cmd": "screenshot"}
Molt/Clawdbot (escalation)
// 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 ## 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 ```bash
# Clone and build cd moltmobile-android
cd clawdnode-android
./gradlew assembleDebug ./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 - **0.2.0** — Renamed from ClawdNode, added MiniMax audio, SUPER ATTENTION MODE, remote browser
adb install app/build/outputs/apk/debug/app-debug.apk - **0.1.0** — Initial release (as ClawdNode)
```
Or transfer APK and install manually (enable "Unknown sources"). ---
### 3. Configure Gateway *MoltMobile — Molt in your pocket.*
1. Open ClawdNode app
2. Enter Gateway URL: `http://<tailscale-ip>:18789`
3. Enter Gateway Token: (from your Clawdbot config)
4. Save Configuration
### 4. Grant Permissions
The app needs several permissions:
1. **Notification Access** — System settings, enable ClawdNode
2. **Call Screening Role** — Become the call screener
3. **Runtime Permissions**:
- Phone state
- Call log
- Answer calls
- Record audio (for STT)
- Contacts (for caller ID)
### 5. Test Connection
- Status should show "✓ Connected to Gateway"
- Send a test notification to your phone
- Check Gateway logs for the notification event
## Voice Flow Example
```
1. Call comes in from unknown number
2. ClawdNode sends: {"type": "call_incoming", "number": "+1234567890", "contact": null}
3. Claude decides to answer and screen
4. Gateway sends: {"cmd": "call_answer", "greeting": "Hi, this is Johan's assistant. Who's calling?"}
5. ClawdNode answers, plays TTS greeting
6. Caller speaks: "Hi, I'm calling from Dr. Smith's office about tomorrow's appointment"
7. ClawdNode sends: {"type": "call_audio", "transcript": "Hi, I'm calling from Dr. Smith's office..."}
8. Claude processes, decides to confirm details
9. Gateway sends: {"cmd": "call_speak", "text": "Thank you for calling. Can you confirm the time?"}
10. ... conversation continues ...
11. Gateway sends: {"cmd": "call_hangup"}
12. Claude sends summary to Johan via Signal
```
## Project Structure
```
app/src/main/java/com/inou/clawdnode/
├── ClawdNodeApp.kt # Application class, initialization
├── service/
│ ├── NodeService.kt # Foreground service, command routing
│ └── GatewayClient.kt # WebSocket connection
├── notifications/
│ └── NotificationListener.kt # Notification capture & actions
├── calls/
│ ├── CallScreener.kt # Call screening service
│ └── VoiceCallService.kt # InCallService for voice interaction
├── security/
│ ├── TokenStore.kt # Encrypted credential storage
│ └── AuditLog.kt # Local audit trail
├── protocol/
│ └── Messages.kt # Event/Command data classes
└── ui/
└── MainActivity.kt # Setup UI
```
## Requirements
- Android 10+ (API 29) — required for CallScreeningService
- Tailscale installed and connected
- Clawdbot Gateway running and accessible
## Security Notes
- Gateway connection is via Tailscale mesh network only
- Credentials are stored using Android's EncryptedSharedPreferences
- All actions are logged locally with timestamps
- No data leaves your network (except to Gateway)
## License
MIT — Use freely, contribute back.

View File

@ -4,15 +4,15 @@ plugins {
} }
android { android {
namespace = "com.inou.clawdnode" namespace = "com.inou.moltmobile"
compileSdk = 34 compileSdk = 34
defaultConfig { defaultConfig {
applicationId = "com.inou.clawdnode" applicationId = "com.inou.moltmobile"
minSdk = 29 // Android 10+ for CallScreeningService minSdk = 29 // Android 10+ for CallScreeningService
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 1
versionName = "0.1.0" versionName = "0.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@ -30,14 +30,23 @@
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" <uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
tools:ignore="ProtectedPermissions" /> tools:ignore="ProtectedPermissions" />
<!-- SUPER ATTENTION MODE -->
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FLASHLIGHT" />
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<!-- For camera flash -->
<uses-feature android:name="android.hardware.camera.flash" android:required="false" />
<application <application
android:name=".ClawdNodeApp" android:name=".MoltMobileApp"
android:allowBackup="false" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.ClawdNode" android:theme="@style/Theme.MoltMobile"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="34"> tools:targetApi="34">
@ -45,13 +54,28 @@
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.ClawdNode"> android:theme="@style/Theme.MoltMobile">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Remote Browser Activity -->
<activity
android:name=".browser.RemoteBrowserActivity"
android:exported="false"
android:theme="@style/Theme.MoltMobile" />
<!-- Super Attention Activity (full screen alert) -->
<activity
android:name=".attention.SuperAttentionActivity"
android:exported="false"
android:theme="@style/Theme.MoltMobile.FullScreen"
android:showOnLockScreen="true"
android:turnScreenOn="true"
android:launchMode="singleTop" />
<!-- Core Foreground Service --> <!-- Core Foreground Service -->
<service <service
android:name=".service.NodeService" android:name=".service.NodeService"
@ -59,7 +83,7 @@
android:foregroundServiceType="specialUse"> android:foregroundServiceType="specialUse">
<property <property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="AI phone assistant connecting to personal gateway" /> android:value="AI mobile assistant - MoltMobile" />
</service> </service>
<!-- Notification Listener Service --> <!-- Notification Listener Service -->

View File

@ -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<String>
) {
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<String, Any?> = 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<String, Any?>) {
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
}

View File

@ -1,23 +1,23 @@
package com.inou.clawdnode package com.inou.moltmobile
import android.app.Application import android.app.Application
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.os.Build import android.os.Build
import com.inou.clawdnode.debug.DebugClient import com.inou.moltmobile.debug.DebugClient
import com.inou.clawdnode.gateway.DirectGateway import com.inou.moltmobile.gateway.DirectGateway
import com.inou.clawdnode.security.AuditLog import com.inou.moltmobile.security.AuditLog
import com.inou.clawdnode.security.DeviceIdentity import com.inou.moltmobile.security.DeviceIdentity
import com.inou.clawdnode.security.TokenStore import com.inou.moltmobile.security.TokenStore
import com.inou.clawdnode.service.NotificationManager as AppNotificationManager import com.inou.moltmobile.service.NotificationManager as AppNotificationManager
/** /**
* ClawdNode Application * MoltMobile Application
* *
* AI-powered phone assistant that connects to Clawdbot Gateway. * AI-powered phone assistant that connects to Clawdbot Gateway.
* Enables Claude to answer calls, screen notifications, and act on your behalf. * Enables Claude to answer calls, screen notifications, and act on your behalf.
*/ */
class ClawdNodeApp : Application() { class MoltMobileApp : Application() {
lateinit var tokenStore: TokenStore lateinit var tokenStore: TokenStore
private set private set
@ -36,10 +36,10 @@ class ClawdNodeApp : Application() {
// Create notification channels // Create notification channels
createNotificationChannels() 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 // 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( DebugClient.log("App initialized", mapOf(
"gatewayUrl" to (tokenStore.gatewayUrl ?: "not set"), "gatewayUrl" to (tokenStore.gatewayUrl ?: "not set"),
"hasToken" to (tokenStore.gatewayToken != null) "hasToken" to (tokenStore.gatewayToken != null)
@ -71,6 +71,9 @@ class ClawdNodeApp : Application() {
// TODO: Implement call hangup // TODO: Implement call hangup
} }
// Initialize with context (for AudioManager)
DirectGateway.initialize(this)
// Connect // Connect
DirectGateway.connect() DirectGateway.connect()
DebugClient.lifecycle("DIRECT_GATEWAY_SETUP", "Command handlers registered, connecting...") DebugClient.lifecycle("DIRECT_GATEWAY_SETUP", "Command handlers registered, connecting...")
@ -85,7 +88,7 @@ class ClawdNodeApp : Application() {
"Connection Status", "Connection Status",
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW
).apply { ).apply {
description = "Shows ClawdNode connection status" description = "Shows MoltMobile connection status"
setShowBadge(false) setShowBadge(false)
} }
@ -111,11 +114,11 @@ class ClawdNodeApp : Application() {
} }
companion object { companion object {
const val CHANNEL_SERVICE = "clawdnode_service" const val CHANNEL_SERVICE = "moltmobile_service"
const val CHANNEL_ALERTS = "clawdnode_alerts" const val CHANNEL_ALERTS = "moltmobile_alerts"
const val CHANNEL_CALLS = "clawdnode_calls" const val CHANNEL_CALLS = "moltmobile_calls"
lateinit var instance: ClawdNodeApp lateinit var instance: MoltMobileApp
private set private set
} }
} }

View File

@ -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
}
}

View File

@ -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<Byte>()
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<Byte>()
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
}
}

View File

@ -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()
}
}
}

View File

@ -1,4 +1,4 @@
package com.inou.clawdnode.calls package com.inou.moltmobile.calls
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
@ -8,11 +8,11 @@ import android.os.IBinder
import android.telecom.Call import android.telecom.Call
import android.telecom.CallScreeningService import android.telecom.CallScreeningService
import android.util.Log import android.util.Log
import com.inou.clawdnode.ClawdNodeApp import com.inou.moltmobile.MoltMobileApp
import com.inou.clawdnode.debug.DebugClient import com.inou.moltmobile.debug.DebugClient
import com.inou.clawdnode.gateway.DirectGateway import com.inou.moltmobile.gateway.DirectGateway
import com.inou.clawdnode.protocol.CallIncomingEvent import com.inou.moltmobile.protocol.CallIncomingEvent
import com.inou.clawdnode.service.NodeService import com.inou.moltmobile.service.NodeService
/** /**
* Screens incoming calls before they ring. * Screens incoming calls before they ring.
@ -84,7 +84,7 @@ class CallScreener : CallScreeningService() {
DebugClient.error("Failed to send call event", e) DebugClient.error("Failed to send call event", e)
} }
ClawdNodeApp.instance.auditLog.logCall( MoltMobileApp.instance.auditLog.logCall(
"CALL_INCOMING", "CALL_INCOMING",
number, number,
contactName, contactName,

View File

@ -1,4 +1,4 @@
package com.inou.clawdnode.calls package com.inou.moltmobile.calls
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
@ -16,11 +16,11 @@ import android.telecom.Call
import android.telecom.InCallService import android.telecom.InCallService
import android.telecom.VideoProfile import android.telecom.VideoProfile
import android.util.Log import android.util.Log
import com.inou.clawdnode.ClawdNodeApp import com.inou.moltmobile.MoltMobileApp
import com.inou.clawdnode.protocol.CallAudioEvent import com.inou.moltmobile.protocol.CallAudioEvent
import com.inou.clawdnode.protocol.CallEndedEvent import com.inou.moltmobile.protocol.CallEndedEvent
import com.inou.clawdnode.service.CallManager import com.inou.moltmobile.service.CallManager
import com.inou.clawdnode.service.NodeService import com.inou.moltmobile.service.NodeService
import java.util.* import java.util.*
/** /**
@ -148,7 +148,7 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener {
) )
nodeService?.sendEvent(event) nodeService?.sendEvent(event)
ClawdNodeApp.instance.auditLog.logCall( MoltMobileApp.instance.auditLog.logCall(
"CALL_ENDED", "CALL_ENDED",
call.details.handle?.schemeSpecificPart, call.details.handle?.schemeSpecificPart,
null, null,
@ -171,7 +171,7 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener {
Log.i(tag, "Answering call: $callId") Log.i(tag, "Answering call: $callId")
call.answer(VideoProfile.STATE_AUDIO_ONLY) call.answer(VideoProfile.STATE_AUDIO_ONLY)
ClawdNodeApp.instance.auditLog.logCall( MoltMobileApp.instance.auditLog.logCall(
"CALL_ANSWERED", "CALL_ANSWERED",
call.details.handle?.schemeSpecificPart, call.details.handle?.schemeSpecificPart,
null, null,
@ -192,7 +192,7 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener {
Log.i(tag, "Rejecting call: $callId") Log.i(tag, "Rejecting call: $callId")
call.reject(false, null) call.reject(false, null)
ClawdNodeApp.instance.auditLog.logCall( MoltMobileApp.instance.auditLog.logCall(
"CALL_REJECTED", "CALL_REJECTED",
call.details.handle?.schemeSpecificPart, call.details.handle?.schemeSpecificPart,
null, null,
@ -239,7 +239,7 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener {
} }
}) })
ClawdNodeApp.instance.auditLog.log( MoltMobileApp.instance.auditLog.log(
"CALL_SPEAK", "CALL_SPEAK",
"TTS: $text", "TTS: $text",
mapOf("call_id" to callId) mapOf("call_id" to callId)

View File

@ -1,4 +1,4 @@
package com.inou.clawdnode.debug package com.inou.moltmobile.debug
import android.util.Log import android.util.Log
import kotlinx.coroutines.* import kotlinx.coroutines.*

View File

@ -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<String, Any?> = 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<String>
) {
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<String, Any?> = 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<String, Any?>) {
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()
}
}

View File

@ -1,4 +1,4 @@
package com.inou.clawdnode.notifications package com.inou.moltmobile.notifications
import android.app.Notification import android.app.Notification
import android.app.RemoteInput import android.app.RemoteInput
@ -11,12 +11,12 @@ import android.os.IBinder
import android.service.notification.NotificationListenerService import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification import android.service.notification.StatusBarNotification
import android.util.Log import android.util.Log
import com.inou.clawdnode.ClawdNodeApp import com.inou.moltmobile.MoltMobileApp
import com.inou.clawdnode.debug.DebugClient import com.inou.moltmobile.debug.DebugClient
import com.inou.clawdnode.gateway.DirectGateway import com.inou.moltmobile.gateway.DirectGateway
import com.inou.clawdnode.protocol.NotificationEvent import com.inou.moltmobile.protocol.NotificationEvent
import com.inou.clawdnode.service.NodeService import com.inou.moltmobile.service.NodeService
import com.inou.clawdnode.service.NotificationManager import com.inou.moltmobile.service.NotificationManager
/** /**
* Listens to all notifications and forwards them to Gateway. * Listens to all notifications and forwards them to Gateway.
@ -160,7 +160,7 @@ class NotificationListener : NotificationListenerService() {
// Also log to local audit // Also log to local audit
try { try {
ClawdNodeApp.instance.auditLog.logNotification( MoltMobileApp.instance.auditLog.logNotification(
"NOTIFICATION_POSTED", "NOTIFICATION_POSTED",
sbn.packageName, sbn.packageName,
title title
@ -235,7 +235,7 @@ class NotificationListener : NotificationListenerService() {
"isReply" to (replyText != null) "isReply" to (replyText != null)
)) ))
ClawdNodeApp.instance.auditLog.log( MoltMobileApp.instance.auditLog.log(
"NOTIFICATION_ACTION", "NOTIFICATION_ACTION",
"Triggered action: $actionTitle", "Triggered action: $actionTitle",
mapOf("notification_id" to notificationId, "reply" to (replyText != null)) mapOf("notification_id" to notificationId, "reply" to (replyText != null))

View File

@ -1,4 +1,4 @@
package com.inou.clawdnode.protocol package com.inou.moltmobile.protocol
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonObject import com.google.gson.JsonObject
@ -6,7 +6,7 @@ import com.google.gson.JsonParser
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
/** /**
* Protocol messages between ClawdNode and Gateway. * Protocol messages between MoltMobile and Gateway.
* *
* Gateway Protocol v3: * Gateway Protocol v3:
* - Connect to /ws endpoint * - Connect to /ws endpoint

View File

@ -1,4 +1,4 @@
package com.inou.clawdnode.security package com.inou.moltmobile.security
import android.content.Context import android.content.Context
import com.google.gson.Gson import com.google.gson.Gson
@ -9,7 +9,7 @@ import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue 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. * Security-first: every action is logged with timestamp.
*/ */
class AuditLog(private val context: Context) { class AuditLog(private val context: Context) {

View File

@ -1,4 +1,4 @@
package com.inou.clawdnode.security package com.inou.moltmobile.security
import android.content.Context import android.content.Context
import android.util.Base64 import android.util.Base64
@ -25,7 +25,7 @@ import java.security.SecureRandom
class DeviceIdentity(context: Context) { class DeviceIdentity(context: Context) {
private val tag = "DeviceIdentity" private val tag = "DeviceIdentity"
private val prefsName = "clawdnode_device_identity" private val prefsName = "moltmobile_device_identity"
private val keyPrivate = "private_key" private val keyPrivate = "private_key"
private val keyPublic = "public_key" private val keyPublic = "public_key"
@ -74,7 +74,7 @@ class DeviceIdentity(context: Context) {
* v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce * v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
* *
* @param nonce Challenge nonce from gateway * @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 clientMode Client mode ("node")
* @param role Role ("node") * @param role Role ("node")
* @param scopes Comma-separated scopes (empty for nodes) * @param scopes Comma-separated scopes (empty for nodes)

View File

@ -1,4 +1,4 @@
package com.inou.clawdnode.security package com.inou.moltmobile.security
import android.content.Context import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
@ -16,7 +16,7 @@ class TokenStore(context: Context) {
private val prefs = EncryptedSharedPreferences.create( private val prefs = EncryptedSharedPreferences.create(
context, context,
"clawdnode_secure_prefs", "moltmobile_secure_prefs",
masterKey, masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM

View File

@ -1,13 +1,13 @@
package com.inou.clawdnode.service package com.inou.moltmobile.service
import android.util.Log import android.util.Log
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonObject import com.google.gson.JsonObject
import com.inou.clawdnode.BuildConfig import com.inou.moltmobile.BuildConfig
import com.inou.clawdnode.ClawdNodeApp import com.inou.moltmobile.MoltMobileApp
import com.inou.clawdnode.protocol.Protocol import com.inou.moltmobile.protocol.Protocol
import com.inou.clawdnode.protocol.* import com.inou.moltmobile.protocol.*
import com.inou.clawdnode.security.DeviceIdentity import com.inou.moltmobile.security.DeviceIdentity
import kotlinx.coroutines.* import kotlinx.coroutines.*
import okhttp3.* import okhttp3.*
import java.util.UUID import java.util.UUID
@ -62,10 +62,10 @@ class GatewayClient(
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val requestIdCounter = AtomicInteger(0) private val requestIdCounter = AtomicInteger(0)
private val auditLog get() = ClawdNodeApp.instance.auditLog private val auditLog get() = MoltMobileApp.instance.auditLog
private val tokenStore get() = ClawdNodeApp.instance.tokenStore 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 // Node capabilities
private val caps = listOf("notifications", "calls", "voice") private val caps = listOf("notifications", "calls", "voice")
@ -266,7 +266,7 @@ class GatewayClient(
"calls.speak" to true "calls.speak" to true
), ),
auth = AuthInfo(token = token), auth = AuthInfo(token = token),
userAgent = "clawdnode-android/${getAppVersion()}", userAgent = "moltmobile-android/${getAppVersion()}",
device = DeviceInfo( device = DeviceInfo(
id = deviceId, id = deviceId,
publicKey = publicKey, publicKey = publicKey,

View File

@ -1,4 +1,4 @@
package com.inou.clawdnode.service package com.inou.moltmobile.service
import android.app.Notification import android.app.Notification
import android.app.PendingIntent import android.app.PendingIntent
@ -8,14 +8,14 @@ import android.os.Binder
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.inou.clawdnode.ClawdNodeApp import com.inou.moltmobile.MoltMobileApp
import com.inou.clawdnode.R import com.inou.moltmobile.R
import com.inou.clawdnode.protocol.* import com.inou.moltmobile.protocol.*
import com.inou.clawdnode.ui.MainActivity import com.inou.moltmobile.ui.MainActivity
/** /**
* Main foreground service that maintains Gateway connection * Main foreground service that maintains Gateway connection
* and coordinates all ClawdNode functionality. * and coordinates all MoltMobile functionality.
*/ */
class NodeService : Service() { class NodeService : Service() {
@ -58,7 +58,7 @@ class NodeService : Service() {
startForeground(NOTIFICATION_ID, createNotification()) startForeground(NOTIFICATION_ID, createNotification())
// Connect to gateway // Connect to gateway
if (ClawdNodeApp.instance.tokenStore.isConfigured) { if (MoltMobileApp.instance.tokenStore.isConfigured) {
gatewayClient.connect() gatewayClient.connect()
} else { } else {
Log.w(tag, "Gateway not configured, waiting for setup") Log.w(tag, "Gateway not configured, waiting for setup")
@ -70,7 +70,7 @@ class NodeService : Service() {
override fun onDestroy() { override fun onDestroy() {
Log.i(tag, "NodeService destroyed") Log.i(tag, "NodeService destroyed")
gatewayClient.disconnect() gatewayClient.disconnect()
ClawdNodeApp.instance.auditLog.log("SERVICE_STOP", "NodeService destroyed") MoltMobileApp.instance.auditLog.log("SERVICE_STOP", "NodeService destroyed")
super.onDestroy() super.onDestroy()
} }
@ -189,8 +189,8 @@ class NodeService : Service() {
val status = if (isConnected) "Connected to Gateway" else "Disconnected" val status = if (isConnected) "Connected to Gateway" else "Disconnected"
return NotificationCompat.Builder(this, ClawdNodeApp.CHANNEL_SERVICE) return NotificationCompat.Builder(this, MoltMobileApp.CHANNEL_SERVICE)
.setContentTitle("ClawdNode") .setContentTitle("MoltMobile")
.setContentText(status) .setContentText(status)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setOngoing(true) .setOngoing(true)
@ -215,9 +215,9 @@ class NodeService : Service() {
* These get populated by the respective listener services. * These get populated by the respective listener services.
*/ */
object NotificationManager { 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 this.listener = listener
} }
@ -231,9 +231,9 @@ object NotificationManager {
} }
object CallManager { 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 this.callService = service
} }

View File

@ -1,4 +1,4 @@
package com.inou.clawdnode.ui package com.inou.moltmobile.ui
import android.Manifest import android.Manifest
import android.app.role.RoleManager import android.app.role.RoleManager
@ -16,9 +16,9 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.inou.clawdnode.ClawdNodeApp import com.inou.moltmobile.MoltMobileApp
import com.inou.clawdnode.databinding.ActivityMainBinding import com.inou.moltmobile.databinding.ActivityMainBinding
import com.inou.clawdnode.service.NodeService import com.inou.moltmobile.service.NodeService
/** /**
* Main setup and status activity. * Main setup and status activity.
@ -95,7 +95,7 @@ class MainActivity : AppCompatActivity() {
} }
// Load existing config // Load existing config
val tokenStore = ClawdNodeApp.instance.tokenStore val tokenStore = MoltMobileApp.instance.tokenStore
binding.etGatewayUrl.setText(tokenStore.gatewayUrl ?: "") binding.etGatewayUrl.setText(tokenStore.gatewayUrl ?: "")
binding.etGatewayToken.setText(tokenStore.gatewayToken ?: "") binding.etGatewayToken.setText(tokenStore.gatewayToken ?: "")
@ -149,11 +149,11 @@ class MainActivity : AppCompatActivity() {
return return
} }
val tokenStore = ClawdNodeApp.instance.tokenStore val tokenStore = MoltMobileApp.instance.tokenStore
tokenStore.gatewayUrl = url tokenStore.gatewayUrl = url
tokenStore.gatewayToken = token tokenStore.gatewayToken = token
ClawdNodeApp.instance.auditLog.log( MoltMobileApp.instance.auditLog.log(
"CONFIG_SAVED", "CONFIG_SAVED",
"Gateway configuration updated", "Gateway configuration updated",
mapOf("url" to url) mapOf("url" to url)
@ -245,7 +245,7 @@ class MainActivity : AppCompatActivity() {
} }
private fun showAuditLog() { private fun showAuditLog() {
val entries = ClawdNodeApp.instance.auditLog.getRecentEntries(50) val entries = MoltMobileApp.instance.auditLog.getRecentEntries(50)
val text = entries.joinToString("\n\n") { entry -> val text = entries.joinToString("\n\n") { entry ->
"${entry.timestamp}\n${entry.action}: ${entry.details}" "${entry.timestamp}\n${entry.action}: ${entry.details}"
} }

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">ClawdNode</string> <string name="app_name">MoltMobile</string>
<string name="notification_channel_service">MoltMobile Service</string>
<string name="notification_channel_alerts">MoltMobile Alerts</string>
</resources> </resources>

View File

@ -1,10 +1,21 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.ClawdNode" parent="Theme.Material3.Light.NoActionBar"> <style name="Theme.MoltMobile" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">#B45309</item> <item name="colorPrimary">#B45309</item>
<item name="colorPrimaryDark">#92400E</item> <item name="colorPrimaryDark">#92400E</item>
<item name="colorAccent">#B45309</item> <item name="colorAccent">#B45309</item>
<item name="android:statusBarColor">#F8F7F6</item> <item name="android:statusBarColor">#F8F7F6</item>
<item name="android:windowLightStatusBar">true</item> <item name="android:windowLightStatusBar">true</item>
</style> </style>
<!-- Fullscreen theme for SUPER ATTENTION MODE -->
<style name="Theme.MoltMobile.FullScreen" parent="Theme.Material3.Light.NoActionBar">
<item name="android:windowFullscreen">true</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowContentOverlay">@null</item>
<item name="colorPrimary">#DC2626</item>
<item name="colorPrimaryDark">#B91C1C</item>
<item name="android:statusBarColor">#DC2626</item>
</style>
</resources> </resources>

View File

@ -13,5 +13,5 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "ClawdNode" rootProject.name = "MoltMobile"
include(":app") include(":app")