Compare commits

...

10 Commits

Author SHA1 Message Date
James (ClawdBot) 9d308bc35c Rename package from clawdnode to moltmobile
- Move all classes from com.inou.clawdnode to com.inou.moltmobile
- Update app name and package references
- Keep all existing functionality
2026-02-04 22:58:46 -05:00
James (ClawdBot) b8d1705c9c fix: NotificationManager is a Kotlin object, no getInstance needed 2026-01-28 22:27:19 +00:00
James (ClawdBot) 4462bf4cdf fix: Resolve NotificationManager import conflict
Use alias for our custom NotificationManager to avoid conflict with android.app.NotificationManager
2026-01-28 22:25:38 +00:00
James (ClawdBot) e3b68c9c21 feat: Add DirectGateway - our own WebSocket server
- DirectGateway.kt: bidirectional WebSocket to ws://100.123.216.65:9878
- No auth, no restrictions - full control
- Sends notifications and calls
- Receives commands: notification.action, call.answer/reject/hangup
- App sets up command handlers and auto-connects
- NotificationListener & CallScreener now send to both debug + gateway

Server: /home/johan/dev/clawdnode-gateway/server.js
HTTP API: http://100.123.216.65:9877
WebSocket: ws://100.123.216.65:9878
2026-01-28 21:55:50 +00:00
James (ClawdBot) e0835e0626 feat: Add direct HTTP debug logging
- Add DebugClient that POSTs directly to debug server (100.123.216.65:9876)
- NotificationListener: POST all events directly, full lifecycle logging
- CallScreener: POST all calls directly, full lifecycle logging
- App: Log startup and initialization
- Bypass WebSocket complexity for debugging visibility

Debug server: node /home/johan/dev/clawdnode-debug-server/server.js
Tail: tail -f /tmp/clawdnode-debug.log
2026-01-28 21:15:11 +00:00
James (ClawdBot) 5eb13b01b5 debug: Add early logging to NotificationListener
Log immediately when onNotificationPosted is called to diagnose
if the listener is firing at all.
2026-01-28 20:13:13 +00:00
James (ClawdBot) a1e94f559f fix: Use correct device auth payload format for signature
The signature payload was incorrect. Changed from:
  $nonce:$signedAt

To gateway's expected v2 format:
  v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce

This matches the buildDeviceAuthPayload() function in Clawdbot's
gateway/device-auth.js module.
2026-01-28 19:33:39 +00:00
James (ClawdBot) 1f58f36470 Switch from i2p/eddsa to Bouncy Castle for Ed25519
The i2p eddsa library produces non-standard Ed25519 signatures that
Node.js crypto doesn't accept. Bouncy Castle is more widely tested
and should produce standard-compliant signatures.
2026-01-28 19:25:57 +00:00
James (ClawdBot) 5b140362bf Show key debug info in visible Connection Log 2026-01-28 18:59:32 +00:00
James (ClawdBot) 661a668169 Add debug logging to verify public key derivation 2026-01-28 18:57:50 +00:00
24 changed files with 1990 additions and 553 deletions

237
README.md
View File

