Compare commits
10 Commits
94f1da3ff1
...
9d308bc35c
| Author | SHA1 | Date |
|---|---|---|
|
|
9d308bc35c | |
|
|
b8d1705c9c | |
|
|
4462bf4cdf | |
|
|
e3b68c9c21 | |
|
|
e0835e0626 | |
|
|
5eb13b01b5 | |
|
|
a1e94f559f | |
|
|
1f58f36470 | |
|
|
5b140362bf | |
|
|
661a668169 |
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"
|
||||||
}
|
}
|
||||||
|
|
@ -54,8 +54,8 @@ dependencies {
|
||||||
// Security - encrypted storage
|
// Security - encrypted storage
|
||||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
// Ed25519 signatures (pure Java, works on all Android versions)
|
// Ed25519 signatures via Bouncy Castle (reliable, widely tested)
|
||||||
implementation("net.i2p.crypto:eddsa:0.3.0")
|
implementation("org.bouncycastle:bcprov-jdk18on:1.77")
|
||||||
|
|
||||||
// Networking - WebSocket
|
// Networking - WebSocket
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,24 @@
|
||||||
<!-- Notifications (bind to notification listener) -->
|
<!-- Notifications (bind to notification listener) -->
|
||||||
<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,12 +54,27 @@
|
||||||
<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
|
||||||
|
|
@ -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,81 +0,0 @@
|
||||||
package com.inou.clawdnode
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.os.Build
|
|
||||||
import com.inou.clawdnode.security.AuditLog
|
|
||||||
import com.inou.clawdnode.security.DeviceIdentity
|
|
||||||
import com.inou.clawdnode.security.TokenStore
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ClawdNode Application
|
|
||||||
*
|
|
||||||
* AI-powered phone assistant that connects to Clawdbot Gateway.
|
|
||||||
* Enables Claude to answer calls, screen notifications, and act on your behalf.
|
|
||||||
*/
|
|
||||||
class ClawdNodeApp : Application() {
|
|
||||||
|
|
||||||
lateinit var tokenStore: TokenStore
|
|
||||||
private set
|
|
||||||
|
|
||||||
lateinit var auditLog: AuditLog
|
|
||||||
private set
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
instance = this
|
|
||||||
|
|
||||||
// Initialize security components
|
|
||||||
tokenStore = TokenStore(this)
|
|
||||||
auditLog = AuditLog(this)
|
|
||||||
|
|
||||||
// Create notification channels
|
|
||||||
createNotificationChannels()
|
|
||||||
|
|
||||||
auditLog.log("APP_START", "ClawdNode v0.1.0 started")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationChannels() {
|
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
|
||||||
|
|
||||||
// Foreground service channel (persistent)
|
|
||||||
val serviceChannel = NotificationChannel(
|
|
||||||
CHANNEL_SERVICE,
|
|
||||||
"Connection Status",
|
|
||||||
NotificationManager.IMPORTANCE_LOW
|
|
||||||
).apply {
|
|
||||||
description = "Shows ClawdNode connection status"
|
|
||||||
setShowBadge(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alerts channel (for important events)
|
|
||||||
val alertChannel = NotificationChannel(
|
|
||||||
CHANNEL_ALERTS,
|
|
||||||
"Alerts",
|
|
||||||
NotificationManager.IMPORTANCE_HIGH
|
|
||||||
).apply {
|
|
||||||
description = "Important events requiring attention"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call events channel
|
|
||||||
val callChannel = NotificationChannel(
|
|
||||||
CHANNEL_CALLS,
|
|
||||||
"Call Activity",
|
|
||||||
NotificationManager.IMPORTANCE_DEFAULT
|
|
||||||
).apply {
|
|
||||||
description = "Call screening and voice interaction events"
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.createNotificationChannels(listOf(serviceChannel, alertChannel, callChannel))
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val CHANNEL_SERVICE = "clawdnode_service"
|
|
||||||
const val CHANNEL_ALERTS = "clawdnode_alerts"
|
|
||||||
const val CHANNEL_CALLS = "clawdnode_calls"
|
|
||||||
|
|
||||||
lateinit var instance: ClawdNodeApp
|
|
||||||
private set
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
package com.inou.clawdnode.notifications
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.RemoteInput
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.ServiceConnection
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.service.notification.NotificationListenerService
|
|
||||||
import android.service.notification.StatusBarNotification
|
|
||||||
import android.util.Log
|
|
||||||
import com.inou.clawdnode.ClawdNodeApp
|
|
||||||
import com.inou.clawdnode.protocol.NotificationEvent
|
|
||||||
import com.inou.clawdnode.service.NodeService
|
|
||||||
import com.inou.clawdnode.service.NotificationManager
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listens to all notifications and forwards them to Gateway.
|
|
||||||
* Can also trigger notification actions (reply, dismiss, etc.)
|
|
||||||
*/
|
|
||||||
class NotificationListener : NotificationListenerService() {
|
|
||||||
|
|
||||||
private val tag = "NotificationListener"
|
|
||||||
|
|
||||||
// Track notifications for action triggering
|
|
||||||
private val activeNotifications = mutableMapOf<String, StatusBarNotification>()
|
|
||||||
|
|
||||||
private var nodeService: NodeService? = null
|
|
||||||
private val serviceConnection = object : ServiceConnection {
|
|
||||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
|
||||||
nodeService = (service as NodeService.LocalBinder).getService()
|
|
||||||
}
|
|
||||||
override fun onServiceDisconnected(name: ComponentName?) {
|
|
||||||
nodeService = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
Log.i(tag, "NotificationListener created")
|
|
||||||
NotificationManager.register(this)
|
|
||||||
|
|
||||||
// Bind to NodeService
|
|
||||||
Intent(this, NodeService::class.java).also { intent ->
|
|
||||||
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
Log.i(tag, "NotificationListener destroyed")
|
|
||||||
unbindService(serviceConnection)
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
|
||||||
// Skip our own notifications
|
|
||||||
if (sbn.packageName == packageName) return
|
|
||||||
|
|
||||||
// Skip ongoing/persistent notifications (media players, etc.)
|
|
||||||
if (sbn.isOngoing && !isImportantOngoing(sbn)) return
|
|
||||||
|
|
||||||
Log.d(tag, "Notification posted: ${sbn.packageName}")
|
|
||||||
|
|
||||||
val notificationId = generateNotificationId(sbn)
|
|
||||||
activeNotifications[notificationId] = sbn
|
|
||||||
|
|
||||||
val event = createNotificationEvent(sbn, notificationId)
|
|
||||||
nodeService?.sendEvent(event)
|
|
||||||
|
|
||||||
ClawdNodeApp.instance.auditLog.logNotification(
|
|
||||||
"NOTIFICATION_POSTED",
|
|
||||||
sbn.packageName,
|
|
||||||
event.title
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNotificationRemoved(sbn: StatusBarNotification) {
|
|
||||||
val notificationId = generateNotificationId(sbn)
|
|
||||||
activeNotifications.remove(notificationId)
|
|
||||||
Log.d(tag, "Notification removed: ${sbn.packageName}")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ACTION TRIGGERING
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
fun triggerAction(notificationId: String, actionTitle: String, replyText: String?) {
|
|
||||||
val sbn = activeNotifications[notificationId] ?: run {
|
|
||||||
Log.w(tag, "Notification not found: $notificationId")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val notification = sbn.notification
|
|
||||||
val actions = notification.actions ?: return
|
|
||||||
|
|
||||||
val action = actions.find { it.title.toString().equals(actionTitle, ignoreCase = true) }
|
|
||||||
if (action == null) {
|
|
||||||
Log.w(tag, "Action not found: $actionTitle")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (replyText != null && action.remoteInputs?.isNotEmpty() == true) {
|
|
||||||
// This is a reply action
|
|
||||||
sendReply(action, replyText)
|
|
||||||
} else {
|
|
||||||
// Regular action
|
|
||||||
action.actionIntent.send()
|
|
||||||
}
|
|
||||||
|
|
||||||
ClawdNodeApp.instance.auditLog.log(
|
|
||||||
"NOTIFICATION_ACTION",
|
|
||||||
"Triggered action: $actionTitle",
|
|
||||||
mapOf("notification_id" to notificationId, "reply" to (replyText != null))
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(tag, "Failed to trigger action", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissNotification(notificationId: String) {
|
|
||||||
val sbn = activeNotifications[notificationId] ?: return
|
|
||||||
cancelNotification(sbn.key)
|
|
||||||
|
|
||||||
ClawdNodeApp.instance.auditLog.log(
|
|
||||||
"NOTIFICATION_DISMISS",
|
|
||||||
"Dismissed notification",
|
|
||||||
mapOf("notification_id" to notificationId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendReply(action: Notification.Action, text: String) {
|
|
||||||
val remoteInput = action.remoteInputs?.firstOrNull() ?: return
|
|
||||||
|
|
||||||
val intent = Intent()
|
|
||||||
val bundle = Bundle()
|
|
||||||
bundle.putCharSequence(remoteInput.resultKey, text)
|
|
||||||
RemoteInput.addResultsToIntent(arrayOf(remoteInput), intent, bundle)
|
|
||||||
|
|
||||||
action.actionIntent.send(this, 0, intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// HELPERS
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
private fun generateNotificationId(sbn: StatusBarNotification): String {
|
|
||||||
return "${sbn.packageName}:${sbn.id}:${sbn.postTime}"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationEvent(sbn: StatusBarNotification, id: String): NotificationEvent {
|
|
||||||
val notification = sbn.notification
|
|
||||||
val extras = notification.extras
|
|
||||||
|
|
||||||
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
|
|
||||||
val text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()
|
|
||||||
?: extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString()
|
|
||||||
|
|
||||||
val actions = notification.actions?.map { it.title.toString() } ?: emptyList()
|
|
||||||
|
|
||||||
val appName = try {
|
|
||||||
val appInfo = packageManager.getApplicationInfo(sbn.packageName, 0)
|
|
||||||
packageManager.getApplicationLabel(appInfo).toString()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
sbn.packageName
|
|
||||||
}
|
|
||||||
|
|
||||||
return NotificationEvent(
|
|
||||||
id = id,
|
|
||||||
app = appName,
|
|
||||||
packageName = sbn.packageName,
|
|
||||||
title = title,
|
|
||||||
text = text,
|
|
||||||
actions = actions,
|
|
||||||
timestamp = sbn.postTime
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isImportantOngoing(sbn: StatusBarNotification): Boolean {
|
|
||||||
// Whitelist certain ongoing notifications we care about
|
|
||||||
val importantPackages = setOf(
|
|
||||||
"com.whatsapp",
|
|
||||||
"org.telegram.messenger",
|
|
||||||
"com.google.android.apps.messaging"
|
|
||||||
)
|
|
||||||
return sbn.packageName in importantPackages
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
package com.inou.moltmobile
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.os.Build
|
||||||
|
import com.inou.moltmobile.debug.DebugClient
|
||||||
|
import com.inou.moltmobile.gateway.DirectGateway
|
||||||
|
import com.inou.moltmobile.security.AuditLog
|
||||||
|
import com.inou.moltmobile.security.DeviceIdentity
|
||||||
|
import com.inou.moltmobile.security.TokenStore
|
||||||
|
import com.inou.moltmobile.service.NotificationManager as AppNotificationManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MoltMobile Application
|
||||||
|
*
|
||||||
|
* AI-powered phone assistant that connects to Clawdbot Gateway.
|
||||||
|
* Enables Claude to answer calls, screen notifications, and act on your behalf.
|
||||||
|
*/
|
||||||
|
class MoltMobileApp : Application() {
|
||||||
|
|
||||||
|
lateinit var tokenStore: TokenStore
|
||||||
|
private set
|
||||||
|
|
||||||
|
lateinit var auditLog: AuditLog
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
instance = this
|
||||||
|
|
||||||
|
// Initialize security components
|
||||||
|
tokenStore = TokenStore(this)
|
||||||
|
auditLog = AuditLog(this)
|
||||||
|
|
||||||
|
// Create notification channels
|
||||||
|
createNotificationChannels()
|
||||||
|
|
||||||
|
auditLog.log("APP_START", "MoltMobile v0.2.0 started")
|
||||||
|
|
||||||
|
// Initialize debug client and log startup
|
||||||
|
DebugClient.lifecycle("APP_CREATE", "MoltMobile v0.2.0 started")
|
||||||
|
DebugClient.log("App initialized", mapOf(
|
||||||
|
"gatewayUrl" to (tokenStore.gatewayUrl ?: "not set"),
|
||||||
|
"hasToken" to (tokenStore.gatewayToken != null)
|
||||||
|
))
|
||||||
|
|
||||||
|
// Connect to our DirectGateway (bidirectional WebSocket)
|
||||||
|
setupDirectGateway()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupDirectGateway() {
|
||||||
|
// Set up command handlers
|
||||||
|
DirectGateway.onNotificationAction = { notificationId, action, replyText ->
|
||||||
|
auditLog.log("COMMAND_RECEIVED", "notification.action: $action on $notificationId")
|
||||||
|
AppNotificationManager.triggerAction(notificationId, action, replyText)
|
||||||
|
}
|
||||||
|
|
||||||
|
DirectGateway.onCallAnswer = { callId ->
|
||||||
|
auditLog.log("COMMAND_RECEIVED", "call.answer: $callId")
|
||||||
|
// TODO: Implement call answering via TelecomManager
|
||||||
|
}
|
||||||
|
|
||||||
|
DirectGateway.onCallReject = { callId ->
|
||||||
|
auditLog.log("COMMAND_RECEIVED", "call.reject: $callId")
|
||||||
|
// TODO: Implement call rejection
|
||||||
|
}
|
||||||
|
|
||||||
|
DirectGateway.onCallHangup = { callId ->
|
||||||
|
auditLog.log("COMMAND_RECEIVED", "call.hangup: $callId")
|
||||||
|
// TODO: Implement call hangup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize with context (for AudioManager)
|
||||||
|
DirectGateway.initialize(this)
|
||||||
|
|
||||||
|
// Connect
|
||||||
|
DirectGateway.connect()
|
||||||
|
DebugClient.lifecycle("DIRECT_GATEWAY_SETUP", "Command handlers registered, connecting...")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannels() {
|
||||||
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
|
||||||
|
// Foreground service channel (persistent)
|
||||||
|
val serviceChannel = NotificationChannel(
|
||||||
|
CHANNEL_SERVICE,
|
||||||
|
"Connection Status",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Shows MoltMobile connection status"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alerts channel (for important events)
|
||||||
|
val alertChannel = NotificationChannel(
|
||||||
|
CHANNEL_ALERTS,
|
||||||
|
"Alerts",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = "Important events requiring attention"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call events channel
|
||||||
|
val callChannel = NotificationChannel(
|
||||||
|
CHANNEL_CALLS,
|
||||||
|
"Call Activity",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply {
|
||||||
|
description = "Call screening and voice interaction events"
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.createNotificationChannels(listOf(serviceChannel, alertChannel, callChannel))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL_SERVICE = "moltmobile_service"
|
||||||
|
const val CHANNEL_ALERTS = "moltmobile_alerts"
|
||||||
|
const val CHANNEL_CALLS = "moltmobile_calls"
|
||||||
|
|
||||||
|
lateinit var instance: MoltMobileApp
|
||||||
|
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,9 +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.protocol.CallIncomingEvent
|
import com.inou.moltmobile.debug.DebugClient
|
||||||
import com.inou.clawdnode.service.NodeService
|
import com.inou.moltmobile.gateway.DirectGateway
|
||||||
|
import com.inou.moltmobile.protocol.CallIncomingEvent
|
||||||
|
import com.inou.moltmobile.service.NodeService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Screens incoming calls before they ring.
|
* Screens incoming calls before they ring.
|
||||||
|
|
@ -33,9 +35,11 @@ class CallScreener : CallScreeningService() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Log.i(tag, "CallScreener created")
|
Log.i(tag, "CallScreener created")
|
||||||
|
DebugClient.lifecycle("CALL_SCREENER_CREATE", "Service started")
|
||||||
|
|
||||||
Intent(this, NodeService::class.java).also { intent ->
|
Intent(this, NodeService::class.java).also { intent ->
|
||||||
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
val bound = bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
||||||
|
DebugClient.lifecycle("CALL_SCREENER", "bindService returned: $bound")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,21 +54,37 @@ class CallScreener : CallScreeningService() {
|
||||||
|
|
||||||
Log.i(tag, "Screening call from: $number")
|
Log.i(tag, "Screening call from: $number")
|
||||||
|
|
||||||
|
// POST to debug server immediately
|
||||||
|
DebugClient.call(
|
||||||
|
callId = callId,
|
||||||
|
number = number,
|
||||||
|
contact = null,
|
||||||
|
state = "incoming"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send via DirectGateway
|
||||||
|
DirectGateway.sendCall(callId, number, null, "incoming")
|
||||||
|
|
||||||
// Look up contact name
|
// Look up contact name
|
||||||
val contactName = lookupContact(number)
|
val contactName = lookupContact(number)
|
||||||
|
|
||||||
// Store call details for later action
|
// Store call details for later action
|
||||||
ActiveCalls.add(callId, callDetails)
|
ActiveCalls.add(callId, callDetails)
|
||||||
|
|
||||||
// Send event to Gateway
|
// Send event to Gateway via WebSocket
|
||||||
val event = CallIncomingEvent(
|
try {
|
||||||
callId = callId,
|
val event = CallIncomingEvent(
|
||||||
number = number,
|
callId = callId,
|
||||||
contact = contactName
|
number = number,
|
||||||
)
|
contact = contactName
|
||||||
nodeService?.sendEvent(event)
|
)
|
||||||
|
nodeService?.sendEvent(event)
|
||||||
|
DebugClient.log("Call event sent to gateway", mapOf("callId" to callId))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
DebugClient.error("Failed to send call event", e)
|
||||||
|
}
|
||||||
|
|
||||||
ClawdNodeApp.instance.auditLog.logCall(
|
MoltMobileApp.instance.auditLog.logCall(
|
||||||
"CALL_INCOMING",
|
"CALL_INCOMING",
|
||||||
number,
|
number,
|
||||||
contactName,
|
contactName,
|
||||||
|
|
@ -80,6 +100,8 @@ class CallScreener : CallScreeningService() {
|
||||||
.setSkipNotification(false)
|
.setSkipNotification(false)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DebugClient.call(callId, number, contactName, "allowed_to_ring")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupContact(number: String): String? {
|
private fun lookupContact(number: String): String? {
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
package com.inou.moltmobile.debug
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import okhttp3.*
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct HTTP client for debugging.
|
||||||
|
* Posts events directly to the debug server, bypassing WebSocket complexity.
|
||||||
|
*/
|
||||||
|
object DebugClient {
|
||||||
|
private const val TAG = "DebugClient"
|
||||||
|
|
||||||
|
// Debug server URL - Tailscale IP of james server
|
||||||
|
private const val DEBUG_SERVER = "http://100.123.216.65:9876"
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.readTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
private val JSON = "application/json".toMediaType()
|
||||||
|
|
||||||
|
var enabled = true
|
||||||
|
|
||||||
|
fun log(message: String, data: Map<String, Any?> = emptyMap()) {
|
||||||
|
post("/log", mapOf("message" to message) + data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun error(message: String, throwable: Throwable? = null, data: Map<String, Any?> = emptyMap()) {
|
||||||
|
post("/error", mapOf(
|
||||||
|
"message" to message,
|
||||||
|
"error" to (throwable?.message ?: ""),
|
||||||
|
"stack" to (throwable?.stackTraceToString()?.take(500) ?: "")
|
||||||
|
) + data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lifecycle(event: String, message: String = "") {
|
||||||
|
post("/lifecycle", mapOf("event" to event, "message" to message))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notification(
|
||||||
|
id: String,
|
||||||
|
app: String,
|
||||||
|
packageName: String,
|
||||||
|
title: String?,
|
||||||
|
text: String?,
|
||||||
|
actions: List<String> = emptyList()
|
||||||
|
) {
|
||||||
|
post("/notification", mapOf(
|
||||||
|
"id" to id,
|
||||||
|
"app" to app,
|
||||||
|
"packageName" to packageName,
|
||||||
|
"title" to title,
|
||||||
|
"text" to text,
|
||||||
|
"actions" to actions,
|
||||||
|
"timestamp" to System.currentTimeMillis()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun call(
|
||||||
|
callId: String,
|
||||||
|
number: String?,
|
||||||
|
contact: String?,
|
||||||
|
state: String
|
||||||
|
) {
|
||||||
|
post("/call", mapOf(
|
||||||
|
"callId" to callId,
|
||||||
|
"number" to number,
|
||||||
|
"contact" to contact,
|
||||||
|
"state" to state,
|
||||||
|
"timestamp" to System.currentTimeMillis()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun event(type: String, data: Map<String, Any?> = emptyMap()) {
|
||||||
|
post("/event", mapOf("type" to type) + data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(endpoint: String, data: Map<String, Any?>) {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val json = JSONObject(data).toString()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$DEBUG_SERVER$endpoint")
|
||||||
|
.post(json.toRequestBody(JSON))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
Log.w(TAG, "Debug post failed: ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// Silently fail - debug server might not be running
|
||||||
|
Log.d(TAG, "Debug post failed: ${e.message}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Debug post error", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun testConnection(callback: (Boolean) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$DEBUG_SERVER/health")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
callback(response.isSuccessful)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
callback(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,294 @@
|
||||||
|
package com.inou.moltmobile.notifications
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.RemoteInput
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.service.notification.NotificationListenerService
|
||||||
|
import android.service.notification.StatusBarNotification
|
||||||
|
import android.util.Log
|
||||||
|
import com.inou.moltmobile.MoltMobileApp
|
||||||
|
import com.inou.moltmobile.debug.DebugClient
|
||||||
|
import com.inou.moltmobile.gateway.DirectGateway
|
||||||
|
import com.inou.moltmobile.protocol.NotificationEvent
|
||||||
|
import com.inou.moltmobile.service.NodeService
|
||||||
|
import com.inou.moltmobile.service.NotificationManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens to all notifications and forwards them to Gateway.
|
||||||
|
* Also posts directly to debug server for visibility.
|
||||||
|
*/
|
||||||
|
class NotificationListener : NotificationListenerService() {
|
||||||
|
|
||||||
|
private val tag = "NotificationListener"
|
||||||
|
|
||||||
|
// Track notifications for action triggering
|
||||||
|
private val activeNotifications = mutableMapOf<String, StatusBarNotification>()
|
||||||
|
|
||||||
|
private var nodeService: NodeService? = null
|
||||||
|
private var serviceConnected = false
|
||||||
|
|
||||||
|
private val serviceConnection = object : ServiceConnection {
|
||||||
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
|
Log.i(tag, "NodeService connected")
|
||||||
|
DebugClient.lifecycle("NOTIFICATION_LISTENER", "NodeService bound successfully")
|
||||||
|
nodeService = (service as NodeService.LocalBinder).getService()
|
||||||
|
serviceConnected = true
|
||||||
|
}
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
Log.i(tag, "NodeService disconnected")
|
||||||
|
DebugClient.lifecycle("NOTIFICATION_LISTENER", "NodeService disconnected")
|
||||||
|
nodeService = null
|
||||||
|
serviceConnected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Log.i(tag, "NotificationListener created")
|
||||||
|
DebugClient.lifecycle("NOTIFICATION_LISTENER_CREATE", "Service onCreate called")
|
||||||
|
|
||||||
|
try {
|
||||||
|
NotificationManager.register(this)
|
||||||
|
DebugClient.lifecycle("NOTIFICATION_LISTENER", "Registered with NotificationManager")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Failed to register with NotificationManager", e)
|
||||||
|
DebugClient.error("NotificationManager registration failed", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind to NodeService
|
||||||
|
try {
|
||||||
|
Intent(this, NodeService::class.java).also { intent ->
|
||||||
|
val bound = bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
||||||
|
Log.i(tag, "bindService returned: $bound")
|
||||||
|
DebugClient.lifecycle("NOTIFICATION_LISTENER", "bindService returned: $bound")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Failed to bind to NodeService", e)
|
||||||
|
DebugClient.error("NodeService bind failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onListenerConnected() {
|
||||||
|
super.onListenerConnected()
|
||||||
|
Log.i(tag, "NotificationListener connected to system")
|
||||||
|
DebugClient.lifecycle("NOTIFICATION_LISTENER_CONNECTED", "System has connected us to notification stream")
|
||||||
|
|
||||||
|
// Log existing notifications
|
||||||
|
try {
|
||||||
|
val existing = activeNotifications.size
|
||||||
|
DebugClient.log("Listener connected, ${existing} active notifications tracked")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
DebugClient.error("Error in onListenerConnected", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onListenerDisconnected() {
|
||||||
|
super.onListenerDisconnected()
|
||||||
|
Log.w(tag, "NotificationListener disconnected from system")
|
||||||
|
DebugClient.lifecycle("NOTIFICATION_LISTENER_DISCONNECTED", "System has disconnected us from notification stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
Log.i(tag, "NotificationListener destroyed")
|
||||||
|
DebugClient.lifecycle("NOTIFICATION_LISTENER_DESTROY", "Service onDestroy called")
|
||||||
|
try {
|
||||||
|
unbindService(serviceConnection)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
||||||
|
// ALWAYS log to debug server first
|
||||||
|
Log.i(tag, "onNotificationPosted: ${sbn.packageName}")
|
||||||
|
DebugClient.log("onNotificationPosted called", mapOf(
|
||||||
|
"package" to sbn.packageName,
|
||||||
|
"id" to sbn.id,
|
||||||
|
"key" to sbn.key,
|
||||||
|
"isOngoing" to sbn.isOngoing
|
||||||
|
))
|
||||||
|
|
||||||
|
// Skip our own notifications
|
||||||
|
if (sbn.packageName == packageName) {
|
||||||
|
DebugClient.log("Skipping own notification")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip ongoing/persistent notifications (media players, etc.)
|
||||||
|
if (sbn.isOngoing && !isImportantOngoing(sbn)) {
|
||||||
|
DebugClient.log("Skipping ongoing notification", mapOf("package" to sbn.packageName))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val notificationId = generateNotificationId(sbn)
|
||||||
|
activeNotifications[notificationId] = sbn
|
||||||
|
|
||||||
|
// Extract notification data
|
||||||
|
val notification = sbn.notification
|
||||||
|
val extras = notification.extras
|
||||||
|
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
|
||||||
|
val text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()
|
||||||
|
?: extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString()
|
||||||
|
val actions = notification.actions?.map { it.title.toString() } ?: emptyList()
|
||||||
|
val appName = getAppName(sbn.packageName)
|
||||||
|
|
||||||
|
// POST directly to debug server
|
||||||
|
DebugClient.notification(
|
||||||
|
id = notificationId,
|
||||||
|
app = appName,
|
||||||
|
packageName = sbn.packageName,
|
||||||
|
title = title,
|
||||||
|
text = text,
|
||||||
|
actions = actions
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send via DirectGateway (bidirectional WebSocket)
|
||||||
|
DirectGateway.sendNotification(
|
||||||
|
id = notificationId,
|
||||||
|
app = appName,
|
||||||
|
packageName = sbn.packageName,
|
||||||
|
title = title,
|
||||||
|
text = text,
|
||||||
|
actions = actions
|
||||||
|
)
|
||||||
|
|
||||||
|
// Also log to local audit
|
||||||
|
try {
|
||||||
|
MoltMobileApp.instance.auditLog.logNotification(
|
||||||
|
"NOTIFICATION_POSTED",
|
||||||
|
sbn.packageName,
|
||||||
|
title
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
DebugClient.error("Audit log failed", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send via WebSocket if connected
|
||||||
|
try {
|
||||||
|
val event = NotificationEvent(
|
||||||
|
id = notificationId,
|
||||||
|
app = appName,
|
||||||
|
packageName = sbn.packageName,
|
||||||
|
title = title,
|
||||||
|
text = text,
|
||||||
|
actions = actions,
|
||||||
|
timestamp = sbn.postTime
|
||||||
|
)
|
||||||
|
nodeService?.sendEvent(event)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
DebugClient.error("WebSocket send failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNotificationRemoved(sbn: StatusBarNotification) {
|
||||||
|
val notificationId = generateNotificationId(sbn)
|
||||||
|
activeNotifications.remove(notificationId)
|
||||||
|
DebugClient.log("Notification removed", mapOf(
|
||||||
|
"package" to sbn.packageName,
|
||||||
|
"id" to notificationId
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ACTION TRIGGERING
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
fun triggerAction(notificationId: String, actionTitle: String, replyText: String?) {
|
||||||
|
DebugClient.log("triggerAction called", mapOf(
|
||||||
|
"notificationId" to notificationId,
|
||||||
|
"action" to actionTitle,
|
||||||
|
"hasReply" to (replyText != null)
|
||||||
|
))
|
||||||
|
|
||||||
|
val sbn = activeNotifications[notificationId] ?: run {
|
||||||
|
Log.w(tag, "Notification not found: $notificationId")
|
||||||
|
DebugClient.error("Notification not found for action", null, mapOf("id" to notificationId))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val notification = sbn.notification
|
||||||
|
val actions = notification.actions ?: return
|
||||||
|
|
||||||
|
val action = actions.find { it.title.toString().equals(actionTitle, ignoreCase = true) }
|
||||||
|
if (action == null) {
|
||||||
|
Log.w(tag, "Action not found: $actionTitle")
|
||||||
|
DebugClient.error("Action not found", null, mapOf("action" to actionTitle))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (replyText != null && action.remoteInputs?.isNotEmpty() == true) {
|
||||||
|
sendReply(action, replyText)
|
||||||
|
} else {
|
||||||
|
action.actionIntent.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugClient.event("NOTIFICATION_ACTION_TRIGGERED", mapOf(
|
||||||
|
"notificationId" to notificationId,
|
||||||
|
"action" to actionTitle,
|
||||||
|
"isReply" to (replyText != null)
|
||||||
|
))
|
||||||
|
|
||||||
|
MoltMobileApp.instance.auditLog.log(
|
||||||
|
"NOTIFICATION_ACTION",
|
||||||
|
"Triggered action: $actionTitle",
|
||||||
|
mapOf("notification_id" to notificationId, "reply" to (replyText != null))
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Failed to trigger action", e)
|
||||||
|
DebugClient.error("Action trigger failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissNotification(notificationId: String) {
|
||||||
|
DebugClient.log("dismissNotification called", mapOf("id" to notificationId))
|
||||||
|
val sbn = activeNotifications[notificationId] ?: return
|
||||||
|
cancelNotification(sbn.key)
|
||||||
|
DebugClient.event("NOTIFICATION_DISMISSED", mapOf("id" to notificationId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendReply(action: Notification.Action, text: String) {
|
||||||
|
val remoteInput = action.remoteInputs?.firstOrNull() ?: return
|
||||||
|
|
||||||
|
val intent = Intent()
|
||||||
|
val bundle = Bundle()
|
||||||
|
bundle.putCharSequence(remoteInput.resultKey, text)
|
||||||
|
RemoteInput.addResultsToIntent(arrayOf(remoteInput), intent, bundle)
|
||||||
|
|
||||||
|
action.actionIntent.send(this, 0, intent)
|
||||||
|
DebugClient.event("NOTIFICATION_REPLY_SENT", mapOf("textLength" to text.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// HELPERS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
private fun generateNotificationId(sbn: StatusBarNotification): String {
|
||||||
|
return "${sbn.packageName}:${sbn.id}:${sbn.postTime}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAppName(packageName: String): String {
|
||||||
|
return try {
|
||||||
|
val appInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||||
|
packageManager.getApplicationLabel(appInfo).toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
packageName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isImportantOngoing(sbn: StatusBarNotification): Boolean {
|
||||||
|
val importantPackages = setOf(
|
||||||
|
"com.whatsapp",
|
||||||
|
"org.telegram.messenger",
|
||||||
|
"com.google.android.apps.messaging",
|
||||||
|
"org.thoughtcrime.securesms" // Signal
|
||||||
|
)
|
||||||
|
return sbn.packageName in importantPackages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,40 +1,34 @@
|
||||||
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
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import net.i2p.crypto.eddsa.EdDSAEngine
|
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||||
import net.i2p.crypto.eddsa.EdDSAPrivateKey
|
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||||
import net.i2p.crypto.eddsa.EdDSAPublicKey
|
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||||
import net.i2p.crypto.eddsa.KeyPairGenerator
|
|
||||||
import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
|
|
||||||
import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec
|
|
||||||
import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec
|
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages device identity using Ed25519 keys.
|
* Manages device identity using Ed25519 keys via Bouncy Castle.
|
||||||
*
|
*
|
||||||
* Keys are stored in EncryptedSharedPreferences for security.
|
* Keys are stored in EncryptedSharedPreferences for security.
|
||||||
* Uses pure-Java Ed25519 implementation for compatibility with all Android versions.
|
* Uses Bouncy Castle's Ed25519 implementation for compatibility.
|
||||||
*
|
*
|
||||||
* The key format matches the Clawdbot gateway protocol:
|
* The key format matches the Clawdbot gateway protocol:
|
||||||
* - Public key: 32 bytes raw, base64url-encoded
|
* - Public key: 32 bytes raw, base64url-encoded
|
||||||
* - Signature: 64 bytes raw, base64url-encoded
|
* - Signature: 64 bytes raw, base64url-encoded
|
||||||
* - Payload: "$nonce:$signedAt" (matching gateway's expected format)
|
* - Payload format (v2): "v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce"
|
||||||
*/
|
*/
|
||||||
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"
|
||||||
|
|
||||||
private val ed25519Spec = EdDSANamedCurveTable.getByName("Ed25519")
|
|
||||||
|
|
||||||
private val prefs by lazy {
|
private val prefs by lazy {
|
||||||
val masterKey = MasterKey.Builder(context)
|
val masterKey = MasterKey.Builder(context)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
|
@ -54,9 +48,8 @@ class DeviceIdentity(context: Context) {
|
||||||
*/
|
*/
|
||||||
val publicKey: String by lazy {
|
val publicKey: String by lazy {
|
||||||
ensureKeyPair()
|
ensureKeyPair()
|
||||||
val publicKeyBytes = prefs.getString(keyPublic, null)
|
prefs.getString(keyPublic, null)
|
||||||
?: throw IllegalStateException("No public key available")
|
?: throw IllegalStateException("No public key available")
|
||||||
publicKeyBytes // Already stored as base64url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -67,55 +60,87 @@ class DeviceIdentity(context: Context) {
|
||||||
val publicKeyBase64 = prefs.getString(keyPublic, null)
|
val publicKeyBase64 = prefs.getString(keyPublic, null)
|
||||||
?: throw IllegalStateException("No public key available")
|
?: throw IllegalStateException("No public key available")
|
||||||
|
|
||||||
// Decode the raw public key bytes
|
|
||||||
val publicKeyBytes = base64UrlDecode(publicKeyBase64)
|
val publicKeyBytes = base64UrlDecode(publicKeyBase64)
|
||||||
|
|
||||||
// Create fingerprint using SHA-256 of the raw public key bytes
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
val hash = digest.digest(publicKeyBytes)
|
val hash = digest.digest(publicKeyBytes)
|
||||||
|
|
||||||
// Return as hex string (full 32 bytes = 64 chars, matching gateway)
|
|
||||||
hash.joinToString("") { "%02x".format(it) }
|
hash.joinToString("") { "%02x".format(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign a challenge nonce for gateway authentication
|
* Sign a challenge nonce for gateway authentication.
|
||||||
*
|
*
|
||||||
* @param nonce The challenge nonce from the gateway
|
* Builds the payload in gateway's expected format:
|
||||||
* @return Signature and timestamp
|
* v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
|
||||||
|
*
|
||||||
|
* @param nonce Challenge nonce from gateway
|
||||||
|
* @param clientId Client identifier (e.g., "moltmobile-android")
|
||||||
|
* @param clientMode Client mode ("node")
|
||||||
|
* @param role Role ("node")
|
||||||
|
* @param scopes Comma-separated scopes (empty for nodes)
|
||||||
|
* @param token Auth token (gateway token or device token)
|
||||||
*/
|
*/
|
||||||
fun signChallenge(nonce: String): SignedChallenge {
|
fun signChallenge(
|
||||||
|
nonce: String,
|
||||||
|
clientId: String,
|
||||||
|
clientMode: String,
|
||||||
|
role: String,
|
||||||
|
scopes: String = "",
|
||||||
|
token: String = ""
|
||||||
|
): SignedChallenge {
|
||||||
ensureKeyPair()
|
ensureKeyPair()
|
||||||
|
|
||||||
val signedAt = System.currentTimeMillis()
|
val signedAt = System.currentTimeMillis()
|
||||||
val payload = "$nonce:$signedAt"
|
|
||||||
|
// Build payload in gateway's expected format:
|
||||||
|
// v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
|
||||||
|
val payload = listOf(
|
||||||
|
"v2",
|
||||||
|
deviceId,
|
||||||
|
clientId,
|
||||||
|
clientMode,
|
||||||
|
role,
|
||||||
|
scopes,
|
||||||
|
signedAt.toString(),
|
||||||
|
token,
|
||||||
|
nonce
|
||||||
|
).joinToString("|")
|
||||||
|
|
||||||
Log.d(tag, "Signing payload: $payload")
|
Log.d(tag, "Signing payload: $payload")
|
||||||
|
|
||||||
// Load private key
|
// Load private key (32-byte seed)
|
||||||
val privateKeyBase64 = prefs.getString(keyPrivate, null)
|
val privateKeyBase64 = prefs.getString(keyPrivate, null)
|
||||||
?: throw IllegalStateException("No private key available")
|
?: throw IllegalStateException("No private key available")
|
||||||
val privateKeyBytes = base64UrlDecode(privateKeyBase64)
|
val privateKeyBytes = base64UrlDecode(privateKeyBase64)
|
||||||
|
|
||||||
// Create EdDSA private key
|
// Create Bouncy Castle Ed25519 private key from seed
|
||||||
val privateKeySpec = EdDSAPrivateKeySpec(privateKeyBytes, ed25519Spec)
|
val privateKey = Ed25519PrivateKeyParameters(privateKeyBytes, 0)
|
||||||
val privateKey = EdDSAPrivateKey(privateKeySpec)
|
|
||||||
|
|
||||||
// Sign the payload using standard Ed25519 (not prehashed Ed25519ph)
|
// Verify derived public key matches stored
|
||||||
val signature = EdDSAEngine().apply {
|
val derivedPubKey = privateKey.generatePublicKey().encoded
|
||||||
initSign(privateKey)
|
val storedPubKeyBase64 = prefs.getString(keyPublic, null)
|
||||||
update(payload.toByteArray(Charsets.UTF_8))
|
val storedPubKey = storedPubKeyBase64?.let { base64UrlDecode(it) }
|
||||||
}
|
val keysMatch = derivedPubKey.contentEquals(storedPubKey)
|
||||||
|
|
||||||
|
Log.d(tag, "Keys match: $keysMatch")
|
||||||
|
|
||||||
|
// Sign using Ed25519
|
||||||
|
val signer = Ed25519Signer()
|
||||||
|
signer.init(true, privateKey)
|
||||||
|
signer.update(payload.toByteArray(Charsets.UTF_8), 0, payload.length)
|
||||||
|
val signatureBytes = signer.generateSignature()
|
||||||
|
|
||||||
val signatureBytes = signature.sign()
|
|
||||||
val signatureBase64 = base64UrlEncode(signatureBytes)
|
val signatureBase64 = base64UrlEncode(signatureBytes)
|
||||||
|
|
||||||
Log.d(tag, "Generated signature: ${signatureBase64.take(20)}... (${signatureBytes.size} bytes)")
|
Log.d(tag, "Generated signature: ${signatureBase64.take(20)}... (${signatureBytes.size} bytes)")
|
||||||
|
|
||||||
|
val debugInfo = "Keys match: $keysMatch | Stored: ${storedPubKeyBase64?.take(12)}... | Derived: ${base64UrlEncode(derivedPubKey).take(12)}..."
|
||||||
|
|
||||||
return SignedChallenge(
|
return SignedChallenge(
|
||||||
signature = signatureBase64,
|
signature = signatureBase64,
|
||||||
signedAt = signedAt,
|
signedAt = signedAt,
|
||||||
nonce = nonce
|
nonce = nonce,
|
||||||
|
debugInfo = debugInfo
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,29 +162,26 @@ class DeviceIdentity(context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a new Ed25519 keypair and store it
|
* Generate a new Ed25519 keypair using Bouncy Castle
|
||||||
*/
|
*/
|
||||||
private fun generateKeyPair() {
|
private fun generateKeyPair() {
|
||||||
Log.i(tag, "Generating new Ed25519 device keypair")
|
Log.i(tag, "Generating new Ed25519 device keypair (Bouncy Castle)")
|
||||||
|
|
||||||
val keyPairGenerator = KeyPairGenerator()
|
// Generate random 32-byte seed
|
||||||
keyPairGenerator.initialize(256, SecureRandom())
|
val seed = ByteArray(32)
|
||||||
val keyPair = keyPairGenerator.generateKeyPair()
|
SecureRandom().nextBytes(seed)
|
||||||
|
|
||||||
val privateKey = keyPair.private as EdDSAPrivateKey
|
// Create keypair from seed
|
||||||
val publicKey = keyPair.public as EdDSAPublicKey
|
val privateKey = Ed25519PrivateKeyParameters(seed, 0)
|
||||||
|
val publicKey = privateKey.generatePublicKey()
|
||||||
|
|
||||||
// Get raw 32-byte keys (matching gateway format)
|
val publicKeyBytes = publicKey.encoded
|
||||||
// For Ed25519, the "seed" is the 32-byte private key
|
|
||||||
val privateKeyBytes = privateKey.seed
|
|
||||||
// The "A" point is the 32-byte public key
|
|
||||||
val publicKeyBytes = publicKey.abyte
|
|
||||||
|
|
||||||
Log.d(tag, "Generated keypair: private=${privateKeyBytes.size} bytes, public=${publicKeyBytes.size} bytes")
|
Log.d(tag, "Generated keypair: seed=${seed.size} bytes, public=${publicKeyBytes.size} bytes")
|
||||||
|
|
||||||
// Store as base64url
|
// Store as base64url
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString(keyPrivate, base64UrlEncode(privateKeyBytes))
|
.putString(keyPrivate, base64UrlEncode(seed))
|
||||||
.putString(keyPublic, base64UrlEncode(publicKeyBytes))
|
.putString(keyPublic, base64UrlEncode(publicKeyBytes))
|
||||||
.apply()
|
.apply()
|
||||||
|
|
||||||
|
|
@ -177,16 +199,10 @@ class DeviceIdentity(context: Context) {
|
||||||
Log.i(tag, "Device keypair deleted")
|
Log.i(tag, "Device keypair deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Base64url encode (no padding, URL-safe)
|
|
||||||
*/
|
|
||||||
private fun base64UrlEncode(bytes: ByteArray): String {
|
private fun base64UrlEncode(bytes: ByteArray): String {
|
||||||
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Base64url decode (handles both padded and unpadded)
|
|
||||||
*/
|
|
||||||
private fun base64UrlDecode(input: String): ByteArray {
|
private fun base64UrlDecode(input: String): ByteArray {
|
||||||
return Base64.decode(input, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
return Base64.decode(input, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
||||||
}
|
}
|
||||||
|
|
@ -194,6 +210,7 @@ class DeviceIdentity(context: Context) {
|
||||||
data class SignedChallenge(
|
data class SignedChallenge(
|
||||||
val signature: String,
|
val signature: String,
|
||||||
val signedAt: Long,
|
val signedAt: Long,
|
||||||
val nonce: String
|
val nonce: String,
|
||||||
|
val debugInfo: String = ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -226,9 +226,19 @@ class GatewayClient(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
deviceId = deviceIdentity.deviceId
|
deviceId = deviceIdentity.deviceId
|
||||||
signedChallenge = deviceIdentity.signChallenge(nonce)
|
// Sign with full device auth payload:
|
||||||
|
// v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
|
||||||
|
signedChallenge = deviceIdentity.signChallenge(
|
||||||
|
nonce = nonce,
|
||||||
|
clientId = Protocol.CLIENT_ID,
|
||||||
|
clientMode = Protocol.MODE,
|
||||||
|
role = Protocol.ROLE,
|
||||||
|
scopes = "", // Nodes don't use scopes
|
||||||
|
token = token
|
||||||
|
)
|
||||||
publicKey = deviceIdentity.publicKey
|
publicKey = deviceIdentity.publicKey
|
||||||
log("Device identity ready: id=${deviceId.take(8)}..., signed challenge")
|
log("Device identity ready: id=${deviceId.take(8)}...")
|
||||||
|
log("DEBUG: ${signedChallenge.debugInfo}")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError("Failed to initialize device identity or sign challenge", e)
|
logError("Failed to initialize device identity or sign challenge", e)
|
||||||
// Cannot proceed without device identity for non-local connections
|
// Cannot proceed without device identity for non-local connections
|
||||||
|
|
@ -256,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