From c4d220a4651d8d5f2eb66b7070d3e259d2a42633 Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 16 Dec 2025 14:11:10 +0800 Subject: [PATCH 1/3] Initial Media send update --- .../mediasend/MediaPickerFolderFragment.java | 4 +- .../mediasend/MediaPickerItemFragment.java | 2 +- .../securesms/mediasend/MediaSendActivity.kt | 93 +++-- .../securesms/mediasend/MediaSendFragment.kt | 5 +- .../securesms/mediasend/MediaSendViewModel.kt | 331 +++++++++++------- 5 files changed, 266 insertions(+), 169 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java index 3bdf73322b..e34b80dfd1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java @@ -103,7 +103,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat list.setLayoutManager(layoutManager); list.setAdapter(adapter); - viewModel.getFolders(requireContext()).observe(getViewLifecycleOwner(), adapter::setFolders); + viewModel.getFolders().observe(getViewLifecycleOwner(), adapter::setFolders); initToolbar(view.findViewById(R.id.mediapicker_toolbar)); } @@ -159,7 +159,7 @@ public boolean onMenuItemSelected(@NonNull MenuItem item) { AttachmentManager.managePhotoAccess(requireActivity(), () -> { if (!isAdded()) return; - viewModel.getFolders(requireContext()) + viewModel.getFolders() .observe(getViewLifecycleOwner(), adapter::setFolders); initToolbarOptions(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java index 8af99c9a19..5d1367529d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java @@ -110,7 +110,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat onMediaSelectionChanged(new ArrayList<>(viewModel.getSelectedMedia().getValue())); } - viewModel.getMediaInBucket(requireContext(), bucketId).observe(getViewLifecycleOwner(), adapter::setMedia); + viewModel.getMediaInBucket(bucketId).observe(getViewLifecycleOwner(), adapter::setMedia); initMediaObserver(viewModel); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt index 67956e8753..7495652a38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -19,7 +19,9 @@ import androidx.activity.viewModels import androidx.core.view.ViewGroupCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @@ -126,7 +128,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme initializeCountButtonObserver() initializeCameraButtonObserver() - initializeErrorObserver() + collectEffects() binding.mediasendCameraButton.setOnClickListener { v: View? -> val maxSelection = MediaSendViewModel.MAX_SELECTED_FILES @@ -147,7 +149,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme // and we're on the editor as the only fragment. if (lastEntryFromCameraCapture && isCameraFlow && fm.backStackEntryCount == 1) { fm.popBackStackImmediate() // remove the editor fragment - viewModel.onImageCaptureUndo(this@MediaSendActivity) + viewModel.onImageCaptureUndo() lastEntryFromCameraCapture = false navigateToCamera() return @@ -300,44 +302,63 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme } } - private fun initializeErrorObserver() { - viewModel.getError().observe( - this - ) { error: MediaSendViewModel.Error? -> - if (error == null) return@observe - when (error) { - MediaSendViewModel.Error.INVALID_TYPE_ONLY -> Toast.makeText( - this, - Phrase.from( - this, - R.string.sharingSupportMultipleMedia - ).put(APP_NAME_KEY, getString(R.string.app_name)).format().toString(), - Toast.LENGTH_LONG - ).show() - - MediaSendViewModel.Error.MIXED_TYPE -> Toast.makeText( - this, - R.string.sharingSupportMultipleMediaExcluded, - Toast.LENGTH_LONG - ).show() - - MediaSendViewModel.Error.ITEM_TOO_LARGE -> Toast.makeText( - this, - R.string.attachmentsErrorSize, - Toast.LENGTH_LONG - ).show() - MediaSendViewModel.Error.TOO_MANY_ITEMS -> // In modern session we'll say you can't sent more than 32 items, but if we ever want - // the exact count of how many items the user attempted to send it's: viewModel.getMaxSelection() - Toast.makeText( - this, - getString(R.string.attachmentsErrorNumber), - Toast.LENGTH_SHORT - ).show() + private fun collectEffects() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.effects.collect { effect -> + when (effect) { + is MediaSendViewModel.MediaSendEffect.ShowError -> showError(effect.error) + is MediaSendViewModel.MediaSendEffect.Toast -> + Toast.makeText( + this@MediaSendActivity, + effect.messageRes, + Toast.LENGTH_LONG + ).show() + + is MediaSendViewModel.MediaSendEffect.ToastText -> + Toast.makeText( + this@MediaSendActivity, + effect.message, + Toast.LENGTH_LONG + ).show() + } + } } } } + private fun showError(error: MediaSendViewModel.Error) { + when (error) { + MediaSendViewModel.Error.INVALID_TYPE_ONLY -> Toast.makeText( + this, + Phrase.from(this, R.string.sharingSupportMultipleMedia) + .put(APP_NAME_KEY, getString(R.string.app_name)) + .format() + .toString(), + Toast.LENGTH_LONG + ).show() + + MediaSendViewModel.Error.MIXED_TYPE -> Toast.makeText( + this, + R.string.sharingSupportMultipleMediaExcluded, + Toast.LENGTH_LONG + ).show() + + MediaSendViewModel.Error.ITEM_TOO_LARGE -> Toast.makeText( + this, + R.string.attachmentsErrorSize, + Toast.LENGTH_LONG + ).show() + + MediaSendViewModel.Error.TOO_MANY_ITEMS -> Toast.makeText( + this, + getString(R.string.attachmentsErrorNumber), + Toast.LENGTH_SHORT + ).show() + } + } + private fun navigateToMediaSend(recipient: Address) { val fragment = MediaSendFragment.newInstance(recipient) var backstackTag: String? = null @@ -372,7 +393,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme .request(Manifest.permission.CAMERA) .withPermanentDenialDialog(permanentDenialTxt) .onAllGranted { - val countNow = viewModel.getCountButtonState().value?.count ?: 0 + val countNow = viewModel.uiState.value.count val intent = Intent(this@MediaSendActivity, CameraXActivity::class.java) .putExtra(KEY_MEDIA_SEND_COUNT, countNow) cameraLauncher.launch(intent) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt index 601c2c60e0..4df3803b96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt @@ -207,10 +207,7 @@ class MediaSendFragment : Fragment(), RailItemListener, InputBarDelegate { override fun onRailItemDeleteClicked(distanceFromActive: Int) { val currentItem = binding?.mediasendPager?.currentItem ?: return - viewModel?.onMediaItemRemoved( - requireContext(), - currentItem + distanceFromActive - ) + viewModel?.onMediaItemRemoved(currentItem + distanceFromActive) } fun onTouchEventsNeeded(needed: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt index 899bf57b5c..4aaacc748d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt @@ -4,20 +4,28 @@ import android.app.Application import android.content.Context import android.net.Uri import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asLiveData import com.annimon.stream.Stream import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import org.session.libsession.utilities.Util.equals import org.session.libsession.utilities.Util.runOnMain import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.InputbarViewModel import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.providers.BlobUtils import org.thoughtcrime.securesms.util.MediaUtil -import org.thoughtcrime.securesms.util.SingleLiveEvent import java.util.LinkedList import javax.inject.Inject @@ -29,19 +37,12 @@ internal class MediaSendViewModel @Inject constructor( private val application: Application, proStatusManager: ProStatusManager, recipientRepository: RecipientRepository, + private val context: ApplicationContext, ) : InputbarViewModel( application = application, proStatusManager = proStatusManager, recipientRepository = recipientRepository, ) { - private val selectedMedia: MutableLiveData?> - private val bucketMedia: MutableLiveData> - private val position: MutableLiveData - private val bucketId: MutableLiveData - private val folders: MutableLiveData> - private val countButtonState: MutableLiveData - private val cameraButtonVisibility: MutableLiveData - private val error: SingleLiveEvent private val savedDrawState: MutableMap private val mediaConstraints: MediaConstraints = MediaConstraints.getPushMediaConstraints() @@ -53,23 +54,43 @@ internal class MediaSendViewModel @Inject constructor( private var sentMedia: Boolean = false private var lastImageCapture: Optional + private val _uiState = MutableStateFlow(MediaSendUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + val effects: SharedFlow = _effects.asSharedFlow() + + // Legacy LiveData bridges (delete later once all UI is Flow/Compose) + private val selectedMediaLiveData: LiveData?> = + uiState.map { it.selectedMedia.ifEmpty { null } }.asLiveData() + + private val bucketIdLiveData: LiveData = + uiState.map { it.bucketId }.asLiveData() + + private val positionLiveData: LiveData = + uiState.map { it.position }.asLiveData() + + private val foldersLiveData: LiveData> = + uiState.map { it.folders }.asLiveData() + + private val countButtonStateLiveData: LiveData = + uiState.map { CountButtonState(it.count, it.countVisibility.toLegacyVisibility()) } + .asLiveData() + + private val cameraButtonVisibilityLiveData: LiveData = + uiState.map { it.showCameraButton }.asLiveData() + init { - this.selectedMedia = MutableLiveData() - this.bucketMedia = MutableLiveData() - this.position = MutableLiveData() - this.bucketId = MutableLiveData() - this.folders = MutableLiveData() - this.countButtonState = MutableLiveData() - this.cameraButtonVisibility = MutableLiveData() - this.error = SingleLiveEvent() this.savedDrawState = HashMap() this.countButtonVisibility = CountButtonState.Visibility.FORCED_OFF this.lastImageCapture = Optional.absent() this.body = "" - position.value = -1 - countButtonState.value = CountButtonState(0, countButtonVisibility) - cameraButtonVisibility.value = false + _uiState.value = MediaSendUiState( + position = -1, + countVisibility = CountVisibility.FORCED_OFF, + showCameraButton = false + ) } fun onSelectedMediaChanged(context: Context, newMedia: List) { @@ -80,41 +101,46 @@ internal class MediaSendViewModel @Inject constructor( // Report errors if they occurred if (errors.contains(Error.ITEM_TOO_LARGE)) { - error.setValue(Error.ITEM_TOO_LARGE) + _effects.tryEmit(MediaSendEffect.ShowError(Error.ITEM_TOO_LARGE)) } else if (errors.contains(Error.INVALID_TYPE_ONLY)) { - error.setValue(Error.INVALID_TYPE_ONLY) + _effects.tryEmit(MediaSendEffect.ShowError(Error.INVALID_TYPE_ONLY)) }else if (errors.contains(Error.MIXED_TYPE)) { - error.setValue(Error.MIXED_TYPE) + _effects.tryEmit(MediaSendEffect.ShowError(Error.MIXED_TYPE)) } if (filteredMedia.size > MAX_SELECTED_FILES) { filteredMedia = filteredMedia.subList(0, MAX_SELECTED_FILES) - error.setValue(Error.TOO_MANY_ITEMS) + _effects.tryEmit(MediaSendEffect.ShowError(Error.TOO_MANY_ITEMS)) } - if (filteredMedia.isNotEmpty()) { - val computedId: String = Stream.of(filteredMedia) - .skip(1) - .reduce(filteredMedia[0].bucketId ?: Media.ALL_MEDIA_BUCKET_ID) { id: String?, m: Media -> - if (equals(id, m.bucketId ?: Media.ALL_MEDIA_BUCKET_ID)) { - id - } else { - Media.ALL_MEDIA_BUCKET_ID + val computedId: String = + if (filteredMedia.isNotEmpty()) { + Stream.of(filteredMedia) + .skip(1) + .reduce( + filteredMedia[0].bucketId ?: Media.ALL_MEDIA_BUCKET_ID + ) { id: String?, m: Media -> + if (equals(id, m.bucketId ?: Media.ALL_MEDIA_BUCKET_ID)) { + id + } else { + Media.ALL_MEDIA_BUCKET_ID + } } - } - bucketId.setValue(computedId) - } else { - bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL - } + } else { + Media.ALL_MEDIA_BUCKET_ID + } - selectedMedia.setValue(filteredMedia) - countButtonState.setValue( - CountButtonState( - filteredMedia.size, - countButtonVisibility + val newVisibility = + if (filteredMedia.isEmpty()) CountVisibility.CONDITIONAL + else _uiState.value.countVisibility + + _uiState.update { + it.copy( + selectedMedia = filteredMedia, + bucketId = computedId, + countVisibility = newVisibility ) - ) + } } } } @@ -126,62 +152,69 @@ internal class MediaSendViewModel @Inject constructor( if (filteredMedia.isEmpty()) { if (errors.contains(Error.ITEM_TOO_LARGE)) { - error.setValue(Error.ITEM_TOO_LARGE) + _effects.tryEmit(MediaSendEffect.ShowError(Error.ITEM_TOO_LARGE)) } else if (errors.contains(Error.INVALID_TYPE_ONLY)) { - error.setValue(Error.INVALID_TYPE_ONLY) + _effects.tryEmit(MediaSendEffect.ShowError(Error.INVALID_TYPE_ONLY)) }else if (errors.contains(Error.MIXED_TYPE)) { - error.setValue(Error.MIXED_TYPE) + _effects.tryEmit(MediaSendEffect.ShowError(Error.MIXED_TYPE)) } - bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) - } else { - bucketId.setValue(filteredMedia[0].bucketId ?: Media.ALL_MEDIA_BUCKET_ID) } countButtonVisibility = CountButtonState.Visibility.FORCED_OFF - selectedMedia.value = filteredMedia - countButtonState.setValue( - CountButtonState( - filteredMedia.size, - countButtonVisibility + val newBucketId = + if (filteredMedia.isEmpty()) Media.ALL_MEDIA_BUCKET_ID + else (filteredMedia[0].bucketId ?: Media.ALL_MEDIA_BUCKET_ID) + + _uiState.update { + it.copy( + selectedMedia = filteredMedia, + bucketId = newBucketId, + countVisibility = CountVisibility.FORCED_OFF ) - ) + } } } } fun onMultiSelectStarted() { - countButtonVisibility = CountButtonState.Visibility.FORCED_ON - countButtonState.value = - CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) + _uiState.update { it.copy(countVisibility = CountVisibility.FORCED_ON) } } fun onImageEditorStarted() { - countButtonVisibility = CountButtonState.Visibility.FORCED_OFF - countButtonState.value = - CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) - cameraButtonVisibility.value = false + _uiState.update { + it.copy( + countVisibility = CountVisibility.FORCED_OFF, + showCameraButton = false + ) + } } fun onCameraStarted() { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL - countButtonState.value = - CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) - cameraButtonVisibility.value = false + _uiState.update { + it.copy( + countVisibility = CountVisibility.CONDITIONAL, + showCameraButton = false + ) + } } fun onItemPickerStarted() { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL - countButtonState.value = - CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) - cameraButtonVisibility.value = true + _uiState.update { + it.copy( + countVisibility = CountVisibility.CONDITIONAL, + showCameraButton = true + ) + } } fun onFolderPickerStarted() { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL - countButtonState.value = - CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) - cameraButtonVisibility.value = true + _uiState.update { + it.copy( + countVisibility = CountVisibility.CONDITIONAL, + showCameraButton = true + ) + } } fun onBodyChanged(body: CharSequence) { @@ -189,9 +222,7 @@ internal class MediaSendViewModel @Inject constructor( } fun onFolderSelected(bucketId: String) { - this.bucketId.value = bucketId - bucketMedia.value = - emptyList() + _uiState.update { it.copy(bucketId = bucketId, bucketMedia = emptyList()) } } fun onPageChanged(position: Int) { @@ -202,65 +233,79 @@ internal class MediaSendViewModel @Inject constructor( return } - this.position.value = position + _uiState.update { it.copy(position = position) } } - fun onMediaItemRemoved(context: Context, position: Int) { - if (position < 0 || position >= selectedMediaOrDefault.size) { + fun onMediaItemRemoved(position: Int) { + val current = _uiState.value.selectedMedia + if (position < 0 || position >= current.size) { Log.w( TAG, - "Tried to remove an out-of-bounds item. Size: " + selectedMediaOrDefault.size + ", position: " + position + "Tried to remove an out-of-bounds item. Size: ${current.size}, position: $position" ) return } - val updatedList = selectedMediaOrDefault.toMutableList() + val updatedList = current.toMutableList() val removed: Media = updatedList.removeAt(position) if (BlobUtils.isAuthority(removed.uri)) { BlobUtils.getInstance().delete(context, removed.uri) } - selectedMedia.setValue(updatedList) + _uiState.update { state -> + val newPos = + if (updatedList.isEmpty()) -1 + else state.position.coerceIn(0, updatedList.lastIndex) + state.copy(selectedMedia = updatedList, position = newPos) + } } fun onImageCaptured(media: Media) { - var selected: MutableList? = selectedMedia.value?.toMutableList() - - if (selected == null) { - selected = LinkedList() - } + val selected: MutableList = selectedMediaOrDefault.toMutableList() if (selected.size >= MAX_SELECTED_FILES) { - error.setValue(Error.TOO_MANY_ITEMS) + _effects.tryEmit(MediaSendEffect.ShowError(Error.TOO_MANY_ITEMS)) return } lastImageCapture = Optional.of(media) selected.add(media) - selectedMedia.setValue(selected) - position.setValue(selected.size - 1) - bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) - - if (selected.size == 1) { - countButtonVisibility = CountButtonState.Visibility.FORCED_OFF - } else { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL - } - countButtonState.setValue(CountButtonState(selected.size, countButtonVisibility)) + val newVisibility = + if (selected.size == 1) CountVisibility.FORCED_OFF else CountVisibility.CONDITIONAL + + _uiState.update { + it.copy( + selectedMedia = selected, + position = selected.size - 1, + bucketId = Media.ALL_MEDIA_BUCKET_ID, + countVisibility = newVisibility + ) + } } - fun onImageCaptureUndo(context: Context) { - val selected: MutableList = selectedMediaOrDefault.toMutableList() + fun onImageCaptureUndo() { + val last = if (lastImageCapture.isPresent) lastImageCapture.get() else return + val current = _uiState.value.selectedMedia + + if (!(current.size == 1 && current.contains(last))) return - if (lastImageCapture.isPresent && selected.contains(lastImageCapture.get()) && selected.size == 1) { - selected.remove(lastImageCapture.get()) - selectedMedia.value = selected - countButtonState.value = CountButtonState(selected.size, countButtonVisibility) - BlobUtils.getInstance().delete(context, lastImageCapture.get().uri) + val updated = current.toMutableList().apply { remove(last) } + + _uiState.update { state -> + state.copy( + selectedMedia = updated, + position = -1 + ) + } + + if (BlobUtils.isAuthority(last.uri)) { + BlobUtils.getInstance().delete(application.applicationContext, last.uri) } + + lastImageCapture = Optional.absent() } fun saveDrawState(state: Map) { @@ -276,42 +321,43 @@ internal class MediaSendViewModel @Inject constructor( get() = savedDrawState fun getSelectedMedia(): LiveData?> { - return selectedMedia + return selectedMediaLiveData } - fun getMediaInBucket(context: Context, bucketId: String): LiveData> { - repository.getMediaInBucket(context, bucketId) { value: List -> bucketMedia.postValue(value) } - return bucketMedia + fun getMediaInBucket(bucketId: String): LiveData> { + // refresh data, but state is stored in uiState + repository.getMediaInBucket(context, bucketId) { value -> + _uiState.update { it.copy(bucketMedia = value) } + } + return uiState.map { it.bucketMedia }.asLiveData() } - fun getFolders(context: Context): LiveData> { - repository.getFolders(context) { value: List -> folders.postValue(value) } - return folders + fun getFolders(): LiveData> { + repository.getFolders(context) { value -> + _uiState.update { it.copy(folders = value) } + } + return foldersLiveData } fun getCountButtonState(): LiveData { - return countButtonState + return countButtonStateLiveData } fun getCameraButtonVisibility(): LiveData { - return cameraButtonVisibility + return cameraButtonVisibilityLiveData } fun getPosition(): LiveData { - return position + return positionLiveData } fun getBucketId(): LiveData { - return bucketId - } - - fun getError(): LiveData { - return error + return bucketIdLiveData } private val selectedMediaOrDefault: List - get() = if (selectedMedia.value == null) emptyList() else - selectedMedia.value!! + get() = _uiState.value.selectedMedia + /** * Filters the input list of media. @@ -341,7 +387,6 @@ internal class MediaSendViewModel @Inject constructor( return Pair(validMedia, errors) } - for (m in media) { val isGif = MediaUtil.isGif(m.mimeType) val isVideo = MediaUtil.isVideoType(m.mimeType) @@ -407,6 +452,40 @@ internal class MediaSendViewModel @Inject constructor( } } + data class MediaSendUiState( + val recipientName: String = "", + val folders: List = emptyList(), + val bucketId: String = Media.ALL_MEDIA_BUCKET_ID, + val bucketMedia: List = emptyList(), + val selectedMedia: List = emptyList(), + val position: Int = -1, + val countVisibility: CountVisibility = CountVisibility.FORCED_OFF, + val showCameraButton: Boolean = false + ) { + val count: Int get() = selectedMedia.size + val showCountButton: Boolean get() = + when (countVisibility) { + CountVisibility.FORCED_ON -> true + CountVisibility.FORCED_OFF -> false + CountVisibility.CONDITIONAL -> count > 0 + } + } + + enum class CountVisibility { CONDITIONAL, FORCED_ON, FORCED_OFF } + + private fun CountVisibility.toLegacyVisibility(): CountButtonState.Visibility = + when (this) { + CountVisibility.CONDITIONAL -> CountButtonState.Visibility.CONDITIONAL + CountVisibility.FORCED_ON -> CountButtonState.Visibility.FORCED_ON + CountVisibility.FORCED_OFF -> CountButtonState.Visibility.FORCED_OFF + } + + sealed interface MediaSendEffect { + data class ShowError(val error: Error) : MediaSendEffect + data class Toast(val messageRes: Int) : MediaSendEffect + data class ToastText(val message: String) : MediaSendEffect + } + companion object { private val TAG: String = MediaSendViewModel::class.java.simpleName From 7d559533c67164f9919a5fccfe3273a4b9c788e3 Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 16 Dec 2025 14:24:10 +0800 Subject: [PATCH 2/3] Button visibility state --- .../securesms/mediasend/MediaSendViewModel.kt | 42 +++++++------------ 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt index 4aaacc748d..acbd4cd922 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt @@ -50,7 +50,7 @@ internal class MediaSendViewModel @Inject constructor( var body: CharSequence private set - private var countButtonVisibility: CountButtonState.Visibility + private var sentMedia: Boolean = false private var lastImageCapture: Optional @@ -74,7 +74,7 @@ internal class MediaSendViewModel @Inject constructor( uiState.map { it.folders }.asLiveData() private val countButtonStateLiveData: LiveData = - uiState.map { CountButtonState(it.count, it.countVisibility.toLegacyVisibility()) } + uiState.map { CountButtonState(it.count, it.countVisibility) } .asLiveData() private val cameraButtonVisibilityLiveData: LiveData = @@ -82,13 +82,12 @@ internal class MediaSendViewModel @Inject constructor( init { this.savedDrawState = HashMap() - this.countButtonVisibility = CountButtonState.Visibility.FORCED_OFF this.lastImageCapture = Optional.absent() this.body = "" _uiState.value = MediaSendUiState( position = -1, - countVisibility = CountVisibility.FORCED_OFF, + countVisibility = CountButtonState.Visibility.FORCED_OFF, showCameraButton = false ) } @@ -131,7 +130,7 @@ internal class MediaSendViewModel @Inject constructor( } val newVisibility = - if (filteredMedia.isEmpty()) CountVisibility.CONDITIONAL + if (filteredMedia.isEmpty()) CountButtonState.Visibility.CONDITIONAL else _uiState.value.countVisibility _uiState.update { @@ -160,8 +159,6 @@ internal class MediaSendViewModel @Inject constructor( } } - countButtonVisibility = CountButtonState.Visibility.FORCED_OFF - val newBucketId = if (filteredMedia.isEmpty()) Media.ALL_MEDIA_BUCKET_ID else (filteredMedia[0].bucketId ?: Media.ALL_MEDIA_BUCKET_ID) @@ -170,7 +167,7 @@ internal class MediaSendViewModel @Inject constructor( it.copy( selectedMedia = filteredMedia, bucketId = newBucketId, - countVisibility = CountVisibility.FORCED_OFF + countVisibility = CountButtonState.Visibility.FORCED_OFF ) } } @@ -178,13 +175,13 @@ internal class MediaSendViewModel @Inject constructor( } fun onMultiSelectStarted() { - _uiState.update { it.copy(countVisibility = CountVisibility.FORCED_ON) } + _uiState.update { it.copy(countVisibility = CountButtonState.Visibility.FORCED_ON) } } fun onImageEditorStarted() { _uiState.update { it.copy( - countVisibility = CountVisibility.FORCED_OFF, + countVisibility = CountButtonState.Visibility.FORCED_OFF, showCameraButton = false ) } @@ -193,7 +190,7 @@ internal class MediaSendViewModel @Inject constructor( fun onCameraStarted() { _uiState.update { it.copy( - countVisibility = CountVisibility.CONDITIONAL, + countVisibility = CountButtonState.Visibility.CONDITIONAL, showCameraButton = false ) } @@ -202,7 +199,7 @@ internal class MediaSendViewModel @Inject constructor( fun onItemPickerStarted() { _uiState.update { it.copy( - countVisibility = CountVisibility.CONDITIONAL, + countVisibility = CountButtonState.Visibility.CONDITIONAL, showCameraButton = true ) } @@ -211,7 +208,7 @@ internal class MediaSendViewModel @Inject constructor( fun onFolderPickerStarted() { _uiState.update { it.copy( - countVisibility = CountVisibility.CONDITIONAL, + countVisibility = CountButtonState.Visibility.CONDITIONAL, showCameraButton = true ) } @@ -274,7 +271,7 @@ internal class MediaSendViewModel @Inject constructor( selected.add(media) val newVisibility = - if (selected.size == 1) CountVisibility.FORCED_OFF else CountVisibility.CONDITIONAL + if (selected.size == 1) CountButtonState.Visibility.FORCED_OFF else CountButtonState.Visibility.CONDITIONAL _uiState.update { it.copy( @@ -459,27 +456,18 @@ internal class MediaSendViewModel @Inject constructor( val bucketMedia: List = emptyList(), val selectedMedia: List = emptyList(), val position: Int = -1, - val countVisibility: CountVisibility = CountVisibility.FORCED_OFF, + val countVisibility: CountButtonState.Visibility = CountButtonState.Visibility.FORCED_OFF, val showCameraButton: Boolean = false ) { val count: Int get() = selectedMedia.size val showCountButton: Boolean get() = when (countVisibility) { - CountVisibility.FORCED_ON -> true - CountVisibility.FORCED_OFF -> false - CountVisibility.CONDITIONAL -> count > 0 + CountButtonState.Visibility.FORCED_ON -> true + CountButtonState.Visibility.FORCED_OFF -> false + CountButtonState.Visibility.CONDITIONAL -> count > 0 } } - enum class CountVisibility { CONDITIONAL, FORCED_ON, FORCED_OFF } - - private fun CountVisibility.toLegacyVisibility(): CountButtonState.Visibility = - when (this) { - CountVisibility.CONDITIONAL -> CountButtonState.Visibility.CONDITIONAL - CountVisibility.FORCED_ON -> CountButtonState.Visibility.FORCED_ON - CountVisibility.FORCED_OFF -> CountButtonState.Visibility.FORCED_OFF - } - sealed interface MediaSendEffect { data class ShowError(val error: Error) : MediaSendEffect data class Toast(val messageRes: Int) : MediaSendEffect From 234460ad4d8f5cd670c7391823441aa6aa75b6ec Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 17 Dec 2025 13:54:05 +0800 Subject: [PATCH 3/3] Cleanups --- .../mediasend/MediaPickerItemFragment.java | 2 +- .../securesms/mediasend/MediaSendActivity.kt | 2 +- .../securesms/mediasend/MediaSendViewModel.kt | 23 ++++++++----------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java index 5d1367529d..1cada541d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java @@ -161,7 +161,7 @@ public void onMediaSelectionStarted() { @Override public void onMediaSelectionChanged(@NonNull List selected) { adapter.notifyDataSetChanged(); - viewModel.onSelectedMediaChanged(requireContext(), selected); + viewModel.onSelectedMediaChanged(selected); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt index 7495652a38..1c839902e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -107,7 +107,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme if (isCamera) { navigateToCamera() } else if (!isEmpty(media)) { - viewModel.onSelectedMediaChanged(this, media!!) + viewModel.onSelectedMediaChanged(media!!) lastEntryFromCameraCapture = false diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt index acbd4cd922..e1d1557b71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt @@ -26,7 +26,6 @@ import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.providers.BlobUtils import org.thoughtcrime.securesms.util.MediaUtil -import java.util.LinkedList import javax.inject.Inject /** @@ -92,7 +91,7 @@ internal class MediaSendViewModel @Inject constructor( ) } - fun onSelectedMediaChanged(context: Context, newMedia: List) { + fun onSelectedMediaChanged(newMedia: List) { repository.getPopulatedMedia(context, newMedia) { populatedMedia: List -> runOnMain { // Use the new filter function that returns valid items AND errors @@ -223,9 +222,9 @@ internal class MediaSendViewModel @Inject constructor( } fun onPageChanged(position: Int) { - if (position < 0 || position >= selectedMediaOrDefault.size) { + if (position !in selectedMedia.indices) { Log.w(TAG, - "Tried to move to an out-of-bounds item. Size: " + selectedMediaOrDefault.size + ", position: " + position + "Tried to move to an out-of-bounds item. Size: " + selectedMedia.size + ", position: " + position ) return } @@ -234,7 +233,7 @@ internal class MediaSendViewModel @Inject constructor( } fun onMediaItemRemoved(position: Int) { - val current = _uiState.value.selectedMedia + val current = selectedMedia if (position < 0 || position >= current.size) { Log.w( TAG, @@ -259,7 +258,7 @@ internal class MediaSendViewModel @Inject constructor( } fun onImageCaptured(media: Media) { - val selected: MutableList = selectedMediaOrDefault.toMutableList() + val selected: MutableList = selectedMedia.toMutableList() if (selected.size >= MAX_SELECTED_FILES) { _effects.tryEmit(MediaSendEffect.ShowError(Error.TOO_MANY_ITEMS)) @@ -285,7 +284,7 @@ internal class MediaSendViewModel @Inject constructor( fun onImageCaptureUndo() { val last = if (lastImageCapture.isPresent) lastImageCapture.get() else return - val current = _uiState.value.selectedMedia + val current = selectedMedia if (!(current.size == 1 && current.contains(last))) return @@ -299,7 +298,7 @@ internal class MediaSendViewModel @Inject constructor( } if (BlobUtils.isAuthority(last.uri)) { - BlobUtils.getInstance().delete(application.applicationContext, last.uri) + BlobUtils.getInstance().delete(context, last.uri) } lastImageCapture = Optional.absent() @@ -352,7 +351,7 @@ internal class MediaSendViewModel @Inject constructor( return bucketIdLiveData } - private val selectedMediaOrDefault: List + private val selectedMedia: List get() = _uiState.value.selectedMedia @@ -415,7 +414,7 @@ internal class MediaSendViewModel @Inject constructor( override fun onCleared() { if (!sentMedia) { - Stream.of(selectedMediaOrDefault) + Stream.of(selectedMedia) .map { obj: Media -> obj.uri } .filter { uri: Uri? -> BlobUtils.isAuthority( @@ -423,9 +422,7 @@ internal class MediaSendViewModel @Inject constructor( ) } .forEach { uri: Uri? -> - BlobUtils.getInstance().delete( - application.applicationContext, uri!! - ) + BlobUtils.getInstance().delete(context, uri!!) } } }