@ -1,173 +1,112 @@
# ClawdNode Android # MoltMobile 📱
AI-powered phone assistant that connects to Clawdbot Gateway. Enables Claude to answer calls, screen notifications, and act on your behalf. **Molt in your pocket** — The mobile extension for Molt AI assistant.
## Features (v0.1) MoltMobile gives Molt physical presence through your Android phone:
- 👀 See (camera, screenshots)
- 👂 Hear (microphone, call audio)
- 🗣️ Speak (TTS via MiniMax)
- 📍 Know where you are (location)
- 🔔 See your notifications
- 📞 Handle your calls
- 🚨 Get your attention when it matters
### Notification Interception ## Features
- Captures all notifications from all apps
- Forwards to Gateway: app name, title, text, available actions
- Can trigger actions (Reply, Mark read, etc.) via Gateway commands
- Can dismiss notifications remotely
### Call Screening & Voice ### 📞 Smart Call Handling
- Intercepts incoming calls before ring - Answer/reject calls via gateway command
- Sends caller info to Gateway for Claude to decide - Spam detection and auto-response (TTS)
- **Answer calls programmatically** - Known caller greeting
- **Speak into calls via TTS** (Text-to-Speech) - Call audio streaming (coming soon)
- **Listen to caller via STT** (Speech-to-Text)
- Full voice conversation loop with Claude as the brain
### Security ### 🔔 Notification Relay
- **Tailscale-only** — no public internet exposure - All notifications forwarded to Molt
- Encrypted credential storage (EncryptedSharedPreferences) - Action execution (reply, dismiss, etc.)
- Local audit log of all actions - Smart filtering
- All permissions clearly explained
## Protocol ### 🔊 Voice (MiniMax Integration)
- TTS playback on device
- Audio recording for STT (coming soon)
- Real-time voice conversations (coming soon)
### Events (Phone → Gateway) ### 🚨 SUPER ATTENTION MODE
When Molt needs you NOW:
- Volume MAX (bypasses DND)
- Screen strobe (red/white)
- Camera flash strobe
- Continuous vibration
- Alarm sound loop
- Full-screen alert
- Only stops when you acknowledge
```json ### 🌐 Remote Browser
// Notification received Molt can control Chrome on your phone:
{"type": "notification", "id": "com.whatsapp:123:1706400000", "app": "WhatsApp", "package": "com.whatsapp", "title": "Mom", "text": "Call me when you can", "actions": ["Reply", "Mark read"]} - Open URLs
- Execute JavaScript
- Take screenshots
- Navigate pages
// Incoming call ## Architecture
{"type": "call_incoming", "call_id": "tel:+1234567890", "number": "+1234567890", "contact": "Mom"}
// Caller speech (transcribed)
{"type": "call_audio", "call_id": "tel:+1234567890", "transcript": "Hi, I'm calling about the appointment", "is_final": true}
// Call ended
{"type": "call_ended", "call_id": "tel:+1234567890", "duration": 45, "outcome": "completed"}
``` ```
Your Phone (MoltMobile app)
### Commands (Gateway → Phone) ↓ WebSocket
MoltMobile Gateway (server)
```json
// Take screenshot MiniMax API (LLM + TTS)
{"cmd": "screenshot"}
Molt/Clawdbot (escalation)
// Trigger notification action
{"cmd": "notification_action", "id": "com.whatsapp:123:...", "action": "Reply", "text": "I'll call you back in 30 min"}
// Dismiss notification
{"cmd": "notification_dismiss", "id": "com.whatsapp:123:..."}
// Answer incoming call with greeting
{"cmd": "call_answer", "call_id": "tel:+1234567890", "greeting": "Hello, this is Johan's phone. Who's calling?"}
// Reject call
{"cmd": "call_reject", "call_id": "tel:+1234567890"}
// Speak into active call
{"cmd": "call_speak", "call_id": "tel:+1234567890", "text": "Thank you for calling. I'll let Johan know about the appointment."}
// Hang up
{"cmd": "call_hangup", "call_id": "tel:+1234567890"}
``` ```
## Setup ## Setup
### 1. Build the APK 1. Install the app on your Android phone (Android 10+)
2. Grant permissions (notifications, calls, microphone, camera)
3. Ensure phone is on Tailscale network
4. App auto-connects to gateway at `ws://100.123.216.65:9878`
## Commands (from Gateway)
| Command | Description |
|---------|-------------|
| `audio.play` | Play TTS audio (hex or base64) |
| `audio.stop` | Stop playback |
| `audio.volume.max` | Set volume to maximum |
| `attention.super` | 🚨 SUPER ATTENTION MODE |
| `attention.stop` | Stop attention mode |
| `browser.open` | Open URL in remote browser |
| `browser.navigate` | Navigate to URL |
| `browser.js` | Execute JavaScript |
| `browser.screenshot` | Capture screenshot |
| `browser.close` | Close browser |
| `call.answer` | Answer incoming call |
| `call.reject` | Reject incoming call |
| `call.hangup` | Hang up active call |
| `notification.action` | Trigger notification action |
## Permissions Required
- **Internet** — Gateway connection
- **Notifications** — Relay to Molt
- **Phone/Calls** — Call handling
- **Microphone** — Voice recording
- **Camera** — Flash for attention mode
- **Vibrate** — Attention mode
## Building
```bash ```bash
# Clone and build cd moltmobile-android
cd clawdnode-android
./gradlew assembleDebug ./gradlew assembleDebug
# APK will be at:
# app/build/outputs/apk/debug/app-debug.apk
``` ```
Or open in Android Studio and build. APK will be at `app/build/outputs/apk/debug/app-debug.apk`
### 2. Install on Phone ## Version History
```bash - **0.2.0** — Renamed from ClawdNode, added MiniMax audio, SUPER ATTENTION MODE, remote browser
adb install app/build/outputs/apk/debug/app-debug.apk - **0.1.0** — Initial release (as ClawdNode)
```
Or transfer APK and install manually (enable "Unknown sources"). ---
### 3. Configure Gateway *MoltMobile — Molt in your pocket.*
1. Open ClawdNode app
2. Enter Gateway URL: `http://<tailscale-ip>:18789`
3. Enter Gateway Token: (from your Clawdbot config)
4. Save Configuration
### 4. Grant Permissions
The app needs several permissions:
1. **Notification Access** — System settings, enable ClawdNode
2. **Call Screening Role** — Become the call screener
3. **Runtime Permissions**:
- Phone state
- Call log
- Answer calls
- Record audio (for STT)
- Contacts (for caller ID)
### 5. Test Connection
- Status should show "✓ Connected to Gateway"
- Send a test notification to your phone
- Check Gateway logs for the notification event
## Voice Flow Example
```
1. Call comes in from unknown number
2. ClawdNode sends: {"type": "call_incoming", "number": "+1234567890", "contact": null}
3. Claude decides to answer and screen
4. Gateway sends: {"cmd": "call_answer", "greeting": "Hi, this is Johan's assistant. Who's calling?"}
5. ClawdNode answers, plays TTS greeting
6. Caller speaks: "Hi, I'm calling from Dr. Smith's office about tomorrow's appointment"
7. ClawdNode sends: {"type": "call_audio", "transcript": "Hi, I'm calling from Dr. Smith's office..."}
8. Claude processes, decides to confirm details
9. Gateway sends: {"cmd": "call_speak", "text": "Thank you for calling. Can you confirm the time?"}
10. ... conversation continues ...
11. Gateway sends: {"cmd": "call_hangup"}
12. Claude sends summary to Johan via Signal
```
## Project Structure
```
app/src/main/java/com/inou/clawdnode/
├── ClawdNodeApp.kt # Application class, initialization
├── service/
│ ├── NodeService.kt # Foreground service, command routing
│ └── GatewayClient.kt # WebSocket connection
├── notifications/
│ └── NotificationListener.kt # Notification capture & actions
├── calls/
│ ├── CallScreener.kt # Call screening service
│ └── VoiceCallService.kt # InCallService for voice interaction
├── security/
│ ├── TokenStore.kt # Encrypted credential storage
│ └── AuditLog.kt # Local audit trail
├── protocol/
│ └── Messages.kt # Event/Command data classes
└── ui/
└── MainActivity.kt # Setup UI
```
## Requirements
- Android 10+ (API 29) — required for CallScreeningService
- Tailscale installed and connected
- Clawdbot Gateway running and accessible
## Security Notes
- Gateway connection is via Tailscale mesh network only
- Credentials are stored using Android's EncryptedSharedPreferences
- All actions are logged locally with timestamps
- No data leaves your network (except to Gateway)
## License
MIT — Use freely, contribute back.

View File

