From d67484e8ed4a6459b44d34cf98ec48f368458dd6 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 12 Feb 2025 06:54:09 +0200 Subject: [PATCH] [SES-3292] Updated photo picker (#934) * Using the new API for the photo picker * Using the modern version of the photo picker * Proper way to get images * Sorting folders by recency * We shouldn't reuse the savedstate but instead rely on our custom caching * Clean up * PR clean up --- .../securesms/avatar/AvatarSelection.kt | 129 --------- .../securesms/mediasend/MediaRepository.java | 167 ++++++----- .../securesms/preferences/SettingsActivity.kt | 271 ++++++++++++++---- .../scribbles/ImageEditorFragment.java | 2 - .../{ActionSheet.kt => BottomSheets.kt} | 41 ++- .../securesms/ui/theme/Dimensions.kt | 2 + app/src/main/res/layout/activity_settings.xml | 2 +- 7 files changed, 360 insertions(+), 254 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt rename app/src/main/java/org/thoughtcrime/securesms/ui/components/{ActionSheet.kt => BottomSheets.kt} (86%) diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt deleted file mode 100644 index bf19c3cc34..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt +++ /dev/null @@ -1,129 +0,0 @@ -package org.thoughtcrime.securesms.avatar - -import android.Manifest -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.provider.MediaStore -import androidx.activity.result.ActivityResultLauncher -import androidx.core.content.ContextCompat -import com.canhub.cropper.CropImageContractOptions -import com.canhub.cropper.CropImageOptions -import com.canhub.cropper.CropImageView -import network.loki.messenger.R -import org.session.libsession.utilities.getColorFromAttr -import org.session.libsignal.utilities.ExternalStorageUtil.getImageDir -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.NoExternalStorageException -import org.thoughtcrime.securesms.util.FileProviderUtil -import java.io.File -import java.io.IOException -import java.util.LinkedList - -class AvatarSelection( - private val activity: Activity, - private val onAvatarCropped: ActivityResultLauncher, - private val onPickImage: ActivityResultLauncher -) { - private val TAG: String = AvatarSelection::class.java.simpleName - - private val bgColor by lazy { activity.getColorFromAttr(android.R.attr.colorPrimary) } - private val txtColor by lazy { activity.getColorFromAttr(android.R.attr.textColorPrimary) } - private val imageScrim by lazy { ContextCompat.getColor(activity, R.color.avatar_background) } - private val activityTitle by lazy { activity.getString(R.string.image) } - - /** - * Returns result on [.REQUEST_CODE_CROP_IMAGE] - */ - fun circularCropImage( - inputFile: Uri?, - outputFile: Uri? - ) { - onAvatarCropped.launch( - CropImageContractOptions( - uri = inputFile, - cropImageOptions = CropImageOptions( - guidelines = CropImageView.Guidelines.ON, - aspectRatioX = 1, - aspectRatioY = 1, - fixAspectRatio = true, - cropShape = CropImageView.CropShape.OVAL, - customOutputUri = outputFile, - allowRotation = true, - allowFlipping = true, - backgroundColor = imageScrim, - toolbarColor = bgColor, - activityBackgroundColor = bgColor, - toolbarTintColor = txtColor, - toolbarBackButtonColor = txtColor, - toolbarTitleColor = txtColor, - activityMenuIconColor = txtColor, - activityMenuTextColor = txtColor, - activityTitle = activityTitle - ) - ) - ) - } - - /** - * Returns result on [.REQUEST_CODE_AVATAR] - * - * @return Temporary capture file if created. - */ - fun startAvatarSelection( - includeClear: Boolean, - attemptToIncludeCamera: Boolean, - createTempFile: ()->File? - ) { - var captureFile: File? = null - val hasCameraPermission = ContextCompat - .checkSelfPermission( - activity, - Manifest.permission.CAMERA - ) == PackageManager.PERMISSION_GRANTED - if (attemptToIncludeCamera && hasCameraPermission) { - captureFile = createTempFile() - } - - val chooserIntent = createAvatarSelectionIntent(activity, captureFile, includeClear) - onPickImage.launch(chooserIntent) - } - - private fun createAvatarSelectionIntent( - context: Context, - tempCaptureFile: File?, - includeClear: Boolean - ): Intent { - val extraIntents: MutableList = LinkedList() - val galleryIntent = Intent(Intent.ACTION_OPEN_DOCUMENT) - galleryIntent.setType("image/*") - - if (tempCaptureFile != null) { - val uri = FileProviderUtil.getUriFor(context, tempCaptureFile) - val cameraIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) - cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, uri) - cameraIntent.setFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - extraIntents.add(cameraIntent) - } - - if (includeClear) { - extraIntents.add(Intent("network.loki.securesms.action.CLEAR_PROFILE_PHOTO")) - } - - val chooserIntent = Intent.createChooser( - galleryIntent, - context.getString(R.string.image) - ) - - if (!extraIntents.isEmpty()) { - chooserIntent.putExtra( - Intent.EXTRA_INITIAL_INTENTS, - extraIntents.toTypedArray() - ) - } - - return chooserIntent - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java index b347071fe4..46f52dc783 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.mediasend; +import android.content.ContentUris; import android.content.Context; import android.database.Cursor; import android.net.Uri; @@ -12,21 +13,17 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import com.annimon.stream.Stream; -import java.io.File; +import org.session.libsignal.utilities.guava.Optional; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.MediaUtil; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import network.loki.messenger.R; -import org.session.libsession.utilities.Util; -import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.mms.PartAuthority; -import org.thoughtcrime.securesms.util.FilenameUtils; -import org.thoughtcrime.securesms.util.MediaUtil; /** * Handles the retrieval of media present on the user's device. @@ -62,65 +59,93 @@ class MediaRepository { @WorkerThread private @NonNull List getFolders(@NonNull Context context) { - FolderResult imageFolders = getFolders(context, Images.Media.EXTERNAL_CONTENT_URI); - FolderResult videoFolders = getFolders(context, Video.Media.EXTERNAL_CONTENT_URI); - Map folders = new HashMap<>(imageFolders.getFolderData()); + FolderResult imageFolders = getFolders(context, Images.Media.EXTERNAL_CONTENT_URI); + FolderResult videoFolders = getFolders(context, Video.Media.EXTERNAL_CONTENT_URI); + // Merge image and video folder data + Map mergedFolders = new HashMap<>(imageFolders.getFolderData()); for (Map.Entry entry : videoFolders.getFolderData().entrySet()) { - if (folders.containsKey(entry.getKey())) { - folders.get(entry.getKey()).incrementCount(entry.getValue().getCount()); + if (mergedFolders.containsKey(entry.getKey())) { + mergedFolders.get(entry.getKey()).incrementCount(entry.getValue().getCount()); + // Also update timestamp if the video has a more recent timestamp. + mergedFolders.get(entry.getKey()).updateTimestamp(entry.getValue().getLatestTimestamp()); } else { - folders.put(entry.getKey(), entry.getValue()); + mergedFolders.put(entry.getKey(), entry.getValue()); } } - Comparator folderNameSorter = (Comparator) (first, second) -> { - if (first == null || first.getTitle() == null) return 1; - if (second == null || second.getTitle() == null) return -1; - return first.getTitle().toLowerCase().compareTo(second.getTitle().toLowerCase()); - }; + // Create a list from merged folder data + List folderDataList = new ArrayList<>(mergedFolders.values()); + // Sort folders by their latestTimestamp (most recent first) + Collections.sort(folderDataList, (fd1, fd2) -> Long.compare(fd2.getLatestTimestamp(), fd1.getLatestTimestamp())); - List mediaFolders = Stream.of(folders.values()).map(folder -> new MediaFolder(folder.getThumbnail(), - folder.getTitle(), - folder.getCount(), - folder.getBucketId())) - .sorted(folderNameSorter) - .toList(); + List mediaFolders = new ArrayList<>(); + for (FolderData fd : folderDataList) { + if (fd.getTitle() != null) { + mediaFolders.add(new MediaFolder(fd.getThumbnail(), fd.getTitle(), fd.getCount(), fd.getBucketId())); + } + } + + // Determine the global thumbnail from the most recent media across image and video queries + Uri allMediaThumbnail = imageFolders.getThumbnailTimestamp() > videoFolders.getThumbnailTimestamp() + ? imageFolders.getThumbnail() : videoFolders.getThumbnail(); - Uri allMediaThumbnail = imageFolders.getThumbnailTimestamp() > videoFolders.getThumbnailTimestamp() ? imageFolders.getThumbnail() : videoFolders.getThumbnail(); if (allMediaThumbnail != null) { - int allMediaCount = Stream.of(mediaFolders).reduce(0, (count, folder) -> count + folder.getItemCount()); + int allMediaCount = 0; + for (MediaFolder folder : mediaFolders) { + allMediaCount += folder.getItemCount(); + } + // Prepend an "All Media" folder mediaFolders.add(0, new MediaFolder(allMediaThumbnail, context.getString(R.string.conversationsSettingsAllMedia), allMediaCount, Media.ALL_MEDIA_BUCKET_ID)); } return mediaFolders; } + @WorkerThread private @NonNull FolderResult getFolders(@NonNull Context context, @NonNull Uri contentUri) { - Uri globalThumbnail = null; - long thumbnailTimestamp = 0; - Map folders = new HashMap<>(); - - String[] projection = new String[] { Images.Media.DATA, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_TAKEN }; - String selection = Images.Media.DATA + " NOT NULL"; - String sortBy = Images.Media.BUCKET_DISPLAY_NAME + " COLLATE NOCASE ASC, " + Images.Media.DATE_TAKEN + " DESC"; + Uri globalThumbnail = null; + long thumbnailTimestamp = 0; + Map folders = new HashMap<>(); + + String[] projection = new String[] { + Images.Media._ID, + Images.Media.BUCKET_ID, + Images.Media.BUCKET_DISPLAY_NAME, + Images.Media.DATE_MODIFIED + }; + + String selection = null; + String sortBy = Images.Media.BUCKET_DISPLAY_NAME + " COLLATE NOCASE ASC, " + + Images.Media.DATE_MODIFIED + " DESC"; try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, null, sortBy)) { - while (cursor != null && cursor.moveToNext()) { - String path = cursor.getString(cursor.getColumnIndexOrThrow(projection[0])); - Uri thumbnail = Uri.fromFile(new File(path)); - String bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1])); - String title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2])); - long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3])); - FolderData folder = Util.getOrDefault(folders, bucketId, new FolderData(thumbnail, title, bucketId)); - - folder.incrementCount(); - folders.put(bucketId, folder); - - if (timestamp > thumbnailTimestamp) { - globalThumbnail = thumbnail; - thumbnailTimestamp = timestamp; + if (cursor != null) { + int idIndex = cursor.getColumnIndexOrThrow(Images.Media._ID); + int bucketIdIndex = cursor.getColumnIndexOrThrow(Images.Media.BUCKET_ID); + int bucketDisplayNameIndex = cursor.getColumnIndexOrThrow(Images.Media.BUCKET_DISPLAY_NAME); + int dateIndex = cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED); + + while (cursor.moveToNext()) { + long rowId = cursor.getLong(idIndex); + Uri thumbnail = ContentUris.withAppendedId(contentUri, rowId); + String bucketId = cursor.getString(bucketIdIndex); + String title = cursor.getString(bucketDisplayNameIndex); + long timestamp = cursor.getLong(dateIndex); + + FolderData folder = folders.get(bucketId); + if (folder == null) { + folder = new FolderData(thumbnail, title, bucketId); + folders.put(bucketId, folder); + } + folder.incrementCount(); + folder.updateTimestamp(timestamp); + + if (timestamp > thumbnailTimestamp) { + globalThumbnail = thumbnail; + thumbnailTimestamp = timestamp; + } } } } @@ -142,37 +167,38 @@ class MediaRepository { } @WorkerThread - private @NonNull List getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri, boolean hasOrientation) { + private @NonNull List getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri, boolean isImage) { List media = new LinkedList<>(); - String selection = Images.Media.BUCKET_ID + " = ? AND " + Images.Media.DATA + " NOT NULL"; - String[] selectionArgs = new String[] { bucketId }; - String sortBy = Images.Media.DATE_TAKEN + " DESC"; + String selection = Images.Media.BUCKET_ID + " = ?"; + String[] selectionArgs = new String[] { bucketId}; + String sortBy = Images.Media.DATE_MODIFIED + " DESC"; String[] projection; - if (hasOrientation) { - projection = new String[] { Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.ORIENTATION, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE, Images.Media.DISPLAY_NAME }; + if (isImage) { + projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_MODIFIED, Images.Media.ORIENTATION, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE, Images.Media.DISPLAY_NAME}; } else { - projection = new String[] { Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE, Images.Media.DISPLAY_NAME }; + projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_MODIFIED, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE, Images.Media.DISPLAY_NAME}; } if (Media.ALL_MEDIA_BUCKET_ID.equals(bucketId)) { - selection = Images.Media.DATA + " NOT NULL"; + selection = null; selectionArgs = null; } try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, selectionArgs, sortBy)) { while (cursor != null && cursor.moveToNext()) { - Uri uri = Uri.withAppendedPath(contentUri, cursor.getString(cursor.getColumnIndexOrThrow(Images.Media._ID))); + long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0])); + Uri uri = ContentUris.withAppendedId(contentUri, rowId); String mimetype = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.MIME_TYPE)); - long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_TAKEN)); - int orientation = hasOrientation ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0; + long date = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED)); + int orientation = isImage ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0; int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation))); int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation))); long size = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.SIZE)); String filename = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.DISPLAY_NAME)); - media.add(new Media(uri, filename, mimetype, dateTaken, width, height, size, Optional.of(bucketId), Optional.absent())); + media.add(new Media(uri, filename, mimetype, date, width, height, size, Optional.of(bucketId), Optional.absent())); } } @@ -289,16 +315,18 @@ class MediaRepository { } private static class FolderData { - private final Uri thumbnail; + private final Uri thumbnail; private final String title; private final String bucketId; - private int count; + private long latestTimestamp; // New field - private FolderData(Uri thumbnail, String title, String bucketId) { + private FolderData(@NonNull Uri thumbnail, @NonNull String title, @NonNull String bucketId) { this.thumbnail = thumbnail; - this.title = title; - this.bucketId = bucketId; + this.title = title; + this.bucketId = bucketId; + this.count = 0; + this.latestTimestamp = 0; } Uri getThumbnail() { @@ -324,8 +352,19 @@ class MediaRepository { void incrementCount(int amount) { count += amount; } + + void updateTimestamp(long ts) { + if (ts > latestTimestamp) { + latestTimestamp = ts; + } + } + + long getLatestTimestamp() { + return latestTimestamp; + } } + interface Callback { void onComplete(@NonNull E result); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index dfffd9fd5c..1bd42f100c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -1,11 +1,8 @@ package org.thoughtcrime.securesms.preferences import android.Manifest -import android.app.Activity -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.graphics.BitmapFactory import android.net.Uri import android.os.Bundle @@ -17,8 +14,11 @@ import android.view.MenuItem import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels +import androidx.annotation.DrawableRes import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -29,10 +29,16 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -45,27 +51,22 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope -import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.canhub.cropper.CropImageContract +import com.canhub.cropper.CropImageContractOptions +import com.canhub.cropper.CropImageOptions +import com.canhub.cropper.CropImageView import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.databinding.ActivitySettingsBinding @@ -73,9 +74,9 @@ import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol import org.session.libsession.utilities.StringSubstitutionConstants.VERSION_KEY import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.getColorFromAttr import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ScreenLockActionBarActivity -import org.thoughtcrime.securesms.avatar.AvatarSelection import org.thoughtcrime.securesms.debugmenu.DebugActivity import org.thoughtcrime.securesms.home.PathActivity import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity @@ -93,6 +94,7 @@ import org.thoughtcrime.securesms.ui.Divider import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.LargeItemButton import org.thoughtcrime.securesms.ui.LargeItemButtonWithDrawable +import org.thoughtcrime.securesms.ui.components.BaseBottomSheet import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton import org.thoughtcrime.securesms.ui.components.PrimaryOutlineCopyButton import org.thoughtcrime.securesms.ui.contentDescription @@ -100,16 +102,17 @@ import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.setThemedContent 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.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.dangerButtonColors -import org.thoughtcrime.securesms.util.InternetConnectivity +import org.thoughtcrime.securesms.util.FileProviderUtil import org.thoughtcrime.securesms.util.push import java.io.File import javax.inject.Inject -@AndroidEntryPoint + @AndroidEntryPoint class SettingsActivity : ScreenLockActionBarActivity() { private val TAG = "SettingsActivity" @@ -126,19 +129,33 @@ class SettingsActivity : ScreenLockActionBarActivity() { viewModel.onAvatarPicked(result) } - private val onPickImage = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ){ result -> - if (result.resultCode != RESULT_OK) return@registerForActivityResult + private val pickPhotoLauncher: ActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? -> + uri?.let { + showAvatarPickerOptions = false // close the bottom sheet - val outputFile = Uri.fromFile(File(cacheDir, "cropped")) - val inputFile: Uri? = result.data?.data ?: viewModel.getTempFile()?.let(Uri::fromFile) - cropImage(inputFile, outputFile) - } + // Handle the selected image URI + val outputFile = Uri.fromFile(File(cacheDir, "cropped")) + cropImage(it, outputFile) + + } + } + + // Launcher for capturing a photo using the camera. + private val takePhotoLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success: Boolean -> + if (success) { + showAvatarPickerOptions = false // close the bottom sheet + + val outputFile = Uri.fromFile(File(cacheDir, "cropped")) + cropImage(viewModel.getTempFile()?.let(Uri::fromFile), outputFile) + } else { + Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_SHORT).show() + } + } private val hideRecoveryLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() - ) { result -> + ) { result -> if (result.resultCode != RESULT_OK) return@registerForActivityResult if(result.data?.getBooleanExtra(RecoveryPasswordActivity.RESULT_RECOVERY_HIDDEN, false) == true){ @@ -146,9 +163,14 @@ class SettingsActivity : ScreenLockActionBarActivity() { } } - private val avatarSelection = AvatarSelection(this, onAvatarCropped, onPickImage) - private var showAvatarDialog: Boolean by mutableStateOf(false) + private var showAvatarPickerOptionCamera: Boolean by mutableStateOf(false) + private var showAvatarPickerOptions: Boolean by mutableStateOf(false) + + private val bgColor by lazy { getColorFromAttr(android.R.attr.colorPrimary) } + private val txtColor by lazy { getColorFromAttr(android.R.attr.textColorPrimary) } + private val imageScrim by lazy { ContextCompat.getColor(this, R.color.avatar_background) } + private val activityTitle by lazy { getString(R.string.image) } companion object { private const val SCROLL_STATE = "SCROLL_STATE" @@ -164,19 +186,28 @@ class SettingsActivity : ScreenLockActionBarActivity() { supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_x) // set the compose dialog content - binding.avatarDialog.setThemedContent { - if(showAvatarDialog){ - AvatarDialogContainer( - saveAvatar = viewModel::saveAvatar, - removeAvatar = viewModel::removeAvatar, - startAvatarSelection = ::startAvatarSelection - ) - } + binding.composeLayout.setThemedContent { + SettingsComposeContent( + showAvatarDialog = showAvatarDialog, + startAvatarSelection = ::startAvatarSelection, + saveAvatar = viewModel::saveAvatar, + removeAvatar = viewModel::removeAvatar, + showAvatarPickerOptions = showAvatarPickerOptions, + showCamera = showAvatarPickerOptionCamera, + onSheetDismissRequest = { showAvatarPickerOptions = false }, + onGalleryPicked = { + pickPhotoLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, + onCameraPicked = { + viewModel.createTempFile()?.let{ + takePhotoLauncher.launch(FileProviderUtil.getUriFor(this, it)) + } + } + ) } binding.run { profilePictureView.setOnClickListener { - binding.avatarDialog.isVisible = true showAvatarDialog = true } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } @@ -354,20 +385,41 @@ class SettingsActivity : ScreenLockActionBarActivity() { // Ask for an optional camera permission. Permissions.with(this) .request(Manifest.permission.CAMERA) - .onAnyResult { - avatarSelection.startAvatarSelection( - includeClear = false, - attemptToIncludeCamera = true, - createTempFile = viewModel::createTempFile - ) + .onAnyDenied { + showAvatarPickerOptionCamera = false + showAvatarPickerOptions = true + } + .onAllGranted { + showAvatarPickerOptionCamera = true + showAvatarPickerOptions = true } .execute() } private fun cropImage(inputFile: Uri?, outputFile: Uri?){ - avatarSelection.circularCropImage( - inputFile = inputFile, - outputFile = outputFile, + onAvatarCropped.launch( + CropImageContractOptions( + uri = inputFile, + cropImageOptions = CropImageOptions( + guidelines = CropImageView.Guidelines.ON, + aspectRatioX = 1, + aspectRatioY = 1, + fixAspectRatio = true, + cropShape = CropImageView.CropShape.OVAL, + customOutputUri = outputFile, + allowRotation = true, + allowFlipping = true, + backgroundColor = imageScrim, + toolbarColor = bgColor, + activityBackgroundColor = bgColor, + toolbarTintColor = txtColor, + toolbarBackButtonColor = txtColor, + toolbarTitleColor = txtColor, + activityMenuIconColor = txtColor, + activityMenuTextColor = txtColor, + activityTitle = activityTitle + ) + ) ) } // endregion @@ -492,6 +544,38 @@ class SettingsActivity : ScreenLockActionBarActivity() { } } + @Composable + fun SettingsComposeContent( + showAvatarDialog: Boolean, + startAvatarSelection: ()->Unit, + saveAvatar: ()->Unit, + removeAvatar: ()->Unit, + showAvatarPickerOptions: Boolean, + showCamera: Boolean, + onSheetDismissRequest: () -> Unit, + onGalleryPicked: () -> Unit, + onCameraPicked: () -> Unit + ){ + // dialog for the avatar + if(showAvatarDialog) { + AvatarDialogContainer( + startAvatarSelection = startAvatarSelection, + saveAvatar = saveAvatar, + removeAvatar = removeAvatar + ) + } + + // bottom sheets with options for avatar: Gallery or photo + if(showAvatarPickerOptions) { + AvatarBottomSheet( + showCamera = showCamera, + onDismissRequest = onSheetDismissRequest, + onGalleryPicked = onGalleryPicked, + onCameraPicked = onCameraPicked + ) + } + } + @Composable fun AvatarDialogContainer( startAvatarSelection: ()->Unit, @@ -508,6 +592,78 @@ class SettingsActivity : ScreenLockActionBarActivity() { ) } + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun AvatarBottomSheet( + showCamera: Boolean, + onDismissRequest: () -> Unit, + onGalleryPicked: () -> Unit, + onCameraPicked: () -> Unit + ){ + BaseBottomSheet( + sheetState = rememberModalBottomSheetState(), + onDismissRequest = onDismissRequest + ){ + Row( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = LocalDimensions.current.spacing) + .padding(bottom = LocalDimensions.current.spacing), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.spacing) + ) { + AvatarOption( + title = stringResource(R.string.image), + iconRes = R.drawable.ic_image, + onClick = onGalleryPicked + ) + + if(showCamera) { + AvatarOption( + title = stringResource(R.string.contentDescriptionCamera), + iconRes = R.drawable.ic_camera, + onClick = onCameraPicked + ) + } + } + } + } + + @Composable + fun AvatarOption( + modifier: Modifier = Modifier, + title: String, + @DrawableRes iconRes: Int, + onClick: () -> Unit + ){ + Column( + modifier = modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false), + onClick = onClick + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + modifier = Modifier.size(LocalDimensions.current.iconXLarge) + .background( + shape = CircleShape, + color = LocalColors.current.backgroundBubbleReceived, + ) + .padding(LocalDimensions.current.smallSpacing), + painter = painterResource(id = iconRes), + contentScale = ContentScale.Fit, + contentDescription = null, + colorFilter = ColorFilter.tint(LocalColors.current.textSecondary) + ) + + Text( + modifier = Modifier.padding(top = LocalDimensions.current.xxsSpacing), + text = title, + style = LocalType.current.base, + color = LocalColors.current.text + ) + } + } + @Composable fun AvatarDialog( state: SettingsViewModel.AvatarDialogState, @@ -528,7 +684,7 @@ class SettingsActivity : ScreenLockActionBarActivity() { Box( modifier = Modifier .padding(top = LocalDimensions.current.smallSpacing) - .size(dimensionResource(id = R.dimen.large_profile_picture_size)) + .size(LocalDimensions.current.iconXXLarge) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null // the ripple doesn't look nice as a square with the plus icon on top too @@ -552,7 +708,7 @@ class SettingsActivity : ScreenLockActionBarActivity() { // temporary image is TempAvatar -> { Image( - modifier = Modifier.size(dimensionResource(id = R.dimen.large_profile_picture_size)) + modifier = Modifier.size(LocalDimensions.current.iconXXLarge) .clip(shape = CircleShape,), bitmap = BitmapFactory.decodeByteArray(s.data, 0, s.data.size).asImageBitmap(), contentDescription = null @@ -562,9 +718,11 @@ class SettingsActivity : ScreenLockActionBarActivity() { // empty state else -> { Image( - modifier = Modifier.align(Alignment.Center) - .size(40.dp), + modifier = Modifier.fillMaxSize() + .padding(LocalDimensions.current.badgeSize) + .align(Alignment.Center), painter = painterResource(id = R.drawable.ic_image), + contentScale = ContentScale.Fit, contentDescription = null, colorFilter = ColorFilter.tint(LocalColors.current.textSecondary) ) @@ -620,4 +778,19 @@ class SettingsActivity : ScreenLockActionBarActivity() { ) } } + + @Preview + @Composable + fun PreviewAvatarSheet( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors + ){ + PreviewTheme(colors) { + AvatarBottomSheet( + showCamera = true, + onDismissRequest = {}, + onGalleryPicked = {}, + onCameraPicked = {} + ) + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index 39b75c7a82..83c38e155a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -132,8 +132,6 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu if (restoredModel != null) { editorModel = restoredModel; restoredModel = null; - } else if (savedInstanceState != null) { - editorModel = new Data(savedInstanceState).readModel(); } if (editorModel == null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ActionSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomSheets.kt similarity index 86% rename from app/src/main/java/org/thoughtcrime/securesms/ui/components/ActionSheet.kt rename to app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomSheets.kt index 171539ab39..310c6caf34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ActionSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomSheets.kt @@ -4,14 +4,17 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -25,6 +28,32 @@ import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType + +/** + * The base bottom sheet with our app's styling + */ +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun BaseBottomSheet( + sheetState: SheetState, + onDismissRequest: () -> Unit, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + content: @Composable ColumnScope.() -> Unit +){ + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + shape = RoundedCornerShape( + topStart = LocalDimensions.current.xsSpacing, + topEnd = LocalDimensions.current.xsSpacing + ), + dragHandle = dragHandle, + containerColor = LocalColors.current.backgroundSecondary, + content = content + ) +} + + /** * A bottom sheet dialog that displays a list of options. * @@ -46,16 +75,10 @@ fun ActionSheet( ) { val sheetState = rememberModalBottomSheetState() - ModalBottomSheet( - onDismissRequest = onDismissRequest, + BaseBottomSheet( sheetState = sheetState, - shape = RoundedCornerShape( - topStart = LocalDimensions.current.xsSpacing, - topEnd = LocalDimensions.current.xsSpacing - ), - dragHandle = {}, - containerColor = LocalColors.current.backgroundSecondary, - ) { + onDismissRequest = onDismissRequest + ){ for (option in options) { ActionSheetItem( text = optionTitle(option), 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 bcadaac1d4..57e5eb0945 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 @@ -26,4 +26,6 @@ data class Dimensions( val badgeSize: Dp = 20.dp, val iconMedium: Dp = 24.dp, val iconLarge: Dp = 46.dp, + val iconXLarge: Dp = 60.dp, + val iconXXLarge: Dp = 80.dp, ) diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 3bfc9c65b0..95ae48f389 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -140,7 +140,7 @@