diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java index 1abe337271..e776b735f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java @@ -24,7 +24,6 @@ import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; -import android.content.pm.PackageManager; import android.database.Cursor; import android.net.Uri; import android.os.AsyncTask; @@ -35,8 +34,6 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; import com.bumptech.glide.RequestManager; import com.squareup.phrase.Phrase; @@ -349,13 +346,13 @@ public static void selectGallery(Activity activity, int requestCode, @NonNull Ad .execute(); } - public static boolean hasFullAccess(Activity activity) { + public static boolean hasFullAccess(Context c) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return Permissions.hasAll(activity, + return Permissions.hasAll(c, Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO); } else { - return Permissions.hasAll(activity, android.Manifest.permission.READ_EXTERNAL_STORAGE); + return Permissions.hasAll(c, android.Manifest.permission.READ_EXTERNAL_STORAGE); } } @@ -386,9 +383,9 @@ public static void managePhotoAccess(@NonNull Activity activity, @Nullable Runna } } - public static boolean shouldShowManagePhoto(@NonNull Activity activity){ + public static boolean shouldShowManagePhoto(@NonNull Context c){ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE){ - return !hasFullAccess(activity) && hasPartialAccess(activity); + return !hasFullAccess(c) && hasPartialAccess(c); }else{ // No partial access for <= API 33 return false; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.java deleted file mode 100644 index b84ebfd276..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.net.Uri; -import androidx.annotation.NonNull; - -/** - * Represents a folder that's shown in {@link MediaPickerFolderFragment}. - */ -public class MediaFolder { - - private final Uri thumbnailUri; - private final String title; - private final int itemCount; - private final String bucketId; - - MediaFolder(@NonNull Uri thumbnailUri, @NonNull String title, int itemCount, @NonNull String bucketId) { - this.thumbnailUri = thumbnailUri; - this.title = title; - this.itemCount = itemCount; - this.bucketId = bucketId; - } - - Uri getThumbnailUri() { - return thumbnailUri; - } - - public String getTitle() { - return title; - } - - int getItemCount() { - return itemCount; - } - - public String getBucketId() { - return bucketId; - } - - enum FolderType { - NORMAL, CAMERA - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.kt new file mode 100644 index 0000000000..038af1d22e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaFolder.kt @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.mediasend + +import android.net.Uri + + +/** + * Represents a folder that's shown in MediaPickerFolderFragment. + */ +data class MediaFolder( + val thumbnailUri: Uri?, + val title: String, + val itemCount: Int, + val bucketId: String, +) { + enum class FolderType { + NORMAL, CAMERA + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java index 1973ad1700..a3afc9147a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.List; +@Deprecated class MediaPickerFolderAdapter extends RecyclerView.Adapter { private final RequestManager glideRequests; 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 e34b80dfd1..321e3134a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java @@ -41,6 +41,7 @@ /** * Allows the user to select a media folder to explore. */ +@Deprecated @AndroidEntryPoint public class MediaPickerFolderFragment extends Fragment implements MediaPickerFolderAdapter.EventListener { @@ -191,6 +192,7 @@ public void onFolderClicked(@NonNull MediaFolder folder) { controller.onFolderSelected(folder); } + @Deprecated public interface Controller { void onFolderSelected(@NonNull MediaFolder folder); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java index b184197fe1..f7b2aba8ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java @@ -22,6 +22,7 @@ import java.util.LinkedList; import java.util.List; +@Deprecated public class MediaPickerItemAdapter extends RecyclerView.Adapter { private final RequestManager glideRequests; 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 1cada541d6..065b4c6659 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java @@ -34,6 +34,7 @@ /** * Allows the user to select a set of media items from a specified folder. */ +@Deprecated @AndroidEntryPoint public class MediaPickerItemFragment extends Fragment implements MediaPickerItemAdapter.EventListener { 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 1c839902e5..bd5ed3b00f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -39,6 +39,8 @@ import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.mediasend.CameraXActivity.Companion.KEY_MEDIA_SEND_COUNT import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.CountButtonState +import org.thoughtcrime.securesms.mediasend.compose.MediaPickerFolderComposeFragment +import org.thoughtcrime.securesms.mediasend.compose.MediaPickerItemComposeFragment import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.scribbles.ImageEditorFragment import org.thoughtcrime.securesms.util.FilenameUtils.constructPhotoFilename @@ -53,8 +55,8 @@ import javax.inject.Inject * It will return the [Media] that the user decided to send. */ @AndroidEntryPoint -class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragment.Controller, - MediaPickerItemFragment.Controller, MediaSendFragment.Controller, +class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderComposeFragment.Controller, + MediaPickerItemComposeFragment.Controller, MediaSendFragment.Controller, ImageEditorFragment.Controller { private var recipient: Recipient? = null @@ -117,7 +119,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) .commit() } else { - val fragment = MediaPickerFolderFragment.newInstance( + val fragment = MediaPickerFolderComposeFragment.newInstance( recipient!! ) supportFragmentManager.beginTransaction() @@ -179,10 +181,9 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme override fun onFolderSelected(folder: MediaFolder) { viewModel.onFolderSelected(folder.bucketId) - val fragment = MediaPickerItemFragment.newInstance( + val fragment = MediaPickerItemComposeFragment.newInstance( folder.bucketId, - folder.title, - MediaSendViewModel.MAX_SELECTED_FILES + folder.title ) supportFragmentManager.beginTransaction() .setCustomAnimations( @@ -208,11 +209,11 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme } override fun onAddMediaClicked(bucketId: String) { - val folderFragment = MediaPickerFolderFragment.newInstance( + val folderFragment = MediaPickerFolderComposeFragment.newInstance( recipient!! ) val itemFragment = - MediaPickerItemFragment.newInstance(bucketId, "", MediaSendViewModel.MAX_SELECTED_FILES) + MediaPickerItemComposeFragment.newInstance(bucketId, "") supportFragmentManager.beginTransaction() .setCustomAnimations( 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 e1d1557b71..89709a8b6e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.mediasend import android.app.Application import android.content.Context import android.net.Uri +import android.os.Build import androidx.lifecycle.LiveData import androidx.lifecycle.asLiveData import com.annimon.stream.Stream @@ -21,6 +22,8 @@ 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.conversation.v2.utilities.AttachmentManager.hasFullAccess +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager.hasPartialAccess import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.pro.ProStatusManager @@ -32,7 +35,7 @@ import javax.inject.Inject * Manages the observable datasets available in [MediaSendActivity]. */ @HiltViewModel -internal class MediaSendViewModel @Inject constructor( +class MediaSendViewModel @Inject constructor( private val application: Application, proStatusManager: ProStatusManager, recipientRepository: RecipientRepository, @@ -91,18 +94,42 @@ internal class MediaSendViewModel @Inject constructor( ) } + fun onMediaSelected(media: Media) { + val updatedList = run { + val current = uiState.value.selectedMedia + val exists = current.any { it.uri == media.uri } + + if (exists) { + current.filterNot { it.uri == media.uri } + } else { + if (current.size >= MAX_SELECTED_FILES) { + _effects.tryEmit(MediaSendEffect.ShowError(Error.TOO_MANY_ITEMS)) + current + } else { + current + media + } + } + } + + onSelectedMediaChanged(updatedList) + } + fun onSelectedMediaChanged(newMedia: List) { repository.getPopulatedMedia(context, newMedia) { populatedMedia: List -> runOnMain { // Use the new filter function that returns valid items AND errors - var (filteredMedia, errors) = getFilteredMedia(context, populatedMedia, mediaConstraints) + var (filteredMedia, errors) = getFilteredMedia( + context, + populatedMedia, + mediaConstraints + ) // Report errors if they occurred if (errors.contains(Error.ITEM_TOO_LARGE)) { _effects.tryEmit(MediaSendEffect.ShowError(Error.ITEM_TOO_LARGE)) } else if (errors.contains(Error.INVALID_TYPE_ONLY)) { _effects.tryEmit(MediaSendEffect.ShowError(Error.INVALID_TYPE_ONLY)) - }else if (errors.contains(Error.MIXED_TYPE)) { + } else if (errors.contains(Error.MIXED_TYPE)) { _effects.tryEmit(MediaSendEffect.ShowError(Error.MIXED_TYPE)) } @@ -136,7 +163,7 @@ internal class MediaSendViewModel @Inject constructor( it.copy( selectedMedia = filteredMedia, bucketId = computedId, - countVisibility = newVisibility + countVisibility = newVisibility, ) } } @@ -146,14 +173,18 @@ internal class MediaSendViewModel @Inject constructor( fun onSingleMediaSelected(context: Context, media: Media) { repository.getPopulatedMedia(context, listOf(media)) { populatedMedia: List -> runOnMain { - val (filteredMedia, errors) = getFilteredMedia(context, populatedMedia, mediaConstraints) + val (filteredMedia, errors) = getFilteredMedia( + context, + populatedMedia, + mediaConstraints + ) if (filteredMedia.isEmpty()) { if (errors.contains(Error.ITEM_TOO_LARGE)) { _effects.tryEmit(MediaSendEffect.ShowError(Error.ITEM_TOO_LARGE)) } else if (errors.contains(Error.INVALID_TYPE_ONLY)) { _effects.tryEmit(MediaSendEffect.ShowError(Error.INVALID_TYPE_ONLY)) - }else if (errors.contains(Error.MIXED_TYPE)) { + } else if (errors.contains(Error.MIXED_TYPE)) { _effects.tryEmit(MediaSendEffect.ShowError(Error.MIXED_TYPE)) } } @@ -166,7 +197,7 @@ internal class MediaSendViewModel @Inject constructor( it.copy( selectedMedia = filteredMedia, bucketId = newBucketId, - countVisibility = CountButtonState.Visibility.FORCED_OFF + countVisibility = CountButtonState.Visibility.FORCED_OFF, ) } } @@ -174,7 +205,11 @@ internal class MediaSendViewModel @Inject constructor( } fun onMultiSelectStarted() { - _uiState.update { it.copy(countVisibility = CountButtonState.Visibility.FORCED_ON) } + _uiState.update { + it.copy( + countVisibility = CountButtonState.Visibility.FORCED_ON + ) + } } fun onImageEditorStarted() { @@ -223,7 +258,8 @@ internal class MediaSendViewModel @Inject constructor( fun onPageChanged(position: Int) { if (position !in selectedMedia.indices) { - Log.w(TAG, + Log.w( + TAG, "Tried to move to an out-of-bounds item. Size: " + selectedMedia.size + ", position: " + position ) return @@ -304,6 +340,14 @@ internal class MediaSendViewModel @Inject constructor( lastImageCapture = Optional.absent() } + fun refreshPhotoAccessUi() { + val show = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + !hasFullAccess(context) && + hasPartialAccess(context) + + _uiState.update { it.copy(showManagePhotoAccess = show) } + } + fun saveDrawState(state: Map) { savedDrawState.clear() savedDrawState.putAll(state) @@ -354,6 +398,12 @@ internal class MediaSendViewModel @Inject constructor( private val selectedMedia: List get() = _uiState.value.selectedMedia + // Same as getFolders but does not return LiveData + fun refreshFolders() { + repository.getFolders(context) { value -> + _uiState.update { it.copy(folders = value) } + } + } /** * Filters the input list of media. @@ -366,6 +416,11 @@ internal class MediaSendViewModel @Inject constructor( media: List, mediaConstraints: MediaConstraints ): Pair, Set> { + + if (media.isEmpty()) { + return Pair(emptyList(), emptySet()) + } + val validMedia = ArrayList() val errors = HashSet() @@ -378,7 +433,7 @@ internal class MediaSendViewModel @Inject constructor( } // if there are no valid types at all, return early - if(validMultiMediaCount == 0){ + if (validMultiMediaCount == 0) { errors.add(Error.INVALID_TYPE_ONLY) return Pair(validMedia, errors) } @@ -427,11 +482,11 @@ internal class MediaSendViewModel @Inject constructor( } } - internal enum class Error { + enum class Error { ITEM_TOO_LARGE, TOO_MANY_ITEMS, INVALID_TYPE_ONLY, MIXED_TYPE } - internal class CountButtonState(val count: Int, private val visibility: Visibility) { + class CountButtonState(val count: Int, private val visibility: Visibility) { val isVisible: Boolean get() { return when (visibility) { @@ -441,7 +496,7 @@ internal class MediaSendViewModel @Inject constructor( } } - internal enum class Visibility { + enum class Visibility { CONDITIONAL, FORCED_ON, FORCED_OFF } } @@ -454,15 +509,23 @@ internal class MediaSendViewModel @Inject constructor( val selectedMedia: List = emptyList(), val position: Int = -1, val countVisibility: CountButtonState.Visibility = CountButtonState.Visibility.FORCED_OFF, - val showCameraButton: Boolean = false + val showCameraButton: Boolean = false, + val showManagePhotoAccess : Boolean = false ) { val count: Int get() = selectedMedia.size - val showCountButton: Boolean get() = - when (countVisibility) { - CountButtonState.Visibility.FORCED_ON -> true - CountButtonState.Visibility.FORCED_OFF -> false - CountButtonState.Visibility.CONDITIONAL -> count > 0 - } + + val isMultiSelect: Boolean + get() = selectedMedia.isNotEmpty() || countVisibility == CountButtonState.Visibility.FORCED_ON + + val canLongPress: Boolean + get() = selectedMedia.isEmpty() && !isMultiSelect + val showCountButton: Boolean + get() = + when (countVisibility) { + CountButtonState.Visibility.FORCED_ON -> true + CountButtonState.Visibility.FORCED_OFF -> false + CountButtonState.Visibility.CONDITIONAL -> count > 0 + } } sealed interface MediaSendEffect { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt new file mode 100644 index 0000000000..9773a70b7d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/Components.kt @@ -0,0 +1,310 @@ +package org.thoughtcrime.securesms.mediasend.compose + +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import network.loki.messenger.R +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.util.MediaUtil + +@Composable +fun MediaFolderCell( + title: String, + count: Int, + thumbnailUri: Uri?, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Box(modifier = Modifier.aspectRatio(1f)) { + AsyncImage( + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Crop, + model = ImageRequest.Builder(LocalContext.current) + .data(thumbnailUri) + .build(), + contentDescription = null, + ) + + // Bottom shade overlay + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0.0f to Color.Transparent, + 0.5f to Color.Black.copy(alpha = 0.5f), + 1.0f to Color.Black.copy(alpha = 0.7f) + ) + ) + ) + .padding(LocalDimensions.current.smallSpacing) + ) { + // Bottom row + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_baseline_folder_24), + contentDescription = null, + modifier = Modifier.size(LocalDimensions.current.iconSmall), + colorFilter = ColorFilter.tint(Color.White) + ) + + Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) + + Text( + text = title, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + + Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) + + Text( + text = count.toString(), + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } +} + + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +fun MediaPickerItemCell( + media: Media, + isSelected: Boolean = false, + selectedIndex: Int = 1, + isMultiSelect: Boolean, + onMediaChosen: (Media) -> Unit, + onSelectionStarted: () -> Unit, + onSelectionChanged: (selectedMedia: Media) -> Unit, + modifier: Modifier = Modifier, + showSelectionOn: Boolean = false, + canLongPress: Boolean = true +) { + Box( + modifier = modifier + .aspectRatio(1f) + .border( + width = LocalDimensions.current.borderStroke, + color = LocalColors.current.borders.copy(alpha = 0.20f) + ) + .combinedClickable( + onClick = { + if (!isMultiSelect) { + onMediaChosen(media) // Choosing a single media + } else { + onSelectionChanged(media) // Selecting/unselecting media + } + }, + onLongClick = if (canLongPress) { + { + // long press starts selection, adds this item + onSelectionChanged(media) + onSelectionStarted() + } + } else null + ) + ) { + // Thumbnail + AsyncImage( + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + model = ImageRequest.Builder(LocalContext.current) + .data(media.uri) + .build(), + contentDescription = null, + ) + + // Play overlay (center) for video + if (MediaUtil.isVideoType(media.mimeType)) { + Box( + modifier = Modifier + .align(Alignment.Center) + .size(36.dp) + .clip(CircleShape) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.triangle_right), + contentDescription = null, + modifier = Modifier.size(LocalDimensions.current.iconMedium), + colorFilter = ColorFilter.tint(LocalColors.current.accent) // match @color/core_blue-ish + ) + } + } + + // Selection overlay + if (isSelected) { + Box( + Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = 0.80f)) + ) + } + + if (isMultiSelect) { + // Select ON badge + order number (top-end) + if (showSelectionOn) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(LocalDimensions.current.xxsSpacing), + contentAlignment = Alignment.Center + ) { + IndicatorOn() + + Text( + text = (selectedIndex + 1).toString(), + color = LocalColors.current.onInvertedBackgroundAccent, + style = LocalType.current.base, + textAlign = TextAlign.Center + ) + } + } else { + // Select OFF badge + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(LocalDimensions.current.xxsSpacing) + ) { + IndicatorOff() + } + } + } + } +} + +@Composable +private fun IndicatorOff(modifier: Modifier = Modifier, size: Dp = 26.dp ) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .border( + width = LocalDimensions.current.borderStroke, + color = LocalColors.current.text, + shape = CircleShape + ) + ) +} + +@Composable +private fun IndicatorOn(modifier: Modifier = Modifier, size: Dp = 26.dp) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background( + color = LocalColors.current.accent, + shape = CircleShape + ) + ) +} + +@Preview +@Composable +private fun PreviewMediaFolderCell() { + MediaFolderCell( + title = "Test Title", + count = 100, + thumbnailUri = null + ) { } +} + +@Preview(name = "MediaPickerItemCell - Not selected") +@Composable +private fun Preview_MediaPickerItemCell_NotSelected() { + val media = previewMedia("content://preview/media/1", "image/jpeg") + + MediaPickerItemCell( + media = media, + isMultiSelect = false, + canLongPress = true, + onMediaChosen = {}, + onSelectionStarted = {}, + onSelectionChanged = {}, + ) +} + +@Preview(name = "MediaPickerItemCell - Selected (order 1)") +@Composable +private fun Preview_MediaPickerItemCell_Selected() { + val media = previewMedia("content://preview/media/2", "image/jpeg") + + MediaPickerItemCell( + media = media, + isMultiSelect = true, + canLongPress = true, + onMediaChosen = {}, + onSelectionStarted = {}, + onSelectionChanged = {}, + ) +} + +private fun previewMedia(uri: String, mime: String): Media { + return Media( + uri.toUri(), + /* filename = */ "preview", + /* mimeType = */ mime, + /* date = */ 0L, + /* width = */ 100, + /* height = */ 100, + /* size = */ 1234L, + /* bucketId = */ "preview", + /* caption = */ null + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt new file mode 100644 index 0000000000..c725605c1e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderComposeFragment.kt @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.mediasend.compose + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import com.squareup.phrase.Phrase +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.displayName +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager +import org.thoughtcrime.securesms.mediasend.MediaFolder +import org.thoughtcrime.securesms.mediasend.MediaPickerFolderFragment +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel +import org.thoughtcrime.securesms.ui.setThemedContent + +@AndroidEntryPoint +class MediaPickerFolderComposeFragment : Fragment() { + + private val viewModel: MediaSendViewModel by activityViewModels() + + private var recipientName: String? = null + private var controller: Controller? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + controller = activity as? Controller + ?: throw IllegalStateException("Parent activity must implement controller class.") + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + recipientName = requireArguments().getString(KEY_RECIPIENT_NAME) + } + + override fun onResume() { + super.onResume() + viewModel.onFolderPickerStarted() + viewModel.refreshPhotoAccessUi() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setThemedContent { + val ctx = LocalContext.current + // Same title as the old toolbar + val title = remember(recipientName) { + Phrase.from(ctx, R.string.attachmentsSendTo) + .put(StringSubstitutionConstants.NAME_KEY, recipientName ?: "") + .format() + .toString() + } + + MediaPickerFolderScreen( + viewModel = viewModel, + title = title, + handleBack = { + requireActivity().onBackPressedDispatcher.onBackPressed() + }, + onFolderClick = { folder -> + controller?.onFolderSelected(folder) + }, + manageMediaAccess = ::manageMediaAccess + ) + } + } + } + + fun manageMediaAccess() { + AttachmentManager.managePhotoAccess(requireActivity()) { + viewModel.refreshFolders() + } + } + + companion object { + private const val KEY_RECIPIENT_NAME = "recipient_name" + + fun newInstance(recipient: Recipient): MediaPickerFolderComposeFragment { + return MediaPickerFolderComposeFragment().apply { + arguments = Bundle().apply { + putString(KEY_RECIPIENT_NAME, recipient.displayName(false)) + } + } + } + } + + interface Controller { + fun onFolderSelected(folder: MediaFolder) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt new file mode 100644 index 0000000000..ec443c5028 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerFolderScreen.kt @@ -0,0 +1,156 @@ +package org.thoughtcrime.securesms.mediasend.compose + +import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.mediasend.MediaFolder +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions + +@Composable +fun MediaPickerFolderScreen( + viewModel: MediaSendViewModel, + onFolderClick: (MediaFolder) -> Unit, + title: String, + handleBack: () -> Unit, + manageMediaAccess: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.refreshFolders() + viewModel.onFolderPickerStarted() + } + + MediaPickerFolder( + folders = uiState.folders, + onFolderClick = onFolderClick, + title = title, + handleBack = handleBack, + showManageMediaAccess = uiState.showManagePhotoAccess, + manageMediaAccess = manageMediaAccess + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@SuppressLint("ConfigurationScreenWidthHeight") +@Composable +private fun MediaPickerFolder( + folders: List, + onFolderClick: (folder: MediaFolder) -> Unit, + title: String, + handleBack: () -> Unit, + showManageMediaAccess: Boolean, + manageMediaAccess : () -> Unit +) { + + // span logic: screenWidth / media_picker_folder_width + val folderWidth = dimensionResource(R.dimen.media_picker_folder_width) + val columns = maxOf(1, (LocalConfiguration.current.screenWidthDp.dp / folderWidth).toInt()) + + Scaffold( + topBar = { + BackAppBar( + title = title, + onBack = handleBack, + actions = { + if (showManageMediaAccess) { + IconButton( + onClick = { + manageMediaAccess() + } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_plus), + contentDescription = null + ) + } + } + } + ) + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier + .fillMaxSize() + .background(LocalColors.current.background), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing) + ) { + items( + items = folders, + key = { folder -> folder.bucketId } + ) { folder -> + MediaFolderCell( + title = folder.title, + count = folder.itemCount, + thumbnailUri = folder.thumbnailUri, + onClick = { onFolderClick(folder) }, + ) + } + } + } + } +} + +@Preview +@Composable +private fun MediaPickerFolderPreview() { + MediaPickerFolder( + folders = listOf( + MediaFolder( + title = "Camera", + itemCount = 0, + thumbnailUri = null, + bucketId = "camera" + ), + MediaFolder( + title = "Daily Bugle", + itemCount = 122, + thumbnailUri = null, + bucketId = "daily_bugle" + ), + MediaFolder( + title = "Screenshots", + itemCount = 42, + thumbnailUri = null, + bucketId = "screenshots" + ) + ), + onFolderClick = {}, + title = "Folders", + handleBack = {}, + showManageMediaAccess = true, + manageMediaAccess = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemComposeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemComposeFragment.kt new file mode 100644 index 0000000000..a891f9f7b8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemComposeFragment.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.mediasend.compose + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import dagger.hilt.android.AndroidEntryPoint +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel +import org.thoughtcrime.securesms.ui.setThemedContent + +@AndroidEntryPoint +class MediaPickerItemComposeFragment : Fragment() { + + private val viewModel: MediaSendViewModel by activityViewModels() + + private var controller: Controller? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + controller = activity as? Controller + ?: throw IllegalStateException("Parent activity must implement controller class.") + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val bucketId = requireArguments().getString(ARG_BUCKET_ID)!! + val title = requireArguments().getString(ARG_TITLE)!! + + return ComposeView(requireContext()).apply { + setThemedContent { + MediaPickerItemScreen( + viewModel = viewModel, + bucketId = bucketId, + title = title, + onBack = { requireActivity().onBackPressedDispatcher.onBackPressed() }, + onMediaSelected = { media -> + // Exact same path as old fragment -> Activity + controller?.onMediaSelected(media) + } + ) + } + } + } + + companion object { + private const val ARG_BUCKET_ID = "bucket_id" + private const val ARG_TITLE = "title" + private const val ARG_MAX_SELECTION = "max_selection" + + @JvmStatic + fun newInstance(bucketId: String, title: String) = + MediaPickerItemComposeFragment().apply { + arguments = bundleOf( + ARG_BUCKET_ID to bucketId, + ARG_TITLE to title, + ) + } + } + + interface Controller { + fun onMediaSelected(media: Media) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt new file mode 100644 index 0000000000..58af7e8b5a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/compose/MediaPickerItemScreen.kt @@ -0,0 +1,188 @@ +package org.thoughtcrime.securesms.mediasend.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import network.loki.messenger.R +import org.session.libsession.utilities.MediaTypes +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.Media.Companion.ALL_MEDIA_BUCKET_ID +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions + +@Composable +fun MediaPickerItemScreen( + viewModel: MediaSendViewModel, + bucketId: String, + title: String, + onBack: () -> Unit, + onMediaSelected: (Media) -> Unit, // navigate to send screen +) { + val uiState = viewModel.uiState.collectAsState().value + + LaunchedEffect(bucketId) { + viewModel.getMediaInBucket(bucketId) // triggers repository + updates uiState.bucketMedia + viewModel.onItemPickerStarted() + } + + MediaPickerItem( + title = title, + media = uiState.bucketMedia, + selectedMedia = uiState.selectedMedia, + canLongPress = uiState.canLongPress, + showMultiSelectAction = !uiState.showCountButton, + onBack = onBack, + onStartMultiSelect = { + viewModel.onMultiSelectStarted() + }, + onToggleSelection = { nextSelected -> + viewModel.onMediaSelected(nextSelected) // List + }, + onSinglePick = { media -> + onMediaSelected(media) + }, + isMultiSelect = uiState.isMultiSelect + ) + +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MediaPickerItem( + title: String, + media: List, + selectedMedia: List, + canLongPress: Boolean, + showMultiSelectAction: Boolean, + onBack: () -> Unit, + onStartMultiSelect: () -> Unit, + onToggleSelection: (selectedMedia: Media) -> Unit, + onSinglePick: (Media) -> Unit, + isMultiSelect: Boolean = false +) { + + val itemWidth = 85.dp + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val columns = maxOf(1, (screenWidth / itemWidth).toInt()) + + Scaffold( + modifier = Modifier.background(LocalColors.current.background), + topBar = { + BackAppBar( + title = title, + onBack = onBack, + actions = { + if (showMultiSelectAction) { + IconButton( + onClick = { + onStartMultiSelect() + } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_images), + contentDescription = null + ) + } + } + } + ) + }, + ) { padding -> + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier + .padding(padding) + .fillMaxSize() + .background(LocalColors.current.background), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.tinySpacing) + ) { + items(media, key = { it.uri }) { item -> + val isSelected = selectedMedia.any { it.uri == item.uri } + MediaPickerItemCell( + media = item, + isSelected = isSelected, + selectedIndex = selectedMedia.indexOfFirst { it.uri == item.uri }, + isMultiSelect = isMultiSelect, + canLongPress = canLongPress, + showSelectionOn = isSelected, + onMediaChosen = { onSinglePick(it) }, + onSelectionStarted = onStartMultiSelect, + onSelectionChanged = onToggleSelection, + ) + } + } + } +} + + +@Preview(name = "Picker - no selection") +@Composable +private fun Preview_MediaPickerItem_NoSelection() { + val media = previewMediaList() + MediaPickerItem( + title = "Screenshots", + media = media, + selectedMedia = emptyList(), + canLongPress = true, + showMultiSelectAction = true, + onBack = {}, + onStartMultiSelect = {}, + onToggleSelection = {}, + onSinglePick = {}, + ) +} + +@Preview(name = "Picker - multi-select with 2 selected") +@Composable +private fun Preview_MediaPickerItem_WithSelection() { + val media = previewMediaList() + val selected = listOf(media[1], media[4]) + + MediaPickerItem( + title = "Camera Roll", + media = media, + selectedMedia = selected, + canLongPress = true, + showMultiSelectAction = false, + onBack = {}, + onStartMultiSelect = {}, + onToggleSelection = {}, + onSinglePick = {}, + ) +} + +private fun previewMediaList(): List { + return (1..12).map { i -> + Media( + "content://preview/media/$i".toUri(), + "preview_$i.jpg", + MediaTypes.IMAGE_JPEG, + /* date */ 0L, + /* width */ 1080, + /* height */ 1080, + /* size */ 1234L, + /* bucketId */ ALL_MEDIA_BUCKET_ID, + /* caption */ null + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt index 18746d21f5..702391c596 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.unit.dp val LocalDimensions = staticCompositionLocalOf { Dimensions() } data class Dimensions( + val tinySpacing : Dp = 2.dp, val xxxsSpacing: Dp = 4.dp, val xxsSpacing: Dp = 8.dp, val xsSpacing: Dp = 12.dp, @@ -52,5 +53,5 @@ data class Dimensions( val minContentSize: Dp = 80.dp, val maxContentSize: Dp = 520.dp, val minContentSizeMedium: Dp = 160.dp, - val maxContentSizeMedium: Dp = 620.dp + val maxContentSizeMedium: Dp = 620.dp, )