@ -4,15 +4,15 @@ plugins {
} }
android { android {
namespace = "com.inou.clawdnode" namespace = "com.inou.moltmobile"
compileSdk = 34 compileSdk = 34
defaultConfig { defaultConfig {
applicationId = "com.inou.clawdnode" applicationId = "com.inou.moltmobile"
minSdk = 29 // Android 10+ for CallScreeningService minSdk = 29 // Android 10+ for CallScreeningService
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 1
versionName = "0.1.0" versionName = "0.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@ -54,8 +54,8 @@ dependencies {
// Security - encrypted storage // Security - encrypted storage
implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Ed25519 signatures (pure Java, works on all Android versions) // Ed25519 signatures via Bouncy Castle (reliable, widely tested)
implementation("net.i2p.crypto:eddsa:0.3.0") implementation("org.bouncycastle:bcprov-jdk18on:1.77")
// Networking - WebSocket // Networking - WebSocket
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,304 @@
package com.inou.moltmobile.attention
import android.app.KeyguardManager
import android.content.Context
import android.graphics.Color
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.os.*
import android.util.Log
import android.view.View
import android.view.WindowManager
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.*
/**
* SUPER ATTENTION MODE
*
* Makes the phone go absolutely crazy to get attention:
* - Full screen alert
* - Volume MAX
* - Screen strobe (red/white)
* - Camera flash strobe
* - Continuous vibration
* - Alarm sound loop
*
* Only stops when user acknowledges or gateway sends stop command.
*/
class SuperAttentionActivity : AppCompatActivity() {
companion object {
private const val TAG = "MoltMobile.ATTENTION"
const val EXTRA_MESSAGE = "message"
// Singleton to track if attention mode is active
var isActive = false
private set
var currentInstance: SuperAttentionActivity? = null
private set
}
private var vibrator: Vibrator? = null
private var mediaPlayer: MediaPlayer? = null
private var cameraManager: CameraManager? = null
private var cameraId: String? = null
private var flashOn = false
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var strobeJob: Job? = null
private var flashJob: Job? = null
private lateinit var rootView: View
private lateinit var messageText: TextView
private lateinit var ackButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Wake up and show over lock screen
setupWindowFlags()
// Simple layout - just a message and ACK button
setupUI()
val message = intent.getStringExtra(EXTRA_MESSAGE) ?: "URGENT ALERT"
messageText.text = message
Log.w(TAG, "🚨 SUPER ATTENTION MODE ACTIVATED: $message")
isActive = true
currentInstance = this
// GO CRAZY
startMaxVolume()
startAlarmSound()
startVibration()
startScreenStrobe()
startFlashStrobe()
}
private fun setupWindowFlags() {
// Show over lock screen, turn screen on, keep screen on
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
keyguardManager.requestDismissKeyguard(this, null)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
)
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Full brightness
val layoutParams = window.attributes
layoutParams.screenBrightness = 1f
window.attributes = layoutParams
}
private fun setupUI() {
// Create views programmatically to avoid resource dependencies
rootView = View(this).apply {
setBackgroundColor(Color.RED)
}
val container = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
gravity = android.view.Gravity.CENTER
setPadding(48, 48, 48, 48)
}
TextView(this).apply {
text = "🚨 ALERT 🚨"
textSize = 48f
setTextColor(Color.WHITE)
gravity = android.view.Gravity.CENTER
container.addView(this)
}
messageText = TextView(this).apply {
textSize = 24f
setTextColor(Color.WHITE)
gravity = android.view.Gravity.CENTER
setPadding(0, 32, 0, 64)
container.addView(this)
}
ackButton = Button(this).apply {
text = "ACKNOWLEDGE"
textSize = 20f
setBackgroundColor(Color.WHITE)
setTextColor(Color.RED)
setPadding(64, 32, 64, 32)
setOnClickListener { acknowledge() }
container.addView(this)
}
rootView = container
setContentView(rootView)
}
private fun startMaxVolume() {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_ALARM)
audioManager.setStreamVolume(
AudioManager.STREAM_ALARM,
maxVolume,
AudioManager.FLAG_ALLOW_RINGER_MODES
)
Log.d(TAG, "Volume set to MAX: $maxVolume")
}
private fun startAlarmSound() {
try {
val alarmUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
mediaPlayer = MediaPlayer().apply {
setDataSource(this@SuperAttentionActivity, alarmUri)
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_ALARM)
.build()
)
isLooping = true
prepare()
start()
}
Log.d(TAG, "Alarm sound started")
} catch (e: Exception) {
Log.e(TAG, "Failed to start alarm: ${e.message}")
}
}
private fun startVibration() {
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
// Continuous strong vibration pattern
val pattern = longArrayOf(0, 500, 200, 500, 200, 1000)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator?.vibrate(VibrationEffect.createWaveform(pattern, 0))
} else {
@Suppress("DEPRECATION")
vibrator?.vibrate(pattern, 0)
}
Log.d(TAG, "Vibration started")
}
private fun startScreenStrobe() {
strobeJob = scope.launch {
var red = true
while (isActive) {
rootView.setBackgroundColor(if (red) Color.RED else Color.WHITE)
red = !red
delay(200)
}
}
Log.d(TAG, "Screen strobe started")
}
private fun startFlashStrobe() {
cameraManager = getSystemService(Context.CAMERA_SERVICE) as CameraManager
try {
for (id in cameraManager!!.cameraIdList) {
val characteristics = cameraManager!!.getCameraCharacteristics(id)
val hasFlash = characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) ?: false
if (hasFlash) {
cameraId = id
break
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to get camera: ${e.message}")
return
}
if (cameraId == null) {
Log.w(TAG, "No camera flash available")
return
}
flashJob = scope.launch {
while (isActive) {
try {
flashOn = !flashOn
cameraManager?.setTorchMode(cameraId!!, flashOn)
} catch (e: Exception) {
Log.e(TAG, "Flash toggle failed: ${e.message}")
}
delay(300)
}
}
Log.d(TAG, "Flash strobe started")
}
private fun acknowledge() {
Log.w(TAG, "🚨 SUPER ATTENTION MODE ACKNOWLEDGED")
stopEverything()
finish()
}
fun stopFromGateway() {
Log.w(TAG, "🚨 SUPER ATTENTION MODE STOPPED BY GATEWAY")
runOnUiThread {
stopEverything()
finish()
}
}
private fun stopEverything() {
isActive = false
currentInstance = null
strobeJob?.cancel()
flashJob?.cancel()
// Stop sound
mediaPlayer?.stop()
mediaPlayer?.release()
mediaPlayer = null
// Stop vibration
vibrator?.cancel()
// Turn off flash
try {
cameraId?.let { cameraManager?.setTorchMode(it, false) }
} catch (e: Exception) {
Log.e(TAG, "Failed to turn off flash: ${e.message}")
}
Log.d(TAG, "All attention effects stopped")
}
override fun onDestroy() {
super.onDestroy()
stopEverything()
scope.cancel()
}
override fun onBackPressed() {
// Prevent back button from dismissing - must acknowledge
// Do nothing
}
}

View File

