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
|
||||
- Captures all notifications from all apps
|
||||
- Forwards to Gateway: app name, title, text, available actions
|
||||
- Can trigger actions (Reply, Mark read, etc.) via Gateway commands
|
||||
- Can dismiss notifications remotely
|
||||
## Features
|
||||
|
||||
### Call Screening & Voice
|
||||
- Intercepts incoming calls before ring
|
||||
- Sends caller info to Gateway for Claude to decide
|
||||
- **Answer calls programmatically**
|
||||
- **Speak into calls via TTS** (Text-to-Speech)
|
||||
- **Listen to caller via STT** (Speech-to-Text)
|
||||
- Full voice conversation loop with Claude as the brain
|
||||
### 📞 Smart Call Handling
|
||||
- Answer/reject calls via gateway command
|
||||
- Spam detection and auto-response (TTS)
|
||||
- Known caller greeting
|
||||
- Call audio streaming (coming soon)
|
||||
|
||||
### Security
|
||||
- **Tailscale-only** — no public internet exposure
|
||||
- Encrypted credential storage (EncryptedSharedPreferences)
|
||||
- Local audit log of all actions
|
||||
- All permissions clearly explained
|
||||
### 🔔 Notification Relay
|
||||
- All notifications forwarded to Molt
|
||||
- Action execution (reply, dismiss, etc.)
|
||||
- Smart filtering
|
||||
|
||||
## Protocol
|
||||
### 🔊 Voice (MiniMax Integration)
|
||||
- TTS playback on device
|
||||
- Audio recording for STT (coming soon)
|
||||
- Real-time voice conversations (coming soon)
|
||||
|
||||
### Events (Phone → Gateway)
|
||||
### 🚨 SUPER ATTENTION MODE
|
||||
When Molt needs you NOW:
|
||||
- Volume MAX (bypasses DND)
|
||||
- Screen strobe (red/white)
|
||||
- Camera flash strobe
|
||||
- Continuous vibration
|
||||
- Alarm sound loop
|
||||
- Full-screen alert
|
||||
- Only stops when you acknowledge
|
||||
|
||||
```json
|
||||
// Notification received
|
||||
{"type": "notification", "id": "com.whatsapp:123:1706400000", "app": "WhatsApp", "package": "com.whatsapp", "title": "Mom", "text": "Call me when you can", "actions": ["Reply", "Mark read"]}
|
||||
### 🌐 Remote Browser
|
||||
Molt can control Chrome on your phone:
|
||||
- Open URLs
|
||||
- Execute JavaScript
|
||||
- Take screenshots
|
||||
- Navigate pages
|
||||
|
||||
// Incoming call
|
||||
{"type": "call_incoming", "call_id": "tel:+1234567890", "number": "+1234567890", "contact": "Mom"}
|
||||
## Architecture
|
||||
|
||||
// Caller speech (transcribed)
|
||||
{"type": "call_audio", "call_id": "tel:+1234567890", "transcript": "Hi, I'm calling about the appointment", "is_final": true}
|
||||
|
||||
// Call ended
|
||||
{"type": "call_ended", "call_id": "tel:+1234567890", "duration": 45, "outcome": "completed"}
|
||||
```
|
||||
|
||||
### Commands (Gateway → Phone)
|
||||
|
||||
```json
|
||||
// Take screenshot
|
||||
{"cmd": "screenshot"}
|
||||
|
||||
// Trigger notification action
|
||||
{"cmd": "notification_action", "id": "com.whatsapp:123:...", "action": "Reply", "text": "I'll call you back in 30 min"}
|
||||
|
||||
// Dismiss notification
|
||||
{"cmd": "notification_dismiss", "id": "com.whatsapp:123:..."}
|
||||
|
||||
// Answer incoming call with greeting
|
||||
{"cmd": "call_answer", "call_id": "tel:+1234567890", "greeting": "Hello, this is Johan's phone. Who's calling?"}
|
||||
|
||||
// Reject call
|
||||
{"cmd": "call_reject", "call_id": "tel:+1234567890"}
|
||||
|
||||
// Speak into active call
|
||||
{"cmd": "call_speak", "call_id": "tel:+1234567890", "text": "Thank you for calling. I'll let Johan know about the appointment."}
|
||||
|
||||
// Hang up
|
||||
{"cmd": "call_hangup", "call_id": "tel:+1234567890"}
|
||||
Your Phone (MoltMobile app)
|
||||
↓ WebSocket
|
||||
MoltMobile Gateway (server)
|
||||
↓
|
||||
MiniMax API (LLM + TTS)
|
||||
↓
|
||||
Molt/Clawdbot (escalation)
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Build the APK
|
||||
1. Install the app on your Android phone (Android 10+)
|
||||
2. Grant permissions (notifications, calls, microphone, camera)
|
||||
3. Ensure phone is on Tailscale network
|
||||
4. App auto-connects to gateway at `ws://100.123.216.65:9878`
|
||||
|
||||
## Commands (from Gateway)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `audio.play` | Play TTS audio (hex or base64) |
|
||||
| `audio.stop` | Stop playback |
|
||||
| `audio.volume.max` | Set volume to maximum |
|
||||
| `attention.super` | 🚨 SUPER ATTENTION MODE |
|
||||
| `attention.stop` | Stop attention mode |
|
||||
| `browser.open` | Open URL in remote browser |
|
||||
| `browser.navigate` | Navigate to URL |
|
||||
| `browser.js` | Execute JavaScript |
|
||||
| `browser.screenshot` | Capture screenshot |
|
||||
| `browser.close` | Close browser |
|
||||
| `call.answer` | Answer incoming call |
|
||||
| `call.reject` | Reject incoming call |
|
||||
| `call.hangup` | Hang up active call |
|
||||
| `notification.action` | Trigger notification action |
|
||||
|
||||
## Permissions Required
|
||||
|
||||
- **Internet** — Gateway connection
|
||||
- **Notifications** — Relay to Molt
|
||||
- **Phone/Calls** — Call handling
|
||||
- **Microphone** — Voice recording
|
||||
- **Camera** — Flash for attention mode
|
||||
- **Vibrate** — Attention mode
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Clone and build
|
||||
cd clawdnode-android
|
||||
cd moltmobile-android
|
||||
./gradlew assembleDebug
|
||||
|
||||
# APK will be at:
|
||||
# app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
Or open in Android Studio and build.
|
||||
APK will be at `app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
### 2. Install on Phone
|
||||
## Version History
|
||||
|
||||
```bash
|
||||
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
- **0.2.0** — Renamed from ClawdNode, added MiniMax audio, SUPER ATTENTION MODE, remote browser
|
||||
- **0.1.0** — Initial release (as ClawdNode)
|
||||
|
||||
Or transfer APK and install manually (enable "Unknown sources").
|
||||
---
|
||||
|
||||
### 3. Configure Gateway
|
||||
|
||||
1. Open ClawdNode app
|
||||
2. Enter Gateway URL: `http://<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.
|
||||
*MoltMobile — Molt in your pocket.*
|
||||
|
|
|
|||
|
|
@ -4,15 +4,15 @@ plugins {
|
|||
}
|
||||
|
||||
android {
|
||||
namespace = "com.inou.clawdnode"
|
||||
namespace = "com.inou.moltmobile"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.inou.clawdnode"
|
||||
applicationId = "com.inou.moltmobile"
|
||||
minSdk = 29 // Android 10+ for CallScreeningService
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionName = "0.2.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
|
@ -54,8 +54,8 @@ dependencies {
|
|||
// Security - encrypted storage
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// Ed25519 signatures (pure Java, works on all Android versions)
|
||||
implementation("net.i2p.crypto:eddsa:0.3.0")
|
||||
// Ed25519 signatures via Bouncy Castle (reliable, widely tested)
|
||||
implementation("org.bouncycastle:bcprov-jdk18on:1.77")
|
||||
|
||||
// Networking - WebSocket
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
|
|
|||
|
|
@ -30,14 +30,23 @@
|
|||
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
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
|
||||
android:name=".ClawdNodeApp"
|
||||
android:name=".MoltMobileApp"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ClawdNode"
|
||||
android:theme="@style/Theme.MoltMobile"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
tools:targetApi="34">
|
||||
|
||||
|
|
@ -45,13 +54,28 @@
|
|||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.ClawdNode">
|
||||
android:theme="@style/Theme.MoltMobile">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</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 -->
|
||||
<service
|
||||
android:name=".service.NodeService"
|
||||
|
|
@ -59,7 +83,7 @@
|
|||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
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>
|
||||
|
||||
<!-- 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.Context
|
||||
|
|
@ -8,9 +8,11 @@ import android.os.IBinder
|
|||
import android.telecom.Call
|
||||
import android.telecom.CallScreeningService
|
||||
import android.util.Log
|
||||
import com.inou.clawdnode.ClawdNodeApp
|
||||
import com.inou.clawdnode.protocol.CallIncomingEvent
|
||||
import com.inou.clawdnode.service.NodeService
|
||||
import com.inou.moltmobile.MoltMobileApp
|
||||
import com.inou.moltmobile.debug.DebugClient
|
||||
import com.inou.moltmobile.gateway.DirectGateway
|
||||
import com.inou.moltmobile.protocol.CallIncomingEvent
|
||||
import com.inou.moltmobile.service.NodeService
|
||||
|
||||
/**
|
||||
* Screens incoming calls before they ring.
|
||||
|
|
@ -33,9 +35,11 @@ class CallScreener : CallScreeningService() {
|
|||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(tag, "CallScreener created")
|
||||
DebugClient.lifecycle("CALL_SCREENER_CREATE", "Service started")
|
||||
|
||||
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")
|
||||
|
||||
// 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
|
||||
val contactName = lookupContact(number)
|
||||
|
||||
// Store call details for later action
|
||||
ActiveCalls.add(callId, callDetails)
|
||||
|
||||
// Send event to Gateway
|
||||
val event = CallIncomingEvent(
|
||||
callId = callId,
|
||||
number = number,
|
||||
contact = contactName
|
||||
)
|
||||
nodeService?.sendEvent(event)
|
||||
// Send event to Gateway via WebSocket
|
||||
try {
|
||||
val event = CallIncomingEvent(
|
||||
callId = callId,
|
||||
number = number,
|
||||
contact = contactName
|
||||
)
|
||||
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",
|
||||
number,
|
||||
contactName,
|
||||
|
|
@ -80,6 +100,8 @@ class CallScreener : CallScreeningService() {
|
|||
.setSkipNotification(false)
|
||||
.build()
|
||||
)
|
||||
|
||||
DebugClient.call(callId, number, contactName, "allowed_to_ring")
|
||||
}
|
||||
|
||||
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.Context
|
||||
|
|
@ -16,11 +16,11 @@ import android.telecom.Call
|
|||
import android.telecom.InCallService
|
||||
import android.telecom.VideoProfile
|
||||
import android.util.Log
|
||||
import com.inou.clawdnode.ClawdNodeApp
|
||||
import com.inou.clawdnode.protocol.CallAudioEvent
|
||||
import com.inou.clawdnode.protocol.CallEndedEvent
|
||||
import com.inou.clawdnode.service.CallManager
|
||||
import com.inou.clawdnode.service.NodeService
|
||||
import com.inou.moltmobile.MoltMobileApp
|
||||
import com.inou.moltmobile.protocol.CallAudioEvent
|
||||
import com.inou.moltmobile.protocol.CallEndedEvent
|
||||
import com.inou.moltmobile.service.CallManager
|
||||
import com.inou.moltmobile.service.NodeService
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
|
|
@ -148,7 +148,7 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener {
|
|||
)
|
||||
nodeService?.sendEvent(event)
|
||||
|
||||
ClawdNodeApp.instance.auditLog.logCall(
|
||||
MoltMobileApp.instance.auditLog.logCall(
|
||||
"CALL_ENDED",
|
||||
call.details.handle?.schemeSpecificPart,
|
||||
null,
|
||||
|
|
@ -171,7 +171,7 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener {
|
|||
Log.i(tag, "Answering call: $callId")
|
||||
call.answer(VideoProfile.STATE_AUDIO_ONLY)
|
||||
|
||||
ClawdNodeApp.instance.auditLog.logCall(
|
||||
MoltMobileApp.instance.auditLog.logCall(
|
||||
"CALL_ANSWERED",
|
||||
call.details.handle?.schemeSpecificPart,
|
||||
null,
|
||||
|
|
@ -192,7 +192,7 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener {
|
|||
Log.i(tag, "Rejecting call: $callId")
|
||||
call.reject(false, null)
|
||||
|
||||
ClawdNodeApp.instance.auditLog.logCall(
|
||||
MoltMobileApp.instance.auditLog.logCall(
|
||||
"CALL_REJECTED",
|
||||
call.details.handle?.schemeSpecificPart,
|
||||
null,
|
||||
|
|
@ -239,7 +239,7 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener {
|
|||
}
|
||||
})
|
||||
|
||||
ClawdNodeApp.instance.auditLog.log(
|
||||
MoltMobileApp.instance.auditLog.log(
|
||||
"CALL_SPEAK",
|
||||
"TTS: $text",
|
||||
mapOf("call_id" to callId)
|
||||
|
|
@ -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.JsonObject
|
||||
|
|
@ -6,7 +6,7 @@ import com.google.gson.JsonParser
|
|||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Protocol messages between ClawdNode and Gateway.
|
||||
* Protocol messages between MoltMobile and Gateway.
|
||||
*
|
||||
* Gateway Protocol v3:
|
||||
* - Connect to /ws endpoint
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.inou.clawdnode.security
|
||||
package com.inou.moltmobile.security
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.Gson
|
||||
|
|
@ -9,7 +9,7 @@ import java.util.*
|
|||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
/**
|
||||
* Local audit log for all ClawdNode actions.
|
||||
* Local audit log for all MoltMobile actions.
|
||||
* Security-first: every action is logged with timestamp.
|
||||
*/
|
||||
class AuditLog(private val context: Context) {
|
||||
|
|
@ -1,40 +1,34 @@
|
|||
package com.inou.clawdnode.security
|
||||
package com.inou.moltmobile.security
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import net.i2p.crypto.eddsa.EdDSAEngine
|
||||
import net.i2p.crypto.eddsa.EdDSAPrivateKey
|
||||
import net.i2p.crypto.eddsa.EdDSAPublicKey
|
||||
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 org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
import java.security.MessageDigest
|
||||
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.
|
||||
* 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:
|
||||
* - Public key: 32 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) {
|
||||
|
||||
private val tag = "DeviceIdentity"
|
||||
private val prefsName = "clawdnode_device_identity"
|
||||
private val prefsName = "moltmobile_device_identity"
|
||||
private val keyPrivate = "private_key"
|
||||
private val keyPublic = "public_key"
|
||||
|
||||
private val ed25519Spec = EdDSANamedCurveTable.getByName("Ed25519")
|
||||
|
||||
private val prefs by lazy {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
|
|
@ -54,9 +48,8 @@ class DeviceIdentity(context: Context) {
|
|||
*/
|
||||
val publicKey: String by lazy {
|
||||
ensureKeyPair()
|
||||
val publicKeyBytes = prefs.getString(keyPublic, null)
|
||||
prefs.getString(keyPublic, null)
|
||||
?: 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)
|
||||
?: throw IllegalStateException("No public key available")
|
||||
|
||||
// Decode the raw public key bytes
|
||||
val publicKeyBytes = base64UrlDecode(publicKeyBase64)
|
||||
|
||||
// Create fingerprint using SHA-256 of the raw public key bytes
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val hash = digest.digest(publicKeyBytes)
|
||||
|
||||
// Return as hex string (full 32 bytes = 64 chars, matching gateway)
|
||||
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
|
||||
* @return Signature and timestamp
|
||||
* Builds the payload in gateway's expected format:
|
||||
* 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()
|
||||
|
||||
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")
|
||||
|
||||
// Load private key
|
||||
// Load private key (32-byte seed)
|
||||
val privateKeyBase64 = prefs.getString(keyPrivate, null)
|
||||
?: throw IllegalStateException("No private key available")
|
||||
val privateKeyBytes = base64UrlDecode(privateKeyBase64)
|
||||
|
||||
// Create EdDSA private key
|
||||
val privateKeySpec = EdDSAPrivateKeySpec(privateKeyBytes, ed25519Spec)
|
||||
val privateKey = EdDSAPrivateKey(privateKeySpec)
|
||||
// Create Bouncy Castle Ed25519 private key from seed
|
||||
val privateKey = Ed25519PrivateKeyParameters(privateKeyBytes, 0)
|
||||
|
||||
// Sign the payload using standard Ed25519 (not prehashed Ed25519ph)
|
||||
val signature = EdDSAEngine().apply {
|
||||
initSign(privateKey)
|
||||
update(payload.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
// Verify derived public key matches stored
|
||||
val derivedPubKey = privateKey.generatePublicKey().encoded
|
||||
val storedPubKeyBase64 = prefs.getString(keyPublic, null)
|
||||
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)
|
||||
|
||||
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(
|
||||
signature = signatureBase64,
|
||||
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() {
|
||||
Log.i(tag, "Generating new Ed25519 device keypair")
|
||||
Log.i(tag, "Generating new Ed25519 device keypair (Bouncy Castle)")
|
||||
|
||||
val keyPairGenerator = KeyPairGenerator()
|
||||
keyPairGenerator.initialize(256, SecureRandom())
|
||||
val keyPair = keyPairGenerator.generateKeyPair()
|
||||
// Generate random 32-byte seed
|
||||
val seed = ByteArray(32)
|
||||
SecureRandom().nextBytes(seed)
|
||||
|
||||
val privateKey = keyPair.private as EdDSAPrivateKey
|
||||
val publicKey = keyPair.public as EdDSAPublicKey
|
||||
// Create keypair from seed
|
||||
val privateKey = Ed25519PrivateKeyParameters(seed, 0)
|
||||
val publicKey = privateKey.generatePublicKey()
|
||||
|
||||
// Get raw 32-byte keys (matching gateway format)
|
||||
// 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
|
||||
val publicKeyBytes = publicKey.encoded
|
||||
|
||||
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
|
||||
prefs.edit()
|
||||
.putString(keyPrivate, base64UrlEncode(privateKeyBytes))
|
||||
.putString(keyPrivate, base64UrlEncode(seed))
|
||||
.putString(keyPublic, base64UrlEncode(publicKeyBytes))
|
||||
.apply()
|
||||
|
||||
|
|
@ -177,16 +199,10 @@ class DeviceIdentity(context: Context) {
|
|||
Log.i(tag, "Device keypair deleted")
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64url encode (no padding, URL-safe)
|
||||
*/
|
||||
private fun base64UrlEncode(bytes: ByteArray): String {
|
||||
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 {
|
||||
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(
|
||||
val signature: String,
|
||||
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 androidx.security.crypto.EncryptedSharedPreferences
|
||||
|
|
@ -16,7 +16,7 @@ class TokenStore(context: Context) {
|
|||
|
||||
private val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"clawdnode_secure_prefs",
|
||||
"moltmobile_secure_prefs",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
package com.inou.clawdnode.service
|
||||
package com.inou.moltmobile.service
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
import com.inou.clawdnode.BuildConfig
|
||||
import com.inou.clawdnode.ClawdNodeApp
|
||||
import com.inou.clawdnode.protocol.Protocol
|
||||
import com.inou.clawdnode.protocol.*
|
||||
import com.inou.clawdnode.security.DeviceIdentity
|
||||
import com.inou.moltmobile.BuildConfig
|
||||
import com.inou.moltmobile.MoltMobileApp
|
||||
import com.inou.moltmobile.protocol.Protocol
|
||||
import com.inou.moltmobile.protocol.*
|
||||
import com.inou.moltmobile.security.DeviceIdentity
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.*
|
||||
import java.util.UUID
|
||||
|
|
@ -62,10 +62,10 @@ class GatewayClient(
|
|||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val requestIdCounter = AtomicInteger(0)
|
||||
|
||||
private val auditLog get() = ClawdNodeApp.instance.auditLog
|
||||
private val tokenStore get() = ClawdNodeApp.instance.tokenStore
|
||||
private val auditLog get() = MoltMobileApp.instance.auditLog
|
||||
private val tokenStore get() = MoltMobileApp.instance.tokenStore
|
||||
|
||||
private val deviceIdentity by lazy { DeviceIdentity(ClawdNodeApp.instance) }
|
||||
private val deviceIdentity by lazy { DeviceIdentity(MoltMobileApp.instance) }
|
||||
|
||||
// Node capabilities
|
||||
private val caps = listOf("notifications", "calls", "voice")
|
||||
|
|
@ -226,9 +226,19 @@ class GatewayClient(
|
|||
|
||||
try {
|
||||
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
|
||||
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) {
|
||||
logError("Failed to initialize device identity or sign challenge", e)
|
||||
// Cannot proceed without device identity for non-local connections
|
||||
|
|
@ -256,7 +266,7 @@ class GatewayClient(
|
|||
"calls.speak" to true
|
||||
),
|
||||
auth = AuthInfo(token = token),
|
||||
userAgent = "clawdnode-android/${getAppVersion()}",
|
||||
userAgent = "moltmobile-android/${getAppVersion()}",
|
||||
device = DeviceInfo(
|
||||
id = deviceId,
|
||||
publicKey = publicKey,
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.inou.clawdnode.service
|
||||
package com.inou.moltmobile.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
|
|
@ -8,14 +8,14 @@ import android.os.Binder
|
|||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.inou.clawdnode.ClawdNodeApp
|
||||
import com.inou.clawdnode.R
|
||||
import com.inou.clawdnode.protocol.*
|
||||
import com.inou.clawdnode.ui.MainActivity
|
||||
import com.inou.moltmobile.MoltMobileApp
|
||||
import com.inou.moltmobile.R
|
||||
import com.inou.moltmobile.protocol.*
|
||||
import com.inou.moltmobile.ui.MainActivity
|
||||
|
||||
/**
|
||||
* Main foreground service that maintains Gateway connection
|
||||
* and coordinates all ClawdNode functionality.
|
||||
* and coordinates all MoltMobile functionality.
|
||||
*/
|
||||
class NodeService : Service() {
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ class NodeService : Service() {
|
|||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
|
||||
// Connect to gateway
|
||||
if (ClawdNodeApp.instance.tokenStore.isConfigured) {
|
||||
if (MoltMobileApp.instance.tokenStore.isConfigured) {
|
||||
gatewayClient.connect()
|
||||
} else {
|
||||
Log.w(tag, "Gateway not configured, waiting for setup")
|
||||
|
|
@ -70,7 +70,7 @@ class NodeService : Service() {
|
|||
override fun onDestroy() {
|
||||
Log.i(tag, "NodeService destroyed")
|
||||
gatewayClient.disconnect()
|
||||
ClawdNodeApp.instance.auditLog.log("SERVICE_STOP", "NodeService destroyed")
|
||||
MoltMobileApp.instance.auditLog.log("SERVICE_STOP", "NodeService destroyed")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
|
@ -189,8 +189,8 @@ class NodeService : Service() {
|
|||
|
||||
val status = if (isConnected) "Connected to Gateway" else "Disconnected"
|
||||
|
||||
return NotificationCompat.Builder(this, ClawdNodeApp.CHANNEL_SERVICE)
|
||||
.setContentTitle("ClawdNode")
|
||||
return NotificationCompat.Builder(this, MoltMobileApp.CHANNEL_SERVICE)
|
||||
.setContentTitle("MoltMobile")
|
||||
.setContentText(status)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setOngoing(true)
|
||||
|
|
@ -215,9 +215,9 @@ class NodeService : Service() {
|
|||
* These get populated by the respective listener services.
|
||||
*/
|
||||
object NotificationManager {
|
||||
private var listener: com.inou.clawdnode.notifications.NotificationListener? = null
|
||||
private var listener: com.inou.moltmobile.notifications.NotificationListener? = null
|
||||
|
||||
fun register(listener: com.inou.clawdnode.notifications.NotificationListener) {
|
||||
fun register(listener: com.inou.moltmobile.notifications.NotificationListener) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
|
|
@ -231,9 +231,9 @@ object NotificationManager {
|
|||
}
|
||||
|
||||
object CallManager {
|
||||
private var callService: com.inou.clawdnode.calls.VoiceCallService? = null
|
||||
private var callService: com.inou.moltmobile.calls.VoiceCallService? = null
|
||||
|
||||
fun register(service: com.inou.clawdnode.calls.VoiceCallService) {
|
||||
fun register(service: com.inou.moltmobile.calls.VoiceCallService) {
|
||||
this.callService = service
|
||||
}
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.inou.clawdnode.ui
|
||||
package com.inou.moltmobile.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.role.RoleManager
|
||||
|
|
@ -16,9 +16,9 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.inou.clawdnode.ClawdNodeApp
|
||||
import com.inou.clawdnode.databinding.ActivityMainBinding
|
||||
import com.inou.clawdnode.service.NodeService
|
||||
import com.inou.moltmobile.MoltMobileApp
|
||||
import com.inou.moltmobile.databinding.ActivityMainBinding
|
||||
import com.inou.moltmobile.service.NodeService
|
||||
|
||||
/**
|
||||
* Main setup and status activity.
|
||||
|
|
@ -95,7 +95,7 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
// Load existing config
|
||||
val tokenStore = ClawdNodeApp.instance.tokenStore
|
||||
val tokenStore = MoltMobileApp.instance.tokenStore
|
||||
binding.etGatewayUrl.setText(tokenStore.gatewayUrl ?: "")
|
||||
binding.etGatewayToken.setText(tokenStore.gatewayToken ?: "")
|
||||
|
||||
|
|
@ -149,11 +149,11 @@ class MainActivity : AppCompatActivity() {
|
|||
return
|
||||
}
|
||||
|
||||
val tokenStore = ClawdNodeApp.instance.tokenStore
|
||||
val tokenStore = MoltMobileApp.instance.tokenStore
|
||||
tokenStore.gatewayUrl = url
|
||||
tokenStore.gatewayToken = token
|
||||
|
||||
ClawdNodeApp.instance.auditLog.log(
|
||||
MoltMobileApp.instance.auditLog.log(
|
||||
"CONFIG_SAVED",
|
||||
"Gateway configuration updated",
|
||||
mapOf("url" to url)
|
||||
|
|
@ -245,7 +245,7 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
private fun showAuditLog() {
|
||||
val entries = ClawdNodeApp.instance.auditLog.getRecentEntries(50)
|
||||
val entries = MoltMobileApp.instance.auditLog.getRecentEntries(50)
|
||||
val text = entries.joinToString("\n\n") { entry ->
|
||||
"${entry.timestamp}\n${entry.action}: ${entry.details}"
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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="colorPrimaryDark">#92400E</item>
|
||||
<item name="colorAccent">#B45309</item>
|
||||
<item name="android:statusBarColor">#F8F7F6</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -13,5 +13,5 @@ dependencyResolutionManagement {
|
|||
}
|
||||
}
|
||||
|
||||
rootProject.name = "ClawdNode"
|
||||
rootProject.name = "MoltMobile"
|
||||
include(":app")
|
||||
|
|
|
|||
Loading…
Reference in New Issue