Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (Lio/getstream/chat/android/compose/ui/util/StorageHelperWrapper;Lkotlinx/coroutines/flow/StateFlow;)V
public fun <init> (Lio/getstream/chat/android/compose/ui/util/StorageHelperWrapper;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;)V
public synthetic fun <init> (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
Expand Down Expand Up @@ -5135,6 +5137,7 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/Messages
public fun <init> (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 <init> (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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -62,12 +72,52 @@ public fun rememberCaptureMediaLauncher(
photo: Boolean,
video: Boolean,
onResult: (File) -> Unit,
): ManagedActivityResultLauncher<Unit, File?>? =
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<Unit, File?>? =
rememberCaptureMediaLauncherInternal(photo, video, onResult)

@Composable
private fun rememberCaptureMediaLauncherInternal(
photo: Boolean,
video: Boolean,
onResult: (File?) -> Unit,
): ManagedActivityResultLauncher<Unit, File?>? {
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<String?>(null) }
var videoFilePath by rememberSaveable { mutableStateOf<String?>(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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ChannelState?>,
private val savedStateHandle: SavedStateHandle = SavedStateHandle(),
) : ViewModel() {

/**
Expand Down Expand Up @@ -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<Boolean>(KEY_IS_SHOWING_ATTACHMENTS) ?: false,
)

/**
* Loads all the items based on the current type.
Expand Down Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <T : ViewModel> create(modelClass: Class<T>, 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Unit, File?>() {

/**
* 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<Intent> = mode.intents(context)
onFilesCreated?.invoke(pictureFile, videoFile)
val initialIntent = intents.lastOrNull() ?: Intent()
return Intent.createChooser(initialIntent, mode.label(context))
.apply {
Expand All @@ -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<Intent> {
videoFile = fileManager.createVideoInExternalDir(context).getOrNull()
return videoFile
Expand Down
Loading