@ -0,0 +1,209 @@
package com.inou.moltmobile.audio
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaPlayer
import android.media.MediaRecorder
import android.util.Base64
import android.util.Log
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
/**
* Audio manager for MoltMobile
* Handles recording (for STT) and playback (for TTS)
*/
class AudioManager(private val context: Context) {
companion object {
private const val TAG = "MoltMobile.Audio"
private const val SAMPLE_RATE = 24000
private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO
private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
}
private var mediaPlayer: MediaPlayer? = null
private var audioRecord: AudioRecord? = null
private var isRecording = false
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/**
* Play audio from hex-encoded MP3 data (from MiniMax TTS)
*/
fun playHexAudio(hexData: String, onComplete: () -> Unit = {}) {
scope.launch {
try {
// Decode hex to bytes
val audioBytes = hexStringToByteArray(hexData)
// Write to temp file
val tempFile = File(context.cacheDir, "tts_${System.currentTimeMillis()}.mp3")
FileOutputStream(tempFile).use { it.write(audioBytes) }
// Play on main thread
withContext(Dispatchers.Main) {
mediaPlayer?.release()
mediaPlayer = MediaPlayer().apply {
setDataSource(tempFile.absolutePath)
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.build()
)
setOnCompletionListener {
tempFile.delete()
onComplete()
}
prepare()
start()
}
Log.d(TAG, "Playing TTS audio: ${audioBytes.size} bytes")
}
} catch (e: Exception) {
Log.e(TAG, "Error playing audio: ${e.message}")
}
}
}
/**
* Play audio from Base64-encoded data
*/
fun playBase64Audio(base64Data: String, onComplete: () -> Unit = {}) {
scope.launch {
try {
val audioBytes = Base64.decode(base64Data, Base64.DEFAULT)
val tempFile = File(context.cacheDir, "tts_${System.currentTimeMillis()}.mp3")
FileOutputStream(tempFile).use { it.write(audioBytes) }
withContext(Dispatchers.Main) {
mediaPlayer?.release()
mediaPlayer = MediaPlayer().apply {
setDataSource(tempFile.absolutePath)
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.build()
)
setOnCompletionListener {
tempFile.delete()
onComplete()
}
prepare()
start()
}
}
} catch (e: Exception) {
Log.e(TAG, "Error playing base64 audio: ${e.message}")
}
}
}
/**
* Start recording audio (for STT)
* Returns a callback to stop recording and get the data
*/
fun startRecording(onData: (ByteArray) -> Unit): () -> Unit {
val bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
audioRecord = AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE,
CHANNEL_CONFIG,
AUDIO_FORMAT,
bufferSize * 2
)
isRecording = true
val audioData = mutableListOf<Byte>()
scope.launch {
audioRecord?.startRecording()
val buffer = ByteArray(bufferSize)
while (isRecording) {
val read = audioRecord?.read(buffer, 0, buffer.size) ?: 0
if (read > 0) {
audioData.addAll(buffer.take(read))
}
}
// Send recorded data
if (audioData.isNotEmpty()) {
onData(audioData.toByteArray())
}
}
return {
isRecording = false
audioRecord?.stop()
audioRecord?.release()
audioRecord = null
}
}
/**
* Record for a fixed duration and return data
*/
suspend fun recordForDuration(durationMs: Long): ByteArray {
return withContext(Dispatchers.IO) {
val audioData = mutableListOf<Byte>()
var stopRecording: (() -> Unit)? = null
stopRecording = startRecording { data ->
audioData.addAll(data.toList())
}
delay(durationMs)
stopRecording()
audioData.toByteArray()
}
}
/**
* Stop any current playback
*/
fun stopPlayback() {
mediaPlayer?.stop()
mediaPlayer?.release()
mediaPlayer = null
}
/**
* Set volume to maximum (for SUPER ATTENTION MODE)
*/
fun setMaxVolume() {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager
val maxVolume = audioManager.getStreamMaxVolume(android.media.AudioManager.STREAM_ALARM)
audioManager.setStreamVolume(
android.media.AudioManager.STREAM_ALARM,
maxVolume,
android.media.AudioManager.FLAG_ALLOW_RINGER_MODES
)
Log.d(TAG, "Volume set to MAX: $maxVolume")
}
fun release() {
stopPlayback()
isRecording = false
audioRecord?.release()
scope.cancel()
}
private fun hexStringToByteArray(hex: String): ByteArray {
val len = hex.length
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
data[i / 2] = ((Character.digit(hex[i], 16) shl 4) + Character.digit(hex[i + 1], 16)).toByte()
i += 2
}
return data
}
}

View File

@ -0,0 +1,230 @@
package com.inou.moltmobile.browser
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Bundle
import android.util.Base64
import android.util.Log
import android.view.ViewGroup
import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import java.io.ByteArrayOutputStream
/**
* Remote Browser Activity
*
* Allows Molt to control a browser on the phone:
* - Open URLs
* - Execute JavaScript
* - Take screenshots
* - Navigate
*/
class RemoteBrowserActivity : AppCompatActivity() {
companion object {
private const val TAG = "MoltMobile.Browser"
const val EXTRA_URL = "url"
// Singleton access
var currentInstance: RemoteBrowserActivity? = null
private set
}
private lateinit var webView: WebView
private lateinit var statusBar: TextView
private lateinit var progressBar: ProgressBar
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
currentInstance = this
// Create layout programmatically
val container = FrameLayout(this)
// Status bar at top
statusBar = TextView(this).apply {
text = "🌐 Molt is browsing..."
setPadding(16, 8, 16, 8)
setBackgroundColor(0xFF1976D2.toInt())
setTextColor(0xFFFFFFFF.toInt())
textSize = 14f
}
container.addView(statusBar, FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
))
// Progress bar
progressBar = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
isIndeterminate = false
max = 100
progress = 0
}
container.addView(progressBar, FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
8
).apply {
topMargin = 48 // Below status bar
})
// WebView
webView = WebView(this).apply {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
setSupportZoom(true)
builtInZoomControls = true
displayZoomControls = false
useWideViewPort = true
loadWithOverviewMode = true
mediaPlaybackRequiresUserGesture = false
}
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
statusBar.text = "🌐 Loading: ${url?.take(50)}..."
progressBar.progress = 0
}
override fun onPageFinished(view: WebView?, url: String?) {
statusBar.text = "🌐 Molt is browsing: ${view?.title?.take(40) ?: url?.take(40)}"
progressBar.progress = 100
Log.d(TAG, "Page loaded: $url")
}
}
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
progressBar.progress = newProgress
}
}
}
val webViewParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
).apply {
topMargin = 56 // Below status bar and progress
}
container.addView(webView, webViewParams)
setContentView(container)
// Load initial URL
intent.getStringExtra(EXTRA_URL)?.let { url ->
navigateTo(url)
}
Log.d(TAG, "Remote browser started")
}
/**
* Navigate to URL
*/
fun navigateTo(url: String) {
val fullUrl = if (url.startsWith("http://") || url.startsWith("https://")) {
url
} else {
"https://$url"
}
Log.d(TAG, "Navigating to: $fullUrl")
webView.loadUrl(fullUrl)
}
/**
* Execute JavaScript and return result
*/
fun executeJs(script: String, callback: (String) -> Unit) {
Log.d(TAG, "Executing JS: ${script.take(100)}...")
webView.evaluateJavascript(script) { result ->
Log.d(TAG, "JS result: ${result.take(200)}")
callback(result)
}
}
/**
* Capture screenshot as Base64 PNG
*/
fun captureScreenshot(): String {
val bitmap = Bitmap.createBitmap(
webView.width,
webView.height,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
webView.draw(canvas)
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 90, stream)
val base64 = Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)
Log.d(TAG, "Screenshot captured: ${base64.length} chars")
return base64
}
/**
* Get current URL
*/
fun getCurrentUrl(): String {
return webView.url ?: ""
}
/**
* Get page title
*/
fun getTitle(): String {
return webView.title ?: ""
}
/**
* Go back
*/
fun goBack(): Boolean {
return if (webView.canGoBack()) {
webView.goBack()
true
} else false
}
/**
* Go forward
*/
fun goForward(): Boolean {
return if (webView.canGoForward()) {
webView.goForward()
true
} else false
}
/**
* Refresh page
*/
fun refresh() {
webView.reload()
}
override fun onDestroy() {
super.onDestroy()
currentInstance = null
webView.destroy()
Log.d(TAG, "Remote browser closed")
}
override fun onBackPressed() {
if (webView.canGoBack()) {
webView.goBack()
} else {
super.onBackPressed()
}
}
}

