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:
parent
b8d1705c9c
commit
9d308bc35c
237
README.md
237
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
|
## 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.
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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.*
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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}"
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,5 @@ dependencyResolutionManagement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "ClawdNode"
|
rootProject.name = "MoltMobile"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue