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:
NianJiu
2026-07-12 07:44:34 +08:00
committed by GitHub
parent 1a1d7aa45e
commit d1f614c9e6
12 changed files with 898 additions and 64 deletions

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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