mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 21:31:36 +00:00
feat(android): add system share target (#104571)
* feat(android): receive system shares safely Co-authored-by: NianJiuZst <3235467914@qq.com> * fix(android): preserve share FIFO in composer * fix(android): serialize cross-task share imports * chore(android): sync share import i18n * fix(android): retry shares in resumed composer * chore(android): sync resumed share i18n --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -145,9 +145,17 @@
|
||||
"surface": "android",
|
||||
"id": "native.android.39bdbc9a546c7f1d"
|
||||
},
|
||||
{
|
||||
"kind": "ui-toast",
|
||||
"line": 200,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt",
|
||||
"source": "Too many shares are waiting to be added.",
|
||||
"surface": "android",
|
||||
"id": "native.android.21a3ed1199d96f1e"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 289,
|
||||
"line": 321,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt",
|
||||
"source": "OPENCLAW",
|
||||
"surface": "android",
|
||||
@@ -155,7 +163,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-state-text",
|
||||
"line": 184,
|
||||
"line": 252,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt",
|
||||
"source": "Searching…",
|
||||
"surface": "android",
|
||||
@@ -163,7 +171,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-state-text",
|
||||
"line": 287,
|
||||
"line": 355,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt",
|
||||
"source": "Mic off",
|
||||
"surface": "android",
|
||||
@@ -171,7 +179,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-state-text",
|
||||
"line": 301,
|
||||
"line": 369,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt",
|
||||
"source": "Off",
|
||||
"surface": "android",
|
||||
@@ -5931,7 +5939,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 333,
|
||||
"line": 379,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Chat needs attention",
|
||||
"surface": "android",
|
||||
@@ -5939,7 +5947,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 518,
|
||||
"line": 570,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "All",
|
||||
"surface": "android",
|
||||
@@ -5947,7 +5955,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 576,
|
||||
"line": 628,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "OpenClaw",
|
||||
"surface": "android",
|
||||
@@ -5955,7 +5963,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 597,
|
||||
"line": 649,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "New chat",
|
||||
"surface": "android",
|
||||
@@ -5963,7 +5971,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 602,
|
||||
"line": 654,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "More new chat options",
|
||||
"surface": "android",
|
||||
@@ -5971,7 +5979,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 617,
|
||||
"line": 669,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Refresh chat",
|
||||
"surface": "android",
|
||||
@@ -5979,7 +5987,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 620,
|
||||
"line": 672,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Chat",
|
||||
"surface": "android",
|
||||
@@ -5987,7 +5995,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 768,
|
||||
"line": 820,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Loading session",
|
||||
"surface": "android",
|
||||
@@ -5995,7 +6003,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 795,
|
||||
"line": 847,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Jump to latest",
|
||||
"surface": "android",
|
||||
@@ -6003,7 +6011,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 821,
|
||||
"line": 873,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Ready when you are",
|
||||
"surface": "android",
|
||||
@@ -6011,7 +6019,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 852,
|
||||
"line": 904,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Fix connection",
|
||||
"surface": "android",
|
||||
@@ -6019,7 +6027,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 853,
|
||||
"line": 905,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Copy diagnostics",
|
||||
"surface": "android",
|
||||
@@ -6027,7 +6035,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1042,
|
||||
"line": 1094,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Preparing audio…",
|
||||
"surface": "android",
|
||||
@@ -6035,7 +6043,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1042,
|
||||
"line": 1094,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Speaking…",
|
||||
"surface": "android",
|
||||
@@ -6043,7 +6051,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1063,
|
||||
"line": 1115,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Tools running",
|
||||
"surface": "android",
|
||||
@@ -6051,7 +6059,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1065,
|
||||
"line": 1117,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "OpenClaw is working",
|
||||
"surface": "android",
|
||||
@@ -6059,7 +6067,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1068,
|
||||
"line": 1120,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "+${toolCalls.size - 4} more",
|
||||
"surface": "android",
|
||||
@@ -6067,7 +6075,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1078,
|
||||
"line": 1130,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Thinking",
|
||||
"surface": "android",
|
||||
@@ -6075,7 +6083,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1079,
|
||||
"line": 1131,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "OpenClaw is preparing a response.",
|
||||
"surface": "android",
|
||||
@@ -6083,7 +6091,15 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1258,
|
||||
"line": 1231,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Dismiss shared-image warning",
|
||||
"surface": "android",
|
||||
"id": "native.android.f3806d79e43f568f"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1332,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Stop",
|
||||
"surface": "android",
|
||||
@@ -6091,7 +6107,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1355,
|
||||
"line": 1429,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Default",
|
||||
"surface": "android",
|
||||
@@ -6099,7 +6115,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1421,
|
||||
"line": 1495,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Pin model",
|
||||
"surface": "android",
|
||||
@@ -6107,7 +6123,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1421,
|
||||
"line": 1495,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Unpin model",
|
||||
"surface": "android",
|
||||
@@ -6115,7 +6131,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1438,
|
||||
"line": 1512,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "No commands found",
|
||||
"surface": "android",
|
||||
@@ -6123,7 +6139,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1500,
|
||||
"line": 1574,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Gateway offline",
|
||||
"surface": "android",
|
||||
@@ -6131,7 +6147,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1546,
|
||||
"line": 1620,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Close thinking level selector",
|
||||
"surface": "android",
|
||||
@@ -6139,7 +6155,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1546,
|
||||
"line": 1620,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Open thinking level selector",
|
||||
"surface": "android",
|
||||
@@ -6147,7 +6163,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1606,
|
||||
"line": 1680,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Attach image",
|
||||
"surface": "android",
|
||||
@@ -6155,7 +6171,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1641,
|
||||
"line": 1715,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Message OpenClaw",
|
||||
"surface": "android",
|
||||
@@ -6163,7 +6179,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1657,
|
||||
"line": 1731,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Open voice",
|
||||
"surface": "android",
|
||||
@@ -6171,7 +6187,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1706,
|
||||
"line": 1780,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Remove attachment",
|
||||
"surface": "android",
|
||||
@@ -6179,7 +6195,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1727,
|
||||
"line": 1801,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Main",
|
||||
"surface": "android",
|
||||
@@ -6187,7 +6203,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1735,
|
||||
"line": 1809,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "$emoji $name",
|
||||
"surface": "android",
|
||||
@@ -6195,7 +6211,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1794,
|
||||
"line": 1868,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Send",
|
||||
"surface": "android",
|
||||
@@ -6203,7 +6219,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1825,
|
||||
"line": 1899,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}",
|
||||
"surface": "android",
|
||||
|
||||
@@ -8,6 +8,8 @@ Recovers Android permission prompts after timeouts or cancellation without exhau
|
||||
|
||||
Requires a clear in-app disclosure and fresh consent before Installed Apps can share app names, package IDs, and status with a paired Gateway; existing opt-ins must consent again. Thanks @joshavant.
|
||||
|
||||
Adds an Android system share target that stages bounded text and image shares for review without losing existing composer drafts. Thanks @NianJiuZst.
|
||||
|
||||
## 2026.7.1 - 2026-07-08
|
||||
|
||||
Adds multi-gateway switching with isolated credentials, history, queues, and notification routing.
|
||||
|
||||
@@ -73,9 +73,11 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
<!-- External sender tasks must converge on one visible composer and share queue. -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation">
|
||||
<meta-data
|
||||
@@ -89,6 +91,17 @@
|
||||
<action android:name="android.intent.action.ASSIST" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.content.IntentCompat
|
||||
|
||||
/** Android Assistant entry point used by manifest-declared app actions. */
|
||||
const val actionAskOpenClaw = "ai.openclaw.app.action.ASK_OPENCLAW"
|
||||
@@ -31,6 +34,18 @@ data class AssistantLaunchRequest(
|
||||
val autoSend: Boolean,
|
||||
)
|
||||
|
||||
/** Shared content staged in chat for user review before sending. */
|
||||
data class ShareLaunchRequest(
|
||||
val text: String?,
|
||||
val imageUris: List<Uri>,
|
||||
val droppedImageCount: Int,
|
||||
)
|
||||
|
||||
private data class SharedImageSelection(
|
||||
val uris: List<Uri>,
|
||||
val droppedCount: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parses app-owned navigation actions that should open a specific home tab.
|
||||
*/
|
||||
@@ -68,3 +83,63 @@ fun parseAssistantLaunchIntent(intent: Intent?): AssistantLaunchRequest? {
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses Android Sharesheet content without reading external providers on the main thread. */
|
||||
fun parseShareLaunchIntent(intent: Intent?): ShareLaunchRequest? {
|
||||
val action = intent?.action ?: return null
|
||||
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
|
||||
|
||||
val text =
|
||||
listOf(intent.getStringExtra(Intent.EXTRA_SUBJECT), intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString())
|
||||
.mapNotNull { value -> value?.trim()?.takeIf { it.isNotEmpty() } }
|
||||
.distinct()
|
||||
.joinToString(separator = "\n\n")
|
||||
.ifEmpty { null }
|
||||
val imageSelection =
|
||||
if (intent.type?.startsWith("image/", ignoreCase = true) == true) {
|
||||
sharedImageUris(intent, action)
|
||||
} else {
|
||||
SharedImageSelection(uris = emptyList(), droppedCount = 0)
|
||||
}
|
||||
|
||||
if (text == null && imageSelection.uris.isEmpty()) return null
|
||||
return ShareLaunchRequest(
|
||||
text = text,
|
||||
imageUris = imageSelection.uris,
|
||||
droppedImageCount = imageSelection.droppedCount,
|
||||
)
|
||||
}
|
||||
|
||||
private fun sharedImageUris(
|
||||
intent: Intent,
|
||||
action: String,
|
||||
): SharedImageSelection {
|
||||
val streamUris =
|
||||
when (action) {
|
||||
Intent.ACTION_SEND ->
|
||||
listOfNotNull(IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java))
|
||||
|
||||
Intent.ACTION_SEND_MULTIPLE ->
|
||||
IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java).orEmpty()
|
||||
|
||||
else -> emptyList()
|
||||
}
|
||||
val clipUris =
|
||||
intent.clipData
|
||||
?.let { clip ->
|
||||
(0 until clip.itemCount).mapNotNull { index -> clip.getItemAt(index).uri }
|
||||
}.orEmpty()
|
||||
|
||||
// Only provider-backed content URIs use the sender's temporary read grant. Rejecting file://
|
||||
// prevents an external intent from turning OpenClaw into a reader for its own private files.
|
||||
val validUris =
|
||||
(streamUris + clipUris)
|
||||
.filter { uri -> uri.scheme.equals(ContentResolver.SCHEME_CONTENT, ignoreCase = true) }
|
||||
.distinct()
|
||||
return SharedImageSelection(
|
||||
uris = validUris.take(MAX_SHARED_IMAGE_COUNT),
|
||||
droppedCount = (validUris.size - MAX_SHARED_IMAGE_COUNT).coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
|
||||
private const val MAX_SHARED_IMAGE_COUNT = 8
|
||||
|
||||
@@ -5,6 +5,7 @@ import ai.openclaw.app.ui.RootScreen
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -112,8 +113,12 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun onNewIntent(intent: android.content.Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
pendingIntentRouter.onNewIntent(intent) { routedIntent ->
|
||||
initializedViewModel?.let { handleAssistantIntent(viewModel = it, intent = routedIntent) }
|
||||
val accepted =
|
||||
pendingIntentRouter.onNewIntent(intent) { routedIntent ->
|
||||
initializedViewModel?.let { handleLaunchIntent(viewModel = it, intent = routedIntent) }
|
||||
}
|
||||
if (!accepted) {
|
||||
Toast.makeText(this, "Too many shares are waiting to be added.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +146,7 @@ class MainActivity : AppCompatActivity() {
|
||||
pendingIntentRouter.discardInitialIntent()
|
||||
}
|
||||
pendingIntentRouter.activate { initialIntent ->
|
||||
handleAssistantIntent(viewModel = readyViewModel, intent = initialIntent)
|
||||
handleLaunchIntent(viewModel = readyViewModel, intent = initialIntent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,10 +191,16 @@ class MainActivity : AppCompatActivity() {
|
||||
/**
|
||||
* Routes assistant/app-action intents into ViewModel state without recreating the activity.
|
||||
*/
|
||||
private fun handleAssistantIntent(
|
||||
private fun handleLaunchIntent(
|
||||
viewModel: MainViewModel,
|
||||
intent: Intent?,
|
||||
) {
|
||||
parseShareLaunchIntent(intent)?.let { request ->
|
||||
if (!viewModel.handleShareLaunch(request)) {
|
||||
Toast.makeText(this, "Too many shares are waiting to be added.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
return
|
||||
}
|
||||
parseHomeDestinationIntent(intent)?.let { destination ->
|
||||
viewModel.requestHomeDestination(destination)
|
||||
return
|
||||
@@ -199,47 +210,68 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Holds launch intents until ViewModel activation, then routes every later intent immediately. */
|
||||
/** Queues shares until ViewModel activation while retaining only the latest ordinary launch intent. */
|
||||
internal class MainActivityPendingIntentRouter {
|
||||
private data class PendingLaunchIntent(
|
||||
val sequence: Long,
|
||||
val intent: Intent,
|
||||
val initial: Boolean,
|
||||
)
|
||||
|
||||
private var activated = false
|
||||
private var pendingIntent: Intent? = null
|
||||
private var pendingIntentIsInitial = false
|
||||
private var sequence = 0L
|
||||
private val pendingShareIntents = ArrayDeque<PendingLaunchIntent>()
|
||||
private var pendingNonShareIntent: PendingLaunchIntent? = null
|
||||
|
||||
fun setInitialIntent(intent: Intent?) {
|
||||
if (!activated) {
|
||||
pendingIntent = intent
|
||||
pendingIntentIsInitial = true
|
||||
}
|
||||
if (!activated && intent != null) store(intent = intent, initial = true)
|
||||
}
|
||||
|
||||
fun onNewIntent(
|
||||
intent: Intent,
|
||||
routeIntent: (Intent) -> Unit,
|
||||
) {
|
||||
): Boolean {
|
||||
if (activated) {
|
||||
routeIntent(intent)
|
||||
return
|
||||
return true
|
||||
}
|
||||
pendingIntent = intent
|
||||
pendingIntentIsInitial = false
|
||||
return store(intent = intent, initial = false)
|
||||
}
|
||||
|
||||
fun discardInitialIntent() {
|
||||
if (activated || !pendingIntentIsInitial) return
|
||||
pendingIntent = null
|
||||
pendingIntentIsInitial = false
|
||||
if (activated) return
|
||||
pendingShareIntents.removeAll { it.initial }
|
||||
if (pendingNonShareIntent?.initial == true) pendingNonShareIntent = null
|
||||
}
|
||||
|
||||
fun activate(routeIntent: (Intent) -> Unit): Boolean {
|
||||
if (activated) return false
|
||||
activated = true
|
||||
pendingIntent?.let(routeIntent)
|
||||
pendingIntent = null
|
||||
pendingIntentIsInitial = false
|
||||
(pendingShareIntents + listOfNotNull(pendingNonShareIntent))
|
||||
.sortedBy(PendingLaunchIntent::sequence)
|
||||
.forEach { pending -> routeIntent(pending.intent) }
|
||||
pendingShareIntents.clear()
|
||||
pendingNonShareIntent = null
|
||||
return true
|
||||
}
|
||||
|
||||
private fun store(
|
||||
intent: Intent,
|
||||
initial: Boolean,
|
||||
): Boolean {
|
||||
val pending = PendingLaunchIntent(sequence = sequence++, intent = intent, initial = initial)
|
||||
if (!intent.isShareLaunchIntent()) {
|
||||
pendingNonShareIntent = pending
|
||||
return true
|
||||
}
|
||||
if (pendingShareIntents.size >= MAX_PENDING_CHAT_SHARES) return false
|
||||
pendingShareIntents.addLast(pending)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun Intent.isShareLaunchIntent(): Boolean = action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE
|
||||
|
||||
/** Keeps launch intents one-shot across same-process Activity recreation, but not process death. */
|
||||
internal class MainActivityInitialIntentGate {
|
||||
private var claimed = false
|
||||
|
||||
@@ -21,6 +21,7 @@ import ai.openclaw.app.ui.GatewaySavedAuthAction
|
||||
import ai.openclaw.app.voice.VoiceConversationEntry
|
||||
import android.Manifest
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -30,6 +31,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -48,6 +50,67 @@ data class ChatDraft(
|
||||
val placement: ChatDraftPlacement,
|
||||
)
|
||||
|
||||
data class ChatShareDraft(
|
||||
val id: Long,
|
||||
val text: String?,
|
||||
val imageUris: List<Uri>,
|
||||
val droppedImageCount: Int,
|
||||
)
|
||||
|
||||
internal const val MAX_PENDING_CHAT_SHARES = 16
|
||||
|
||||
/** Bounded process-local queue whose stable head survives Activity recreation with the ViewModel. */
|
||||
internal class ChatShareDraftQueue(
|
||||
private val capacity: Int = MAX_PENDING_CHAT_SHARES,
|
||||
) {
|
||||
private val lock = Any()
|
||||
private val drafts = ArrayDeque<ChatShareDraft>()
|
||||
private val headLease = Mutex()
|
||||
private val _head = MutableStateFlow<ChatShareDraft?>(null)
|
||||
val head: StateFlow<ChatShareDraft?> = _head.asStateFlow()
|
||||
|
||||
init {
|
||||
require(capacity > 0)
|
||||
}
|
||||
|
||||
fun enqueue(draft: ChatShareDraft): Boolean =
|
||||
synchronized(lock) {
|
||||
if (drafts.size >= capacity) return@synchronized false
|
||||
drafts.addLast(draft)
|
||||
_head.value = drafts.first()
|
||||
true
|
||||
}
|
||||
|
||||
/** Only the active loader may advance the queue; stale effects cannot acknowledge a newer head. */
|
||||
fun acknowledgeHead(id: Long): Boolean =
|
||||
synchronized(lock) {
|
||||
if (drafts.firstOrNull()?.id != id) return@synchronized false
|
||||
drafts.removeFirst()
|
||||
_head.value = drafts.firstOrNull()
|
||||
true
|
||||
}
|
||||
|
||||
/** Serializes loaders across overlapping Activity instances while rechecking the stable head. */
|
||||
suspend fun withHeadLease(
|
||||
id: Long,
|
||||
block: suspend () -> Unit,
|
||||
): Boolean =
|
||||
headLease.withLock {
|
||||
if (synchronized(lock) { drafts.firstOrNull()?.id } != id) return@withLock false
|
||||
block()
|
||||
true
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(lock) {
|
||||
drafts.clear()
|
||||
_head.value = null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun size(): Int = synchronized(lock) { drafts.size }
|
||||
}
|
||||
|
||||
internal fun shouldStartRuntimeOnForeground(
|
||||
foreground: Boolean,
|
||||
onboardingCompleted: Boolean,
|
||||
@@ -87,6 +150,10 @@ class MainViewModel(
|
||||
private val gatewayConfigOperationSeq = AtomicLong()
|
||||
private val gatewayConfigOperationMutex = Mutex()
|
||||
|
||||
// Multiple MainActivity instances can overlap across sender tasks; the process owns one queue.
|
||||
private val chatShareDraftSeq = nodeApp.chatShareDraftSeq
|
||||
private val chatShareDraftQueue = nodeApp.chatShareDraftQueue
|
||||
|
||||
// One bounded heap-only slot follows the ViewModel across Activity recreation.
|
||||
// Detail disposal clears it; process death drops it with the ViewModel.
|
||||
internal val cronEditorDraftMemory = CronEditorDraftMemory()
|
||||
@@ -104,6 +171,7 @@ class MainViewModel(
|
||||
val startOnboardingAtGatewaySetup: StateFlow<Boolean> = _startOnboardingAtGatewaySetup
|
||||
private val _chatDraft = MutableStateFlow<ChatDraft?>(null)
|
||||
val chatDraft: StateFlow<ChatDraft?> = _chatDraft
|
||||
val chatShareDraft: StateFlow<ChatShareDraft?> = chatShareDraftQueue.head
|
||||
private val _pendingAssistantAutoSend = MutableStateFlow<String?>(null)
|
||||
val pendingAssistantAutoSend: StateFlow<String?> = _pendingAssistantAutoSend
|
||||
private val _assistantAutoSendInFlight = MutableStateFlow(false)
|
||||
@@ -563,6 +631,7 @@ class MainViewModel(
|
||||
/** Routes assistant intents into chat, either as a draft or queued auto-send prompt. */
|
||||
fun handleAssistantLaunch(request: AssistantLaunchRequest) {
|
||||
_requestedHomeDestination.value = HomeDestination.Chat
|
||||
chatShareDraftQueue.clear()
|
||||
if (request.autoSend) {
|
||||
_pendingAssistantAutoSend.value = request.prompt
|
||||
_chatDraft.value = null
|
||||
@@ -572,6 +641,24 @@ class MainViewModel(
|
||||
_chatDraft.value = request.prompt?.let { ChatDraft(text = it, placement = ChatDraftPlacement.Replace) }
|
||||
}
|
||||
|
||||
/** Opens shared content as a fresh composer draft; sending still requires an explicit tap. */
|
||||
fun handleShareLaunch(request: ShareLaunchRequest): Boolean {
|
||||
val accepted =
|
||||
chatShareDraftQueue.enqueue(
|
||||
ChatShareDraft(
|
||||
id = chatShareDraftSeq.incrementAndGet(),
|
||||
text = request.text,
|
||||
imageUris = request.imageUris,
|
||||
droppedImageCount = request.droppedImageCount,
|
||||
),
|
||||
)
|
||||
if (!accepted) return false
|
||||
_requestedHomeDestination.value = HomeDestination.Chat
|
||||
_pendingAssistantAutoSend.value = null
|
||||
_chatDraft.value = null
|
||||
return true
|
||||
}
|
||||
|
||||
fun clearRequestedHomeDestination() {
|
||||
_requestedHomeDestination.value = null
|
||||
}
|
||||
@@ -584,6 +671,15 @@ class MainViewModel(
|
||||
_chatDraft.value = null
|
||||
}
|
||||
|
||||
fun acknowledgeChatShareDraft(id: Long) {
|
||||
chatShareDraftQueue.acknowledgeHead(id)
|
||||
}
|
||||
|
||||
suspend fun withChatShareDraftLease(
|
||||
id: Long,
|
||||
block: suspend () -> Unit,
|
||||
): Boolean = chatShareDraftQueue.withHeadLease(id, block)
|
||||
|
||||
fun setChatReplyDraft(value: String) {
|
||||
_pendingAssistantAutoSend.value = null
|
||||
_chatDraft.value = ChatDraft(text = value, placement = ChatDraftPlacement.BeforeExisting)
|
||||
|
||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Android Application singleton that owns process-wide secure prefs and lazy NodeRuntime startup.
|
||||
@@ -19,6 +20,10 @@ import kotlinx.coroutines.runBlocking
|
||||
class NodeApp : Application() {
|
||||
val prefs: SecurePrefs by lazy { SecurePrefs(this) }
|
||||
|
||||
// System share senders can create overlapping Activity tasks; keep one bounded process queue.
|
||||
internal val chatShareDraftSeq = AtomicLong()
|
||||
internal val chatShareDraftQueue = ChatShareDraftQueue()
|
||||
|
||||
private val runtimeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val runtimeLock = Any()
|
||||
private var runtimeInstance: NodeRuntime? = null
|
||||
|
||||
@@ -2,6 +2,11 @@ package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.ChatDraft
|
||||
import ai.openclaw.app.ChatDraftPlacement
|
||||
import ai.openclaw.app.ChatShareDraft
|
||||
import ai.openclaw.app.chat.OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES
|
||||
import ai.openclaw.app.chat.VoiceNoteRecorderState
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
internal fun mergeChatDraft(
|
||||
draft: ChatDraft?,
|
||||
@@ -13,3 +18,135 @@ internal fun mergeChatDraft(
|
||||
ChatDraftPlacement.BeforeExisting -> text + currentInput
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends system shares so existing drafts stay first and queued shares remain FIFO. */
|
||||
internal fun mergeSharedChatText(
|
||||
sharedText: String?,
|
||||
currentInput: String,
|
||||
): String {
|
||||
val shared = sharedText?.trim()?.takeIf { it.isNotEmpty() } ?: return currentInput
|
||||
return if (currentInput.isEmpty()) shared else listOf(currentInput, shared).joinToString(separator = "\n\n")
|
||||
}
|
||||
|
||||
internal data class StagedChatShare(
|
||||
val text: String?,
|
||||
val attachments: List<PendingAttachment>,
|
||||
val failedImageCount: Int,
|
||||
val droppedImageCount: Int,
|
||||
)
|
||||
|
||||
internal const val CHAT_COMPOSER_MAX_ATTACHMENTS = 8
|
||||
internal const val CHAT_COMPOSER_MAX_DECODED_ATTACHMENT_BYTES = OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES
|
||||
internal const val CHAT_COMPOSER_MAX_BASE64_CHARS = ((CHAT_COMPOSER_MAX_DECODED_ATTACHMENT_BYTES + 2) / 3) * 4
|
||||
|
||||
internal data class ChatAttachmentAdmission(
|
||||
val accepted: List<PendingAttachment>,
|
||||
val omittedCount: Int,
|
||||
)
|
||||
|
||||
internal fun admitChatAttachments(
|
||||
currentAttachments: List<PendingAttachment>,
|
||||
candidates: List<PendingAttachment>,
|
||||
maxAttachmentCount: Int = CHAT_COMPOSER_MAX_ATTACHMENTS,
|
||||
maxBase64Chars: Long = CHAT_COMPOSER_MAX_BASE64_CHARS,
|
||||
maxDecodedBytes: Long = CHAT_COMPOSER_MAX_DECODED_ATTACHMENT_BYTES,
|
||||
): ChatAttachmentAdmission {
|
||||
require(maxAttachmentCount >= 0 && maxBase64Chars >= 0 && maxDecodedBytes >= 0)
|
||||
val accepted = mutableListOf<PendingAttachment>()
|
||||
var base64Chars = currentAttachments.sumOf { it.base64.length.toLong() }
|
||||
var decodedBytes = currentAttachments.sumOf { decodedBase64ByteCount(it.base64) }
|
||||
var omittedCount = 0
|
||||
for (candidate in candidates) {
|
||||
val candidateBase64Chars = candidate.base64.length.toLong()
|
||||
val candidateDecodedBytes = decodedBase64ByteCount(candidate.base64)
|
||||
val withinCount = currentAttachments.size + accepted.size < maxAttachmentCount
|
||||
val withinBase64 = candidateBase64Chars <= maxBase64Chars - base64Chars
|
||||
val withinDecoded = candidateDecodedBytes <= maxDecodedBytes - decodedBytes
|
||||
if (withinCount && withinBase64 && withinDecoded) {
|
||||
accepted += candidate
|
||||
base64Chars += candidateBase64Chars
|
||||
decodedBytes += candidateDecodedBytes
|
||||
} else {
|
||||
omittedCount += 1
|
||||
}
|
||||
}
|
||||
return ChatAttachmentAdmission(accepted = accepted, omittedCount = omittedCount)
|
||||
}
|
||||
|
||||
private fun decodedBase64ByteCount(base64: String): Long {
|
||||
val padding =
|
||||
when {
|
||||
base64.endsWith("==") -> 2
|
||||
base64.endsWith('=') -> 1
|
||||
else -> 0
|
||||
}
|
||||
return ((base64.length.toLong() * 3) / 4 - padding).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
/** Loads a complete queue head before any part of it becomes visible in the composer. */
|
||||
internal suspend fun stageChatShareDraft(
|
||||
draft: ChatShareDraft,
|
||||
currentAttachments: List<PendingAttachment>,
|
||||
loadImage: suspend (Uri) -> PendingAttachment,
|
||||
): StagedChatShare {
|
||||
val attachments = mutableListOf<PendingAttachment>()
|
||||
var failedImageCount = 0
|
||||
var droppedImageCount = draft.droppedImageCount
|
||||
for (uri in draft.imageUris) {
|
||||
try {
|
||||
val candidate = loadImage(uri)
|
||||
val admission = admitChatAttachments(currentAttachments + attachments, listOf(candidate))
|
||||
attachments += admission.accepted
|
||||
droppedImageCount += admission.omittedCount
|
||||
} catch (error: CancellationException) {
|
||||
// Screen disposal must leave the queue head unacknowledged for the next ChatScreen.
|
||||
throw error
|
||||
} catch (_: Exception) {
|
||||
failedImageCount += 1
|
||||
}
|
||||
}
|
||||
return StagedChatShare(
|
||||
text = draft.text,
|
||||
attachments = attachments,
|
||||
failedImageCount = failedImageCount,
|
||||
droppedImageCount = droppedImageCount,
|
||||
)
|
||||
}
|
||||
|
||||
internal data class ChatShareComposerMerge(
|
||||
val input: String,
|
||||
val attachments: List<PendingAttachment>,
|
||||
val failedImageCount: Int,
|
||||
val droppedImageCount: Int,
|
||||
)
|
||||
|
||||
internal fun mergeStagedChatShare(
|
||||
staged: StagedChatShare,
|
||||
currentInput: String,
|
||||
currentAttachments: List<PendingAttachment>,
|
||||
): ChatShareComposerMerge {
|
||||
val admission = admitChatAttachments(currentAttachments, staged.attachments)
|
||||
return ChatShareComposerMerge(
|
||||
input = mergeSharedChatText(sharedText = staged.text, currentInput = currentInput),
|
||||
attachments = currentAttachments + admission.accepted,
|
||||
failedImageCount = staged.failedImageCount,
|
||||
droppedImageCount = staged.droppedImageCount + admission.omittedCount,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun canCommitStagedChatShare(
|
||||
stagedId: Long,
|
||||
currentHead: ChatShareDraft?,
|
||||
): Boolean = currentHead?.id == stagedId
|
||||
|
||||
internal fun chatComposerSendEnabled(
|
||||
voiceNoteState: VoiceNoteRecorderState,
|
||||
pendingRunCount: Int,
|
||||
hasContent: Boolean,
|
||||
shareStaging: Boolean,
|
||||
): Boolean =
|
||||
!shareStaging &&
|
||||
voiceNoteState !is VoiceNoteRecorderState.Recording &&
|
||||
voiceNoteState !is VoiceNoteRecorderState.Preparing &&
|
||||
pendingRunCount == 0 &&
|
||||
hasContent
|
||||
|
||||
@@ -103,6 +103,8 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -158,6 +160,7 @@ fun ChatScreen(
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val chatCommands by viewModel.chatCommands.collectAsState()
|
||||
val chatDraft by viewModel.chatDraft.collectAsState()
|
||||
val chatShareDraft by viewModel.chatShareDraft.collectAsState()
|
||||
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
|
||||
val assistantAutoSendInFlight by viewModel.assistantAutoSendInFlight.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
@@ -189,6 +192,8 @@ fun ChatScreen(
|
||||
val activeAgentId = selectedChatAgentId(mainSessionKey, gatewayDefaultAgentId)
|
||||
val workspaceGit = gatewayAgents.firstOrNull { it.id == sessionAgentId }?.workspaceGit == true
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val lifecycleState by lifecycleOwner.lifecycle.currentStateFlow.collectAsState()
|
||||
val resolver = context.contentResolver
|
||||
val scope = rememberCoroutineScope()
|
||||
val attachments = remember { mutableStateListOf<PendingAttachment>() }
|
||||
@@ -261,12 +266,53 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
var input by rememberSaveable { mutableStateOf("") }
|
||||
var shareImportNotice by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(chatDraft) {
|
||||
input = mergeChatDraft(chatDraft, input) ?: return@LaunchedEffect
|
||||
viewModel.clearChatDraft()
|
||||
}
|
||||
|
||||
LaunchedEffect(chatShareDraft?.id, lifecycleState) {
|
||||
if (!lifecycleState.isAtLeast(Lifecycle.State.RESUMED)) return@LaunchedEffect
|
||||
val share = chatShareDraft ?: return@LaunchedEffect
|
||||
viewModel.withChatShareDraftLease(share.id) {
|
||||
val attachmentSnapshot = attachments.toList()
|
||||
val staged =
|
||||
withContext(Dispatchers.IO) {
|
||||
stageChatShareDraft(share, currentAttachments = attachmentSnapshot) { uri ->
|
||||
loadSizedImageAttachment(resolver, uri)
|
||||
}
|
||||
}
|
||||
val merged =
|
||||
mergeStagedChatShare(
|
||||
staged = staged,
|
||||
currentInput = input,
|
||||
currentAttachments = attachments,
|
||||
)
|
||||
if (!canCommitStagedChatShare(stagedId = share.id, currentHead = viewModel.chatShareDraft.value)) {
|
||||
return@withChatShareDraftLease
|
||||
}
|
||||
// A non-resumed Activity must not acknowledge into its hidden composer; the next visible
|
||||
// Activity keeps the process-owned head and retries the complete import instead.
|
||||
if (!lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
return@withChatShareDraftLease
|
||||
}
|
||||
// Keep the head pending through both mutations: Send stays gated until text and images
|
||||
// have been merged together, and disposal before this point leaves the head for retry.
|
||||
input = merged.input
|
||||
attachments.clear()
|
||||
attachments.addAll(merged.attachments)
|
||||
shareImportNotice =
|
||||
if (merged.failedImageCount + merged.droppedImageCount > 0) {
|
||||
"Some shared images were omitted or could not be added."
|
||||
} else {
|
||||
null
|
||||
}
|
||||
viewModel.acknowledgeChatShareDraft(share.id)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(gatewayConnectionDisplay.isConnected) {
|
||||
if (!gatewayConnectionDisplay.isConnected) {
|
||||
showModelPicker = false
|
||||
@@ -374,6 +420,9 @@ fun ChatScreen(
|
||||
gatewayOffline = gatewayOffline,
|
||||
offlineStatus = offlineStatus,
|
||||
pendingRunCount = pendingRunCount,
|
||||
shareStaging = chatShareDraft != null,
|
||||
shareImportNotice = shareImportNotice,
|
||||
onDismissShareImportNotice = { shareImportNotice = null },
|
||||
commands = chatCommands,
|
||||
onThinkingLevelChange = viewModel::setChatThinkingLevel,
|
||||
onOpenModelPicker = { showModelPicker = true },
|
||||
@@ -398,8 +447,11 @@ fun ChatScreen(
|
||||
},
|
||||
onAbort = viewModel::abortChat,
|
||||
onSend = {
|
||||
// Re-read the ViewModel so a stale click callback cannot beat StateFlow recomposition.
|
||||
if (viewModel.chatShareDraft.value != null) return@ChatComposer
|
||||
val message = input.trim()
|
||||
if (message.isEmpty() && attachments.isEmpty()) return@ChatComposer
|
||||
shareImportNotice = null
|
||||
val outgoing = attachments.map(PendingAttachment::toOutgoingAttachment)
|
||||
val pendingAttachments = attachments.toList()
|
||||
input = ""
|
||||
@@ -1122,6 +1174,9 @@ private fun ChatComposer(
|
||||
gatewayOffline: Boolean,
|
||||
offlineStatus: String,
|
||||
pendingRunCount: Int,
|
||||
shareStaging: Boolean,
|
||||
shareImportNotice: String?,
|
||||
onDismissShareImportNotice: () -> Unit,
|
||||
commands: List<ChatCommandEntry>,
|
||||
onThinkingLevelChange: (String) -> Unit,
|
||||
onOpenModelPicker: () -> Unit,
|
||||
@@ -1152,12 +1207,31 @@ private fun ChatComposer(
|
||||
// Offline sends queue durably too (text, images, and voice notes), so the gate is identical
|
||||
// to the connected one; admission errors keep the draft when the durable queue refuses it.
|
||||
val sendEnabled =
|
||||
voiceNoteState !is VoiceNoteRecorderState.Recording &&
|
||||
voiceNoteState !is VoiceNoteRecorderState.Preparing &&
|
||||
pendingRunCount == 0 &&
|
||||
(value.trim().isNotEmpty() || attachments.isNotEmpty())
|
||||
chatComposerSendEnabled(
|
||||
voiceNoteState = voiceNoteState,
|
||||
pendingRunCount = pendingRunCount,
|
||||
hasContent = value.trim().isNotEmpty() || attachments.isNotEmpty(),
|
||||
shareStaging = shareStaging,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().imePadding(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (shareImportNotice != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = shareImportNotice,
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.warning,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onDismissShareImportNotice, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Dismiss shared-image warning")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (attachments.isNotEmpty()) {
|
||||
AttachmentStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,20 @@ class MainActivityLifecycleTest {
|
||||
assertEquals(listOf(next), routed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingIntentRouterQueuesRapidColdStartShares() {
|
||||
val router = MainActivityPendingIntentRouter()
|
||||
val first = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, "first")
|
||||
val second = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, "second")
|
||||
val routed = mutableListOf<Intent>()
|
||||
|
||||
router.setInitialIntent(first)
|
||||
assertTrue(router.onNewIntent(second, routed::add))
|
||||
|
||||
assertTrue(router.activate(routed::add))
|
||||
assertEquals(listOf(first, second), routed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingIntentRouterDiscardsOnlyRecreatedInitialIntent() {
|
||||
val router = MainActivityPendingIntentRouter()
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ShareLaunchTest {
|
||||
@Test
|
||||
fun composesDistinctSubjectAndTextForReview() {
|
||||
val parsed =
|
||||
parseShareLaunchIntent(
|
||||
Intent(Intent.ACTION_SEND)
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_SUBJECT, "Article title")
|
||||
.putExtra(Intent.EXTRA_TEXT, "https://example.com/article"),
|
||||
)
|
||||
|
||||
requireNotNull(parsed)
|
||||
assertEquals("Article title\n\nhttps://example.com/article", parsed.text)
|
||||
assertEquals(emptyList<Uri>(), parsed.imageUris)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsCaptionAndProviderBackedImage() {
|
||||
val image = Uri.parse("content://photos/shared/1")
|
||||
val parsed =
|
||||
parseShareLaunchIntent(
|
||||
Intent(Intent.ACTION_SEND)
|
||||
.setType("image/png")
|
||||
.putExtra(Intent.EXTRA_TEXT, "What is in this image?")
|
||||
.putExtra(Intent.EXTRA_STREAM, image),
|
||||
)
|
||||
|
||||
requireNotNull(parsed)
|
||||
assertEquals("What is in this image?", parsed.text)
|
||||
assertEquals(listOf(image), parsed.imageUris)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsProviderBackedImageFromClipData() {
|
||||
val image = Uri.parse("content://photos/shared/clip")
|
||||
val parsed =
|
||||
parseShareLaunchIntent(
|
||||
Intent(Intent.ACTION_SEND)
|
||||
.setType("IMAGE/PNG")
|
||||
.apply {
|
||||
clipData = ClipData("shared", arrayOf("image/png"), ClipData.Item(image))
|
||||
},
|
||||
)
|
||||
|
||||
requireNotNull(parsed)
|
||||
assertEquals(listOf(image), parsed.imageUris)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicatesAndBoundsMultipleImagesAcrossExtrasAndClipData() {
|
||||
val images = (1..10).map { index -> Uri.parse("content://photos/shared/$index") }
|
||||
val parsed =
|
||||
parseShareLaunchIntent(
|
||||
Intent(Intent.ACTION_SEND_MULTIPLE)
|
||||
.setType("image/jpeg")
|
||||
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(images))
|
||||
.apply {
|
||||
clipData = ClipData("shared", arrayOf("image/jpeg"), ClipData.Item(images.first()))
|
||||
},
|
||||
)
|
||||
|
||||
requireNotNull(parsed)
|
||||
assertEquals(images.take(8), parsed.imageUris)
|
||||
assertEquals(2, parsed.droppedImageCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsFileUrisAndEmptyOrUnrelatedIntents() {
|
||||
assertNull(
|
||||
parseShareLaunchIntent(
|
||||
Intent(Intent.ACTION_SEND)
|
||||
.setType("image/jpeg")
|
||||
.putExtra(Intent.EXTRA_STREAM, Uri.parse("file:///data/data/ai.openclaw.app/private.jpg")),
|
||||
),
|
||||
)
|
||||
assertNull(parseShareLaunchIntent(Intent(Intent.ACTION_SEND).setType("text/plain")))
|
||||
assertNull(parseShareLaunchIntent(Intent(Intent.ACTION_VIEW)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rapidSharesKeepStableHeadUntilMatchingAcknowledgement() {
|
||||
val queue = ChatShareDraftQueue(capacity = 2)
|
||||
val first = ChatShareDraft(id = 1, text = "first", imageUris = emptyList(), droppedImageCount = 0)
|
||||
val second = ChatShareDraft(id = 2, text = "second", imageUris = emptyList(), droppedImageCount = 0)
|
||||
|
||||
assertTrue(queue.enqueue(first))
|
||||
assertTrue(queue.enqueue(second))
|
||||
assertEquals(first, queue.head.value)
|
||||
assertFalse(queue.acknowledgeHead(second.id))
|
||||
assertEquals(first, queue.head.value)
|
||||
|
||||
assertTrue(queue.acknowledgeHead(first.id))
|
||||
assertEquals(second, queue.head.value)
|
||||
assertTrue(queue.acknowledgeHead(second.id))
|
||||
assertNull(queue.head.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingShareQueueIsBoundedWithoutReplacingItsHead() {
|
||||
val queue = ChatShareDraftQueue(capacity = 1)
|
||||
val first = ChatShareDraft(id = 1, text = "first", imageUris = emptyList(), droppedImageCount = 0)
|
||||
val overflow = ChatShareDraft(id = 2, text = "overflow", imageUris = emptyList(), droppedImageCount = 0)
|
||||
|
||||
assertTrue(queue.enqueue(first))
|
||||
assertFalse(queue.enqueue(overflow))
|
||||
assertEquals(1, queue.size())
|
||||
assertEquals(first, queue.head.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlappingActivityLoadersCannotCommitTheSameHead() =
|
||||
runBlocking {
|
||||
val queue = ChatShareDraftQueue(capacity = 2)
|
||||
val first = ChatShareDraft(id = 1, text = "first", imageUris = emptyList(), droppedImageCount = 0)
|
||||
val next = ChatShareDraft(id = 2, text = "second", imageUris = emptyList(), droppedImageCount = 0)
|
||||
queue.enqueue(first)
|
||||
queue.enqueue(next)
|
||||
val entered = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
|
||||
val firstLoader =
|
||||
async {
|
||||
queue.withHeadLease(first.id) {
|
||||
entered.complete(Unit)
|
||||
release.await()
|
||||
assertTrue(queue.acknowledgeHead(first.id))
|
||||
}
|
||||
}
|
||||
entered.await()
|
||||
var staleLoaderRan = false
|
||||
val staleLoader =
|
||||
async {
|
||||
queue.withHeadLease(first.id) {
|
||||
staleLoaderRan = true
|
||||
}
|
||||
}
|
||||
release.complete(Unit)
|
||||
|
||||
assertTrue(firstLoader.await())
|
||||
assertFalse(staleLoader.await())
|
||||
assertFalse(staleLoaderRan)
|
||||
assertEquals(next, queue.head.value)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,22 @@ package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.ChatDraft
|
||||
import ai.openclaw.app.ChatDraftPlacement
|
||||
import ai.openclaw.app.ChatShareDraft
|
||||
import ai.openclaw.app.chat.VoiceNoteRecorderState
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ChatComposerDraftTest {
|
||||
@Test
|
||||
fun replyDraftPreservesExistingComposerText() {
|
||||
@@ -19,4 +32,198 @@ class ChatComposerDraftTest {
|
||||
|
||||
assertEquals("repeat this", mergeChatDraft(draft, "existing text"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sharedTextPreservesExistingComposerText() {
|
||||
assertEquals(
|
||||
"existing draft\n\nshared link",
|
||||
mergeSharedChatText(sharedText = "shared link", currentInput = "existing draft"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queuedSharedTextPreservesArrivalOrder() {
|
||||
val first = mergeSharedChatText(sharedText = "first", currentInput = "")
|
||||
|
||||
assertEquals("first\n\nsecond", mergeSharedChatText(sharedText = "second", currentInput = first))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imageOnlyShareLeavesExistingComposerTextUntouched() {
|
||||
assertEquals(
|
||||
"existing draft",
|
||||
mergeSharedChatText(sharedText = null, currentInput = "existing draft"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stagedSharePreservesComposerAndReportsDroppedImages() {
|
||||
val existing = pendingAttachment("existing")
|
||||
val shared = pendingAttachment("shared")
|
||||
val staged =
|
||||
StagedChatShare(
|
||||
text = "shared link",
|
||||
attachments = listOf(shared),
|
||||
failedImageCount = 0,
|
||||
droppedImageCount = 2,
|
||||
)
|
||||
|
||||
val merged =
|
||||
mergeStagedChatShare(
|
||||
staged = staged,
|
||||
currentInput = "existing draft",
|
||||
currentAttachments = listOf(existing),
|
||||
)
|
||||
|
||||
assertEquals("existing draft\n\nshared link", merged.input)
|
||||
assertEquals(listOf(existing, shared), merged.attachments)
|
||||
assertEquals(0, merged.failedImageCount)
|
||||
assertEquals(2, merged.droppedImageCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unreadableSharedImageDoesNotDiscardOtherStagedContent() =
|
||||
runBlocking {
|
||||
val readable = Uri.parse("content://photos/readable")
|
||||
val unreadable = Uri.parse("content://photos/unreadable")
|
||||
val draft =
|
||||
ChatShareDraft(
|
||||
id = 1,
|
||||
text = "caption",
|
||||
imageUris = listOf(readable, unreadable),
|
||||
droppedImageCount = 0,
|
||||
)
|
||||
|
||||
val staged =
|
||||
stageChatShareDraft(draft, currentAttachments = emptyList()) { uri ->
|
||||
if (uri == unreadable) error("provider read failed")
|
||||
pendingAttachment(uri.toString())
|
||||
}
|
||||
|
||||
assertEquals("caption", staged.text)
|
||||
assertEquals(listOf(readable.toString()), staged.attachments.map { it.id })
|
||||
assertEquals(1, staged.failedImageCount)
|
||||
assertEquals(0, staged.droppedImageCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun screenDisposalCancellationLeavesShareUnstaged() {
|
||||
val draft =
|
||||
ChatShareDraft(
|
||||
id = 1,
|
||||
text = null,
|
||||
imageUris = listOf(Uri.parse("content://photos/slow")),
|
||||
droppedImageCount = 0,
|
||||
)
|
||||
|
||||
assertThrows(CancellationException::class.java) {
|
||||
runBlocking {
|
||||
stageChatShareDraft(draft, currentAttachments = emptyList()) { throw CancellationException("screen disposed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedSharesRespectExistingComposerAttachmentLimit() =
|
||||
runBlocking {
|
||||
val current = (1..7).map { pendingAttachment("existing-$it") }
|
||||
val uris = (1..3).map { Uri.parse("content://photos/shared/$it") }
|
||||
val draft = ChatShareDraft(id = 1, text = null, imageUris = uris, droppedImageCount = 0)
|
||||
|
||||
val staged =
|
||||
stageChatShareDraft(draft, currentAttachments = current) { uri ->
|
||||
pendingAttachment(uri.toString())
|
||||
}
|
||||
|
||||
assertEquals(listOf(uris.first().toString()), staged.attachments.map { it.id })
|
||||
assertEquals(2, staged.droppedImageCount)
|
||||
val merged = mergeStagedChatShare(staged, currentInput = "", currentAttachments = current)
|
||||
assertEquals(CHAT_COMPOSER_MAX_ATTACHMENTS, merged.attachments.size)
|
||||
assertEquals(2, merged.droppedImageCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeRechecksAttachmentBudgetAfterStaging() {
|
||||
val staged =
|
||||
StagedChatShare(
|
||||
text = null,
|
||||
attachments = listOf(pendingAttachment("one"), pendingAttachment("two")),
|
||||
failedImageCount = 0,
|
||||
droppedImageCount = 0,
|
||||
)
|
||||
val current = (1..7).map { pendingAttachment("existing-$it") }
|
||||
|
||||
val merged = mergeStagedChatShare(staged, currentInput = "", currentAttachments = current)
|
||||
|
||||
assertEquals(CHAT_COMPOSER_MAX_ATTACHMENTS, merged.attachments.size)
|
||||
assertEquals(1, merged.droppedImageCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachmentAdmissionEnforcesBase64AndDecodedBudgets() {
|
||||
val candidates = listOf(pendingAttachment("one", base64 = "AAAA"), pendingAttachment("two", base64 = "AAAA"))
|
||||
|
||||
val base64Bound =
|
||||
admitChatAttachments(
|
||||
currentAttachments = emptyList(),
|
||||
candidates = candidates,
|
||||
maxAttachmentCount = 8,
|
||||
maxBase64Chars = 4,
|
||||
maxDecodedBytes = 100,
|
||||
)
|
||||
val decodedBound =
|
||||
admitChatAttachments(
|
||||
currentAttachments = emptyList(),
|
||||
candidates = candidates,
|
||||
maxAttachmentCount = 8,
|
||||
maxBase64Chars = 100,
|
||||
maxDecodedBytes = 3,
|
||||
)
|
||||
|
||||
assertEquals(listOf(candidates.first()), base64Bound.accepted)
|
||||
assertEquals(1, base64Bound.omittedCount)
|
||||
assertEquals(listOf(candidates.first()), decodedBound.accepted)
|
||||
assertEquals(1, decodedBound.omittedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stagedShareCommitsOnlyForMatchingQueueHead() {
|
||||
val current = ChatShareDraft(id = 7, text = "current", imageUris = emptyList(), droppedImageCount = 0)
|
||||
val replacement = ChatShareDraft(id = 8, text = "replacement", imageUris = emptyList(), droppedImageCount = 0)
|
||||
|
||||
assertTrue(canCommitStagedChatShare(stagedId = current.id, currentHead = current))
|
||||
assertFalse(canCommitStagedChatShare(stagedId = current.id, currentHead = replacement))
|
||||
assertFalse(canCommitStagedChatShare(stagedId = current.id, currentHead = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendIsDisabledWhileShareHeadStages() {
|
||||
assertFalse(
|
||||
chatComposerSendEnabled(
|
||||
voiceNoteState = VoiceNoteRecorderState.Idle,
|
||||
pendingRunCount = 0,
|
||||
hasContent = true,
|
||||
shareStaging = true,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
chatComposerSendEnabled(
|
||||
voiceNoteState = VoiceNoteRecorderState.Idle,
|
||||
pendingRunCount = 0,
|
||||
hasContent = true,
|
||||
shareStaging = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun pendingAttachment(
|
||||
id: String,
|
||||
base64: String = id,
|
||||
): PendingAttachment =
|
||||
PendingAttachment(
|
||||
id = id,
|
||||
fileName = "$id.jpg",
|
||||
mimeType = "image/jpeg",
|
||||
base64 = base64,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user