diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json
index 0432aa6cc145..81ab015f1238 100644
--- a/apps/.i18n/native-source.json
+++ b/apps/.i18n/native-source.json
@@ -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",
diff --git a/apps/android/CHANGELOG.md b/apps/android/CHANGELOG.md
index b8783e4cc322..60b0979e5258 100644
--- a/apps/android/CHANGELOG.md
+++ b/apps/android/CHANGELOG.md
@@ -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.
diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index 57b1458568a9..ef313b2c5c4a 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -73,9 +73,11 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt b/apps/android/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt
index 54ce5e7f5322..f4fda9062e75 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt
@@ -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,
+ val droppedImageCount: Int,
+)
+
+private data class SharedImageSelection(
+ val uris: List,
+ 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
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt
index d5f2a6839cc9..3b8ed4080ae5 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt
@@ -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()
+ 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
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt
index a2a98f1fe928..6fe21cc0371a 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt
@@ -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,
+ 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()
+ private val headLease = Mutex()
+ private val _head = MutableStateFlow(null)
+ val head: StateFlow = _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 = _startOnboardingAtGatewaySetup
private val _chatDraft = MutableStateFlow(null)
val chatDraft: StateFlow = _chatDraft
+ val chatShareDraft: StateFlow = chatShareDraftQueue.head
private val _pendingAssistantAutoSend = MutableStateFlow(null)
val pendingAssistantAutoSend: StateFlow = _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)
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt
index 7b095737f11e..99504171bd07 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt
@@ -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
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt
index 251221a8b9fb..c299fe960221 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt
@@ -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,
+ 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,
+ val omittedCount: Int,
+)
+
+internal fun admitChatAttachments(
+ currentAttachments: List,
+ candidates: List,
+ 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()
+ 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,
+ loadImage: suspend (Uri) -> PendingAttachment,
+): StagedChatShare {
+ val attachments = mutableListOf()
+ 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,
+ val failedImageCount: Int,
+ val droppedImageCount: Int,
+)
+
+internal fun mergeStagedChatShare(
+ staged: StagedChatShare,
+ currentInput: String,
+ currentAttachments: List,
+): 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
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt
index 4517b652fea3..bd5beda58a96 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt
@@ -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() }
@@ -261,12 +266,53 @@ fun ChatScreen(
}
var input by rememberSaveable { mutableStateOf("") }
+ var shareImportNotice by rememberSaveable { mutableStateOf(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,
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)
}
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt
index 6210fb40eeaf..290c81d33b0f 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt
@@ -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()
+
+ 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()
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ShareLaunchTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ShareLaunchTest.kt
new file mode 100644
index 000000000000..52b6fb6cef25
--- /dev/null
+++ b/apps/android/app/src/test/java/ai/openclaw/app/ShareLaunchTest.kt
@@ -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(), 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()
+ val release = CompletableDeferred()
+
+ 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)
+ }
+}
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt
index fe541f8523d3..d5bd58a793b9 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt
@@ -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,
+ )
}