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