View File

@ -1,4 +1,4 @@
package com.inou.clawdnode.calls package com.inou.moltmobile.calls
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
@ -8,9 +8,11 @@ import android.os.IBinder
import android.telecom.Call import android.telecom.Call
import android.telecom.CallScreeningService import android.telecom.CallScreeningService
import android.util.Log import android.util.Log
import com.inou.clawdnode.ClawdNodeApp import com.inou.moltmobile.MoltMobileApp
import com.inou.clawdnode.protocol.CallIncomingEvent import com.inou.moltmobile.debug.DebugClient
import com.inou.clawdnode.service.NodeService import com.inou.moltmobile.gateway.DirectGateway
import com.inou.moltmobile.protocol.CallIncomingEvent
import com.inou.moltmobile.service.NodeService
/** /**
* Screens incoming calls before they ring. * Screens incoming calls before they ring.
@ -33,9 +35,11 @@ class CallScreener : CallScreeningService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.i(tag, "CallScreener created") Log.i(tag, "CallScreener created")
DebugClient.lifecycle("CALL_SCREENER_CREATE", "Service started")
Intent(this, NodeService::class.java).also { intent -> Intent(this, NodeService::class.java).also { intent ->
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) val bound = bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
DebugClient.lifecycle("CALL_SCREENER", "bindService returned: $bound")
} }
} }
@ -50,21 +54,37 @@ class CallScreener : CallScreeningService() {
Log.i(tag, "Screening call from: $number") Log.i(tag, "Screening call from: $number")
// POST to debug server immediately
DebugClient.call(
callId = callId,
number = number,
contact = null,
state = "incoming"
)
// Send via DirectGateway
DirectGateway.sendCall(callId, number, null, "incoming")
// Look up contact name // Look up contact name
val contactName = lookupContact(number) val contactName = lookupContact(number)
// Store call details for later action // Store call details for later action
ActiveCalls.add(callId, callDetails) ActiveCalls.add(callId, callDetails)
// Send event to Gateway // Send event to Gateway via WebSocket
val event = CallIncomingEvent( try {
callId = callId, val event = CallIncomingEvent(
number = number, callId = callId,
contact = contactName number = number,
) contact = contactName
nodeService?.sendEvent(event) )
nodeService?.sendEvent(event)
DebugClient.log("Call event sent to gateway", mapOf("callId" to callId))
} catch (e: Exception) {
DebugClient.error("Failed to send call event", e)
}
ClawdNodeApp.instance.auditLog.logCall( MoltMobileApp.instance.auditLog.logCall(
"CALL_INCOMING", "CALL_INCOMING",
number, number,
contactName, contactName,
@ -80,6 +100,8 @@ class CallScreener : CallScreeningService() {
.setSkipNotification(false) .setSkipNotification(false)
.build() .build()
) )
DebugClient.call(callId, number, contactName, "allowed_to_ring")
} }
private fun lookupContact(number: String): String? { private fun lookupContact(number: String): String? {

View File

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

View File

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

View File

@ -0,0 +1,392 @@
package com.inou.moltmobile.gateway
import android.content.Context
import android.content.Intent
import android.util.Log
import com.inou.moltmobile.MoltMobileApp
import com.inou.moltmobile.attention.SuperAttentionActivity
import com.inou.moltmobile.audio.AudioManager
import com.inou.moltmobile.browser.RemoteBrowserActivity
import com.inou.moltmobile.debug.DebugClient
import kotlinx.coroutines.*
import okhttp3.*
import org.json.JSONObject
import java.util.concurrent.TimeUnit
/**
* Direct WebSocket connection to MoltMobile Gateway.
* Full bidirectional control for Molt.
*/
object DirectGateway {
private const val TAG = "MoltMobile.Gateway"
// Gateway address - Tailscale IP
private const val GATEWAY_URL = "ws://100.123.216.65:9878"
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS)
.pingInterval(30, TimeUnit.SECONDS)
.build()
private var webSocket: WebSocket? = null
private var isConnected = false
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Audio manager for TTS playback
private var audioManager: AudioManager? = null
// Command handlers (for other components to register)
var onNotificationAction: ((notificationId: String, action: String, replyText: String?) -> Unit)? = null
var onCallAnswer: ((callId: String) -> Unit)? = null
var onCallReject: ((callId: String) -> Unit)? = null
var onCallHangup: ((callId: String) -> Unit)? = null
fun initialize(context: Context) {
audioManager = AudioManager(context)
}
fun connect() {
if (webSocket != null) {
Log.d(TAG, "Already connected or connecting")
return
}
Log.i(TAG, "Connecting to $GATEWAY_URL")
DebugClient.lifecycle("GATEWAY", "Connecting to $GATEWAY_URL")
val request = Request.Builder()
.url(GATEWAY_URL)
.build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(ws: WebSocket, response: Response) {
Log.i(TAG, "✅ Connected to gateway")
isConnected = true
DebugClient.lifecycle("GATEWAY", "Connected")
send(mapOf(
"type" to "hello",
"client" to "moltmobile-android",
"version" to "0.2.0",
"capabilities" to listOf("audio", "browser", "attention", "calls", "notifications")
))
}
override fun onMessage(ws: WebSocket, text: String) {
Log.d(TAG, "📨 Received: ${text.take(200)}")
handleMessage(text)
}
override fun onClosing(ws: WebSocket, code: Int, reason: String) {
Log.i(TAG, "Connection closing: $code $reason")
ws.close(1000, null)
}
override fun onClosed(ws: WebSocket, code: Int, reason: String) {
Log.i(TAG, "Connection closed: $code $reason")
isConnected = false
webSocket = null
scheduleReconnect()
}
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "❌ Connection failed", t)
isConnected = false
webSocket = null
scheduleReconnect()
}
})
}
fun disconnect() {
webSocket?.close(1000, "Client disconnect")
webSocket = null
isConnected = false
}
private fun scheduleReconnect() {
scope.launch {
delay(5000)
if (webSocket == null) {
connect()
}
}
}
private fun handleMessage(text: String) {
try {
val json = JSONObject(text)
val type = json.optString("type", "")
when (type) {
"hello" -> {
Log.i(TAG, "Hello from server: ${json.optString("clientId")}")
}
"command" -> {
val command = json.optString("command")
val params = json.optJSONObject("params") ?: JSONObject()
val commandId = json.optString("commandId")
Log.i(TAG, "📥 Command: $command")
handleCommand(command, params, commandId)
}
}
} catch (e: Exception) {
Log.e(TAG, "Parse error", e)
}
}
private fun handleCommand(command: String, params: JSONObject, commandId: String) {
val context = MoltMobileApp.instance.applicationContext
try {
when (command) {
// ========================================
// AUDIO COMMANDS
// ========================================
"audio.play" -> {
val audioHex = params.optString("audioHex", "")
val audioBase64 = params.optString("audioBase64", "")
when {
audioHex.isNotEmpty() -> {
Log.i(TAG, "🔊 Playing hex audio (${audioHex.length} chars)")
audioManager?.playHexAudio(audioHex) {
sendResponse(commandId, true, data = mapOf("status" to "completed"))
}
sendResponse(commandId, true, data = mapOf("status" to "playing"))
}
audioBase64.isNotEmpty() -> {
Log.i(TAG, "🔊 Playing base64 audio")
audioManager?.playBase64Audio(audioBase64) {
sendResponse(commandId, true, data = mapOf("status" to "completed"))
}
sendResponse(commandId, true, data = mapOf("status" to "playing"))
}
else -> {
sendResponse(commandId, false, "No audio data provided")
}
}
}
"audio.stop" -> {
Log.i(TAG, "🔇 Stopping audio")
audioManager?.stopPlayback()
sendResponse(commandId, true)
}
"audio.volume.max" -> {
Log.i(TAG, "🔊 Setting volume to MAX")
audioManager?.setMaxVolume()
sendResponse(commandId, true)
}
// ========================================
// SUPER ATTENTION MODE 🚨
// ========================================
"attention.super" -> {
val message = params.optString("message", "URGENT ALERT")
Log.w(TAG, "🚨 SUPER ATTENTION MODE: $message")
val intent = Intent(context, SuperAttentionActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(SuperAttentionActivity.EXTRA_MESSAGE, message)
}
context.startActivity(intent)
sendResponse(commandId, true)
MoltMobileApp.instance.auditLog.log("ATTENTION_SUPER", message)
}
"attention.stop" -> {
Log.i(TAG, "🚨 Stopping attention mode")
SuperAttentionActivity.currentInstance?.stopFromGateway()
sendResponse(commandId, true)
}
// ========================================
// BROWSER COMMANDS
// ========================================
"browser.open" -> {
val url = params.optString("url", "https://google.com")
Log.i(TAG, "🌐 Opening browser: $url")
val intent = Intent(context, RemoteBrowserActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
putExtra(RemoteBrowserActivity.EXTRA_URL, url)
}
context.startActivity(intent)
sendResponse(commandId, true)
}
"browser.navigate" -> {
val url = params.optString("url")
Log.i(TAG, "🌐 Navigating to: $url")
RemoteBrowserActivity.currentInstance?.let { browser ->
scope.launch(Dispatchers.Main) {
browser.navigateTo(url)
sendResponse(commandId, true)
}
} ?: sendResponse(commandId, false, "Browser not open")
}
"browser.js" -> {
val script = params.optString("script")
Log.i(TAG, "🌐 Executing JS: ${script.take(50)}...")
RemoteBrowserActivity.currentInstance?.let { browser ->
scope.launch(Dispatchers.Main) {
browser.executeJs(script) { result ->
sendResponse(commandId, true, data = mapOf("result" to result))
}
}
} ?: sendResponse(commandId, false, "Browser not open")
}
"browser.screenshot" -> {
Log.i(TAG, "🌐 Taking screenshot")
RemoteBrowserActivity.currentInstance?.let { browser ->
scope.launch(Dispatchers.Main) {
val screenshot = browser.captureScreenshot()
sendResponse(commandId, true, data = mapOf(
"screenshot" to screenshot,
"url" to browser.getCurrentUrl(),
"title" to browser.getTitle()
))
}
} ?: sendResponse(commandId, false, "Browser not open")
}
"browser.close" -> {
Log.i(TAG, "🌐 Closing browser")
RemoteBrowserActivity.currentInstance?.finish()
sendResponse(commandId, true)
}
// ========================================
// NOTIFICATION COMMANDS
// ========================================
"notification.action" -> {
val notificationId = params.optString("notificationId")
val action = params.optString("action")
val replyText = params.optString("replyText", null)
Log.i(TAG, "🔔 Notification action: $action on $notificationId")
onNotificationAction?.invoke(notificationId, action, replyText)
sendResponse(commandId, true)
}
// ========================================
// CALL COMMANDS
// ========================================
"call.answer" -> {
val callId = params.optString("callId")
Log.i(TAG, "📞 Answering call: $callId")
onCallAnswer?.invoke(callId)
sendResponse(commandId, true)
}
"call.reject" -> {
val callId = params.optString("callId")
Log.i(TAG, "📞 Rejecting call: $callId")
onCallReject?.invoke(callId)
sendResponse(commandId, true)
}
"call.hangup" -> {
val callId = params.optString("callId")
Log.i(TAG, "📞 Hanging up: $callId")
onCallHangup?.invoke(callId)
sendResponse(commandId, true)
}
else -> {
Log.w(TAG, "❓ Unknown command: $command")
sendResponse(commandId, false, "Unknown command: $command")
}
}
} catch (e: Exception) {
Log.e(TAG, "❌ Command failed", e)
sendResponse(commandId, false, e.message)
}
}
private fun sendResponse(commandId: String, success: Boolean, error: String? = null, data: Map<String, Any?> = emptyMap()) {
send(mapOf(
"type" to "response",
"commandId" to commandId,
"success" to success,
"error" to error
) + data)
}
// ========================================
// Outgoing events
// ========================================
fun sendNotification(
id: String,
app: String,
packageName: String,
title: String?,
text: String?,
actions: List<String>
) {
send(mapOf(
"type" to "notification",
"id" to id,
"app" to app,
"packageName" to packageName,
"title" to title,
"text" to text,
"actions" to actions,
"timestamp" to System.currentTimeMillis()
))
}
fun sendCall(callId: String, number: String?, contact: String?, state: String) {
send(mapOf(
"type" to "call",
"callId" to callId,
"number" to number,
"contact" to contact,
"state" to state,
"timestamp" to System.currentTimeMillis()
))
}
fun sendLog(message: String, data: Map<String, Any?> = emptyMap()) {
send(mapOf("type" to "log", "message" to message) + data)
}
fun sendLifecycle(event: String, message: String = "") {
send(mapOf("type" to "lifecycle", "event" to event, "message" to message))
}
private fun send(data: Map<String, Any?>) {
if (!isConnected) return
try {
val json = JSONObject(data).toString()
webSocket?.send(json)
} catch (e: Exception) {
Log.e(TAG, "Send failed", e)
}
}
fun isConnected() = isConnected
fun release() {
audioManager?.release()
disconnect()
scope.cancel()
}
}

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package com.inou.clawdnode.security package com.inou.moltmobile.security
import android.content.Context import android.content.Context
import com.google.gson.Gson import com.google.gson.Gson
@ -9,7 +9,7 @@ import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
/** /**
* Local audit log for all ClawdNode actions. * Local audit log for all MoltMobile actions.
* Security-first: every action is logged with timestamp. * Security-first: every action is logged with timestamp.
*/ */
class AuditLog(private val context: Context) { class AuditLog(private val context: Context) {

View File

@ -1,40 +1,34 @@
package com.inou.clawdnode.security package com.inou.moltmobile.security
import android.content.Context import android.content.Context
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import net.i2p.crypto.eddsa.EdDSAEngine import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import net.i2p.crypto.eddsa.EdDSAPrivateKey import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import net.i2p.crypto.eddsa.EdDSAPublicKey import org.bouncycastle.crypto.signers.Ed25519Signer
import net.i2p.crypto.eddsa.KeyPairGenerator
import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec
import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec
import java.security.MessageDigest import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
/** /**
* Manages device identity using Ed25519 keys. * Manages device identity using Ed25519 keys via Bouncy Castle.
* *
* Keys are stored in EncryptedSharedPreferences for security. * Keys are stored in EncryptedSharedPreferences for security.
* Uses pure-Java Ed25519 implementation for compatibility with all Android versions. * Uses Bouncy Castle's Ed25519 implementation for compatibility.
* *
* The key format matches the Clawdbot gateway protocol: * The key format matches the Clawdbot gateway protocol:
* - Public key: 32 bytes raw, base64url-encoded * - Public key: 32 bytes raw, base64url-encoded
* - Signature: 64 bytes raw, base64url-encoded * - Signature: 64 bytes raw, base64url-encoded
* - Payload: "$nonce:$signedAt" (matching gateway's expected format) * - Payload format (v2): "v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce"
*/ */
class DeviceIdentity(context: Context) { class DeviceIdentity(context: Context) {
private val tag = "DeviceIdentity" private val tag = "DeviceIdentity"
private val prefsName = "clawdnode_device_identity" private val prefsName = "moltmobile_device_identity"
private val keyPrivate = "private_key" private val keyPrivate = "private_key"
private val keyPublic = "public_key" private val keyPublic = "public_key"
private val ed25519Spec = EdDSANamedCurveTable.getByName("Ed25519")
private val prefs by lazy { private val prefs by lazy {
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
@ -54,9 +48,8 @@ class DeviceIdentity(context: Context) {
*/ */
val publicKey: String by lazy { val publicKey: String by lazy {
ensureKeyPair() ensureKeyPair()
val publicKeyBytes = prefs.getString(keyPublic, null) prefs.getString(keyPublic, null)
?: throw IllegalStateException("No public key available") ?: throw IllegalStateException("No public key available")
publicKeyBytes // Already stored as base64url
} }
/** /**
@ -67,55 +60,87 @@ class DeviceIdentity(context: Context) {
val publicKeyBase64 = prefs.getString(keyPublic, null) val publicKeyBase64 = prefs.getString(keyPublic, null)
?: throw IllegalStateException("No public key available") ?: throw IllegalStateException("No public key available")
// Decode the raw public key bytes
val publicKeyBytes = base64UrlDecode(publicKeyBase64) val publicKeyBytes = base64UrlDecode(publicKeyBase64)
// Create fingerprint using SHA-256 of the raw public key bytes
val digest = MessageDigest.getInstance("SHA-256") val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(publicKeyBytes) val hash = digest.digest(publicKeyBytes)
// Return as hex string (full 32 bytes = 64 chars, matching gateway)
hash.joinToString("") { "%02x".format(it) } hash.joinToString("") { "%02x".format(it) }
} }
/** /**
* Sign a challenge nonce for gateway authentication * Sign a challenge nonce for gateway authentication.
* *
* @param nonce The challenge nonce from the gateway * Builds the payload in gateway's expected format:
* @return Signature and timestamp * v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
*
* @param nonce Challenge nonce from gateway
* @param clientId Client identifier (e.g., "moltmobile-android")
* @param clientMode Client mode ("node")
* @param role Role ("node")
* @param scopes Comma-separated scopes (empty for nodes)
* @param token Auth token (gateway token or device token)
*/ */
fun signChallenge(nonce: String): SignedChallenge { fun signChallenge(
nonce: String,
clientId: String,
clientMode: String,
role: String,
scopes: String = "",
token: String = ""
): SignedChallenge {
ensureKeyPair() ensureKeyPair()
val signedAt = System.currentTimeMillis() val signedAt = System.currentTimeMillis()
val payload = "$nonce:$signedAt"
// Build payload in gateway's expected format:
// v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
val payload = listOf(
"v2",
deviceId,
clientId,
clientMode,
role,
scopes,
signedAt.toString(),
token,
nonce
).joinToString("|")
Log.d(tag, "Signing payload: $payload") Log.d(tag, "Signing payload: $payload")
// Load private key // Load private key (32-byte seed)
val privateKeyBase64 = prefs.getString(keyPrivate, null) val privateKeyBase64 = prefs.getString(keyPrivate, null)
?: throw IllegalStateException("No private key available") ?: throw IllegalStateException("No private key available")
val privateKeyBytes = base64UrlDecode(privateKeyBase64) val privateKeyBytes = base64UrlDecode(privateKeyBase64)
// Create EdDSA private key // Create Bouncy Castle Ed25519 private key from seed
val privateKeySpec = EdDSAPrivateKeySpec(privateKeyBytes, ed25519Spec) val privateKey = Ed25519PrivateKeyParameters(privateKeyBytes, 0)
val privateKey = EdDSAPrivateKey(privateKeySpec)
// Sign the payload using standard Ed25519 (not prehashed Ed25519ph) // Verify derived public key matches stored
val signature = EdDSAEngine().apply { val derivedPubKey = privateKey.generatePublicKey().encoded
initSign(privateKey) val storedPubKeyBase64 = prefs.getString(keyPublic, null)
update(payload.toByteArray(Charsets.UTF_8)) val storedPubKey = storedPubKeyBase64?.let { base64UrlDecode(it) }
} val keysMatch = derivedPubKey.contentEquals(storedPubKey)
Log.d(tag, "Keys match: $keysMatch")
// Sign using Ed25519
val signer = Ed25519Signer()
signer.init(true, privateKey)
signer.update(payload.toByteArray(Charsets.UTF_8), 0, payload.length)
val signatureBytes = signer.generateSignature()
val signatureBytes = signature.sign()
val signatureBase64 = base64UrlEncode(signatureBytes) val signatureBase64 = base64UrlEncode(signatureBytes)
Log.d(tag, "Generated signature: ${signatureBase64.take(20)}... (${signatureBytes.size} bytes)") Log.d(tag, "Generated signature: ${signatureBase64.take(20)}... (${signatureBytes.size} bytes)")
val debugInfo = "Keys match: $keysMatch | Stored: ${storedPubKeyBase64?.take(12)}... | Derived: ${base64UrlEncode(derivedPubKey).take(12)}..."
return SignedChallenge( return SignedChallenge(
signature = signatureBase64, signature = signatureBase64,
signedAt = signedAt, signedAt = signedAt,
nonce = nonce nonce = nonce,
debugInfo = debugInfo
) )
} }
@ -137,29 +162,26 @@ class DeviceIdentity(context: Context) {
} }
/** /**
* Generate a new Ed25519 keypair and store it * Generate a new Ed25519 keypair using Bouncy Castle
*/ */
private fun generateKeyPair() { private fun generateKeyPair() {
Log.i(tag, "Generating new Ed25519 device keypair") Log.i(tag, "Generating new Ed25519 device keypair (Bouncy Castle)")
val keyPairGenerator = KeyPairGenerator() // Generate random 32-byte seed
keyPairGenerator.initialize(256, SecureRandom()) val seed = ByteArray(32)
val keyPair = keyPairGenerator.generateKeyPair() SecureRandom().nextBytes(seed)
val privateKey = keyPair.private as EdDSAPrivateKey // Create keypair from seed
val publicKey = keyPair.public as EdDSAPublicKey val privateKey = Ed25519PrivateKeyParameters(seed, 0)
val publicKey = privateKey.generatePublicKey()
// Get raw 32-byte keys (matching gateway format) val publicKeyBytes = publicKey.encoded
// For Ed25519, the "seed" is the 32-byte private key
val privateKeyBytes = privateKey.seed
// The "A" point is the 32-byte public key
val publicKeyBytes = publicKey.abyte
Log.d(tag, "Generated keypair: private=${privateKeyBytes.size} bytes, public=${publicKeyBytes.size} bytes") Log.d(tag, "Generated keypair: seed=${seed.size} bytes, public=${publicKeyBytes.size} bytes")
// Store as base64url // Store as base64url
prefs.edit() prefs.edit()
.putString(keyPrivate, base64UrlEncode(privateKeyBytes)) .putString(keyPrivate, base64UrlEncode(seed))
.putString(keyPublic, base64UrlEncode(publicKeyBytes)) .putString(keyPublic, base64UrlEncode(publicKeyBytes))
.apply() .apply()
@ -177,16 +199,10 @@ class DeviceIdentity(context: Context) {
Log.i(tag, "Device keypair deleted") Log.i(tag, "Device keypair deleted")
} }
/**
* Base64url encode (no padding, URL-safe)
*/
private fun base64UrlEncode(bytes: ByteArray): String { private fun base64UrlEncode(bytes: ByteArray): String {
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
} }
/**
* Base64url decode (handles both padded and unpadded)
*/
private fun base64UrlDecode(input: String): ByteArray { private fun base64UrlDecode(input: String): ByteArray {
return Base64.decode(input, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) return Base64.decode(input, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
} }
@ -194,6 +210,7 @@ class DeviceIdentity(context: Context) {
data class SignedChallenge( data class SignedChallenge(
val signature: String, val signature: String,
val signedAt: Long, val signedAt: Long,
val nonce: String val nonce: String,
val debugInfo: String = ""
) )
} }

View File

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

View File

@ -1,13 +1,13 @@
package com.inou.clawdnode.service package com.inou.moltmobile.service
import android.util.Log import android.util.Log
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonObject import com.google.gson.JsonObject
import com.inou.clawdnode.BuildConfig import com.inou.moltmobile.BuildConfig
import com.inou.clawdnode.ClawdNodeApp import com.inou.moltmobile.MoltMobileApp
import com.inou.clawdnode.protocol.Protocol import com.inou.moltmobile.protocol.Protocol
import com.inou.clawdnode.protocol.* import com.inou.moltmobile.protocol.*
import com.inou.clawdnode.security.DeviceIdentity import com.inou.moltmobile.security.DeviceIdentity
import kotlinx.coroutines.* import kotlinx.coroutines.*
import okhttp3.* import okhttp3.*
import java.util.UUID import java.util.UUID
@ -62,10 +62,10 @@ class GatewayClient(
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val requestIdCounter = AtomicInteger(0) private val requestIdCounter = AtomicInteger(0)
private val auditLog get() = ClawdNodeApp.instance.auditLog private val auditLog get() = MoltMobileApp.instance.auditLog
private val tokenStore get() = ClawdNodeApp.instance.tokenStore private val tokenStore get() = MoltMobileApp.instance.tokenStore
private val deviceIdentity by lazy { DeviceIdentity(ClawdNodeApp.instance) } private val deviceIdentity by lazy { DeviceIdentity(MoltMobileApp.instance) }
// Node capabilities // Node capabilities
private val caps = listOf("notifications", "calls", "voice") private val caps = listOf("notifications", "calls", "voice")
@ -226,9 +226,19 @@ class GatewayClient(
try { try {
deviceId = deviceIdentity.deviceId deviceId = deviceIdentity.deviceId
signedChallenge = deviceIdentity.signChallenge(nonce) // Sign with full device auth payload:
// v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
signedChallenge = deviceIdentity.signChallenge(
nonce = nonce,
clientId = Protocol.CLIENT_ID,
clientMode = Protocol.MODE,
role = Protocol.ROLE,
scopes = "", // Nodes don't use scopes
token = token
)
publicKey = deviceIdentity.publicKey publicKey = deviceIdentity.publicKey
log("Device identity ready: id=${deviceId.take(8)}..., signed challenge") log("Device identity ready: id=${deviceId.take(8)}...")
log("DEBUG: ${signedChallenge.debugInfo}")
} catch (e: Exception) { } catch (e: Exception) {
logError("Failed to initialize device identity or sign challenge", e) logError("Failed to initialize device identity or sign challenge", e)
// Cannot proceed without device identity for non-local connections // Cannot proceed without device identity for non-local connections
@ -256,7 +266,7 @@ class GatewayClient(
"calls.speak" to true "calls.speak" to true
), ),
auth = AuthInfo(token = token), auth = AuthInfo(token = token),
userAgent = "clawdnode-android/${getAppVersion()}", userAgent = "moltmobile-android/${getAppVersion()}",
device = DeviceInfo( device = DeviceInfo(
id = deviceId, id = deviceId,
publicKey = publicKey, publicKey = publicKey,

View File

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

View File

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

View File

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

View File

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

View File

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