diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index b3de3922358..4c4e996a5d5 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4973,6 +4973,8 @@ public final class io/getstream/chat/android/compose/viewmodel/mentions/MentionL public final class io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel : androidx/lifecycle/ViewModel { public static final field $stable I public fun (Lio/getstream/chat/android/compose/ui/util/StorageHelperWrapper;Lkotlinx/coroutines/flow/StateFlow;)V + public fun (Lio/getstream/chat/android/compose/ui/util/StorageHelperWrapper;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;)V + public synthetic fun (Lio/getstream/chat/android/compose/ui/util/StorageHelperWrapper;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun changeAttachmentPickerMode (Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentsPickerMode;Lkotlin/jvm/functions/Function0;)V public static synthetic fun changeAttachmentPickerMode$default (Lio/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel;Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentsPickerMode;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V public final fun changeAttachmentState (Z)V @@ -5135,6 +5137,7 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/Messages public fun (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/client/setup/state/ClientState;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/UserLookupHandler;Lkotlin/jvm/functions/Function1;ILio/getstream/chat/android/ui/common/helper/ClipboardHandler;ZIZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFooterVisibility;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/MessagePositionHandler;ZZZZZ)V public synthetic fun (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/client/setup/state/ClientState;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/UserLookupHandler;Lkotlin/jvm/functions/Function1;ILio/getstream/chat/android/ui/common/helper/ClipboardHandler;ZIZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFooterVisibility;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/MessagePositionHandler;ZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; + public fun create (Ljava/lang/Class;Landroidx/lifecycle/viewmodel/CreationExtras;)Landroidx/lifecycle/ViewModel; } public final class io/getstream/chat/android/compose/viewmodel/messages/PollResultsViewModel : androidx/lifecycle/ViewModel { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerMediaCaptureTabFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerMediaCaptureTabFactory.kt index 54b082db161..acbfcb22f7e 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerMediaCaptureTabFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerMediaCaptureTabFactory.kt @@ -17,13 +17,15 @@ package io.getstream.chat.android.compose.ui.messages.attachments.factory import android.Manifest -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -36,11 +38,10 @@ import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerItemState import io.getstream.chat.android.compose.state.messages.attachments.AttachmentsPickerMode import io.getstream.chat.android.compose.state.messages.attachments.MediaCapture +import io.getstream.chat.android.compose.ui.messages.attachments.media.rememberCancelAwareCaptureMediaLauncher import io.getstream.chat.android.compose.ui.theme.ChatTheme -import io.getstream.chat.android.ui.common.contract.internal.CaptureMediaContract import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData import io.getstream.chat.android.ui.common.utils.isPermissionDeclared -import java.io.File /** * Holds the information required to add support for "media capture" tab in the attachment picker. @@ -101,22 +102,27 @@ public class AttachmentsPickerMediaCaptureTabFactory(private val pickerMediaMode val cameraPermissionState = if (requiresCameraPermission) rememberPermissionState(permission = Manifest.permission.CAMERA) else null - val contract = remember { CaptureMediaContract(pickerMediaMode.mode) } - val mediaCaptureResultLauncher = - rememberLauncherForActivityResult(contract = contract) { file: File? -> - val attachments = if (file == null) { - emptyList() - } else { - listOf(AttachmentMetaData(context, file)) - } - - onAttachmentsSubmitted(attachments) + val mediaCaptureResultLauncher = rememberCancelAwareCaptureMediaLauncher( + photo = pickerMediaMode == PickerMediaMode.PHOTO || pickerMediaMode == PickerMediaMode.PHOTO_AND_VIDEO, + video = pickerMediaMode == PickerMediaMode.VIDEO || pickerMediaMode == PickerMediaMode.PHOTO_AND_VIDEO, + ) { file -> + val attachments = if (file == null) { + emptyList() + } else { + listOf(AttachmentMetaData(context, file)) } + onAttachmentsSubmitted(attachments) + } + + var hasLaunched by rememberSaveable { mutableStateOf(false) } if (cameraPermissionState == null || cameraPermissionState.status == PermissionStatus.Granted) { Box(modifier = Modifier.fillMaxSize()) { LaunchedEffect(Unit) { - mediaCaptureResultLauncher.launch(Unit) + if (!hasLaunched) { + hasLaunched = true + mediaCaptureResultLauncher?.launch(Unit) + } } } } else if (cameraPermissionState.status is PermissionStatus.Denied) { @@ -132,14 +138,4 @@ public class AttachmentsPickerMediaCaptureTabFactory(private val pickerMediaMode VIDEO, PHOTO_AND_VIDEO, } - - /** - * Map [PickerMediaMode] into [CaptureMediaContract.Mode] - */ - private val PickerMediaMode.mode: CaptureMediaContract.Mode - get() = when (this) { - PickerMediaMode.PHOTO -> CaptureMediaContract.Mode.PHOTO - PickerMediaMode.VIDEO -> CaptureMediaContract.Mode.VIDEO - PickerMediaMode.PHOTO_AND_VIDEO -> CaptureMediaContract.Mode.PHOTO_AND_VIDEO - } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/media/CaptureMediaLauncher.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/media/CaptureMediaLauncher.kt index 3d8364380dc..6d1f029a128 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/media/CaptureMediaLauncher.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/media/CaptureMediaLauncher.kt @@ -19,12 +19,22 @@ package io.getstream.chat.android.compose.ui.messages.attachments.media import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import io.getstream.chat.android.ui.common.contract.internal.CaptureMediaContract import java.io.File /** - * Creates and remembers a launcher for capturing media (photo and/or video) using the device camera. + * Creates and remembers a process-death-safe launcher for capturing media (photo and/or video) + * using the device camera. + * + * Destination file paths are persisted via [rememberSaveable]. When the hosting activity is + * destroyed and recreated (e.g. under "Don't keep activities"), the paths are restored on the + * [CaptureMediaContract] before the pending result is delivered, ensuring the captured file is + * correctly resolved. * * @param photo If `true`, enables photo capture capability. When both [photo] and [video] are * `true`, the user will be able to capture both types of media. @@ -62,12 +72,52 @@ public fun rememberCaptureMediaLauncher( photo: Boolean, video: Boolean, onResult: (File) -> Unit, +): ManagedActivityResultLauncher? = + rememberCaptureMediaLauncherInternal(photo, video) { file -> + file?.let(onResult) + } + +/** + * Internal cancel-aware variant of [rememberCaptureMediaLauncher]. + * + * Unlike the public API, this variant invokes [onResult] with `null` when the user cancels the + * capture, allowing callers to react to cancellation (e.g. dismiss the picker). + */ +@Composable +internal fun rememberCancelAwareCaptureMediaLauncher( + photo: Boolean, + video: Boolean, + onResult: (File?) -> Unit, +): ManagedActivityResultLauncher? = + rememberCaptureMediaLauncherInternal(photo, video, onResult) + +@Composable +private fun rememberCaptureMediaLauncherInternal( + photo: Boolean, + video: Boolean, + onResult: (File?) -> Unit, ): ManagedActivityResultLauncher? { - val contract = remember(photo, video) { - resolveMediaPickerMode(photo, video)?.let { CaptureMediaContract(it) } - } ?: return null + val mode = resolveMediaPickerMode(photo, video) ?: return null + + var pictureFilePath by rememberSaveable { mutableStateOf(null) } + var videoFilePath by rememberSaveable { mutableStateOf(null) } + + val contract = remember(mode) { + CaptureMediaContract(mode) { createdPicture, createdVideo -> + pictureFilePath = createdPicture?.absolutePath + videoFilePath = createdVideo?.absolutePath + } + } + + // Restore file references on the contract after process death. + // Runs during composition, before rememberLauncherForActivityResult re-registers + // in its DisposableEffect and dispatches pending results. + contract.restoreFilePaths(picturePath = pictureFilePath, videoPath = videoFilePath) + return rememberLauncherForActivityResult(contract) { file -> - file?.let(onResult) + onResult(file) + pictureFilePath = null + videoFilePath = null } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt index f45d63f88d0..e5193e13f41 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt @@ -20,6 +20,7 @@ import android.net.Uri import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.getstream.chat.android.client.channel.state.ChannelState @@ -45,10 +46,17 @@ import kotlinx.coroutines.withContext * * Used to load file and media images that are then connected to the UI. It also keeps state of the * selected items and prepares items before sending them. + * + * @param storageHelper Wrapper around storage utilities for loading media and files. + * @param channelState The state of the channel where attachments are being picked. + * @param savedStateHandle Handle for persisting state across process death. When provided by + * [MessagesViewModelFactory], it survives activity destruction (e.g. "Don't keep activities" mode), + * ensuring the picker UI is restored when the activity recreates. */ -public class AttachmentsPickerViewModel( +public class AttachmentsPickerViewModel @JvmOverloads constructor( private val storageHelper: StorageHelperWrapper, channelState: StateFlow, + private val savedStateHandle: SavedStateHandle = SavedStateHandle(), ) : ViewModel() { /** @@ -105,9 +113,23 @@ public class AttachmentsPickerViewModel( /** * Gives us information if we're showing the attachments picker or not. + * + * Backed by both [MutableState] (for Compose observation/recomposition) and [SavedStateHandle] + * (for persistence across process death). This ensures the picker remains visible after activity + * recreation (e.g. when returning from an external activity like the camera or file picker under + * "Don't keep activities" mode), which in turn allows `rememberLauncherForActivityResult` + * callbacks inside the picker composable tree to re-register and receive the pending result. */ - public var isShowingAttachments: Boolean by mutableStateOf(false) - private set + public var isShowingAttachments: Boolean + get() = _isShowingAttachments.value + private set(value) { + _isShowingAttachments.value = value + savedStateHandle[KEY_IS_SHOWING_ATTACHMENTS] = value + } + + private val _isShowingAttachments = mutableStateOf( + savedStateHandle.get(KEY_IS_SHOWING_ATTACHMENTS) ?: false, + ) /** * Loads all the items based on the current type. @@ -265,4 +287,8 @@ public class AttachmentsPickerViewModel( images = emptyList() files = emptyList() } + + private companion object { + private const val KEY_IS_SHOWING_ATTACHMENTS = "io.getstream.chat.isShowingAttachments" + } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt index d2f8cc06df7..e6fb406e7e1 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt @@ -19,8 +19,11 @@ package io.getstream.chat.android.compose.viewmodel.messages import android.content.ClipboardManager import android.content.Context import androidx.core.net.toUri +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.setup.state.ClientState @@ -178,4 +181,24 @@ public class MessagesViewModelFactory( @Suppress("UNCHECKED_CAST") return viewModel as T } + + /** + * Creates the required [ViewModel] with access to [CreationExtras], which provides a + * [SavedStateHandle] for persisting state across process death. + * + * Called by Compose's `viewModel()` helper. Falls back to [create] for ViewModels + * that do not require a [SavedStateHandle]. + */ + override fun create(modelClass: Class, extras: CreationExtras): T { + if (modelClass == AttachmentsPickerViewModel::class.java) { + val savedStateHandle = extras.createSavedStateHandle() + @Suppress("UNCHECKED_CAST") + return AttachmentsPickerViewModel( + storageHelper = StorageHelperWrapper(context), + channelState = channelStateFlow, + savedStateHandle = savedStateHandle, + ) as T + } + return create(modelClass) + } } diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt index eec17a93883..1e527207e27 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt @@ -17,11 +17,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Email import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/contract/internal/CaptureMediaContract.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/contract/internal/CaptureMediaContract.kt index 16cd554c614..664289a0a0b 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/contract/internal/CaptureMediaContract.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/contract/internal/CaptureMediaContract.kt @@ -37,20 +37,38 @@ import java.io.File * - Videos: `{externalFilesDir}/Movies/` * With fallback to cache directories if external storage is unavailable. * - * @param mode The capture mode determining what media types can be captured - * @param fileManager Manager for creating temporary files in external storage + * [pictureFile] and [videoFile] are set during [createIntent] and used by [parseResult] to resolve + * the captured file. After process death these references are lost; callers can restore them via + * [restoreFilePaths] before the pending result is dispatched. + * + * @param mode The capture mode determining what media types can be captured. + * @param fileManager Manager for creating temporary files in external storage. + * @param onFilesCreated Optional callback invoked inside [createIntent] after destination files + * are created, giving the caller an opportunity to persist the file references before the + * external activity starts. */ @InternalStreamChatApi public class CaptureMediaContract( private val mode: Mode, private val fileManager: StreamFileManager = StreamFileManager(), + private val onFilesCreated: ((pictureFile: File?, videoFile: File?) -> Unit)? = null, ) : ActivityResultContract() { + /** + * The destination file for a captured photo. + * Set by [createIntent]; can be restored externally after process death. + */ private var pictureFile: File? = null + + /** + * The destination file for a captured video. + * Set by [createIntent]; can be restored externally after process death. + */ private var videoFile: File? = null override fun createIntent(context: Context, input: Unit): Intent { val intents: List = mode.intents(context) + onFilesCreated?.invoke(pictureFile, videoFile) val initialIntent = intents.lastOrNull() ?: Intent() return Intent.createChooser(initialIntent, mode.label(context)) .apply { @@ -61,6 +79,25 @@ public class CaptureMediaContract( } } + /** + * Restores [pictureFile] and [videoFile] from previously persisted absolute paths. + * + * After process death the in-memory file references are lost, so the pending + * [ActivityResultContract] callback would be unable to locate the captured media. + * Call this method **before** the result is dispatched (e.g. in `onCreate`) to + * reconstruct the destination files so that [parseResult] can return the correct file. + * + * Passing `null` for either parameter leaves the corresponding file reference unchanged. + * + * @param picturePath Absolute path of the photo destination file, or `null`. + * @param videoPath Absolute path of the video destination file, or `null`. + */ + @InternalStreamChatApi + public fun restoreFilePaths(picturePath: String?, videoPath: String?) { + picturePath?.let { pictureFile = File(it) } + videoPath?.let { videoFile = File(it) } + } + private fun getRecordVideoIntents(context: Context): List { videoFile = fileManager.createVideoInExternalDir(context).getOrNull() return videoFile