[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
pull/1710/head
ThomasSession 2 months ago committed by GitHub
parent 7a113a29ea
commit d67484e8ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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<CropImageContractOptions>,
private val onPickImage: ActivityResultLauncher<Intent>
) {
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<Intent> = 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<Intent>()
)
}
return chooserIntent
}
}

@ -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<MediaFolder> getFolders(@NonNull Context context) {
FolderResult imageFolders = getFolders(context, Images.Media.EXTERNAL_CONTENT_URI);
FolderResult videoFolders = getFolders(context, Video.Media.EXTERNAL_CONTENT_URI);
Map<String, FolderData> 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<String, FolderData> mergedFolders = new HashMap<>(imageFolders.getFolderData());
for (Map.Entry<String, FolderData> 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<MediaFolder> folderNameSorter = (Comparator<MediaFolder>) (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<FolderData> 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<MediaFolder> mediaFolders = Stream.of(folders.values()).map(folder -> new MediaFolder(folder.getThumbnail(),
folder.getTitle(),
folder.getCount(),
folder.getBucketId()))
.sorted(folderNameSorter)
.toList();
List<MediaFolder> 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<String, FolderData> 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<String, FolderData> 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<Media> getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri, boolean hasOrientation) {
private @NonNull List<Media> getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri, boolean isImage) {
List<Media> 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<E> {
void onComplete(@NonNull E result);
}

@ -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<PickVisualMediaRequest> =
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 = {}
)
}
}
}

@ -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) {

@ -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 <T> 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),

@ -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,
)

@ -140,7 +140,7 @@
</FrameLayout>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/avatarDialog"
android:id="@+id/composeLayout"
android:layout_width="match_parent"
android:layout_height="match_parent" />

Loading…
Cancel
Save