[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 3 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; package org.thoughtcrime.securesms.mediasend;
import android.content.ContentUris;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
@ -12,21 +13,17 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread; import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream; 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.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import network.loki.messenger.R; 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. * Handles the retrieval of media present on the user's device.
@ -62,65 +59,93 @@ class MediaRepository {
@WorkerThread @WorkerThread
private @NonNull List<MediaFolder> getFolders(@NonNull Context context) { private @NonNull List<MediaFolder> getFolders(@NonNull Context context) {
FolderResult imageFolders = getFolders(context, Images.Media.EXTERNAL_CONTENT_URI); FolderResult imageFolders = getFolders(context, Images.Media.EXTERNAL_CONTENT_URI);
FolderResult videoFolders = getFolders(context, Video.Media.EXTERNAL_CONTENT_URI); FolderResult videoFolders = getFolders(context, Video.Media.EXTERNAL_CONTENT_URI);
Map<String, FolderData> folders = new HashMap<>(imageFolders.getFolderData());
// Merge image and video folder data
Map<String, FolderData> mergedFolders = new HashMap<>(imageFolders.getFolderData());
for (Map.Entry<String, FolderData> entry : videoFolders.getFolderData().entrySet()) { for (Map.Entry<String, FolderData> entry : videoFolders.getFolderData().entrySet()) {
if (folders.containsKey(entry.getKey())) { if (mergedFolders.containsKey(entry.getKey())) {
folders.get(entry.getKey()).incrementCount(entry.getValue().getCount()); 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 { } else {
folders.put(entry.getKey(), entry.getValue()); mergedFolders.put(entry.getKey(), entry.getValue());
} }
} }
Comparator<MediaFolder> folderNameSorter = (Comparator<MediaFolder>) (first, second) -> { // Create a list from merged folder data
if (first == null || first.getTitle() == null) return 1; List<FolderData> folderDataList = new ArrayList<>(mergedFolders.values());
if (second == null || second.getTitle() == null) return -1; // Sort folders by their latestTimestamp (most recent first)
return first.getTitle().toLowerCase().compareTo(second.getTitle().toLowerCase()); Collections.sort(folderDataList, (fd1, fd2) -> Long.compare(fd2.getLatestTimestamp(), fd1.getLatestTimestamp()));
};
List<MediaFolder> mediaFolders = Stream.of(folders.values()).map(folder -> new MediaFolder(folder.getThumbnail(), List<MediaFolder> mediaFolders = new ArrayList<>();
folder.getTitle(), for (FolderData fd : folderDataList) {
folder.getCount(), if (fd.getTitle() != null) {
folder.getBucketId())) mediaFolders.add(new MediaFolder(fd.getThumbnail(), fd.getTitle(), fd.getCount(), fd.getBucketId()));
.sorted(folderNameSorter) }
.toList(); }
// 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) { 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)); mediaFolders.add(0, new MediaFolder(allMediaThumbnail, context.getString(R.string.conversationsSettingsAllMedia), allMediaCount, Media.ALL_MEDIA_BUCKET_ID));
} }
return mediaFolders; return mediaFolders;
} }
@WorkerThread @WorkerThread
private @NonNull FolderResult getFolders(@NonNull Context context, @NonNull Uri contentUri) { private @NonNull FolderResult getFolders(@NonNull Context context, @NonNull Uri contentUri) {
Uri globalThumbnail = null; Uri globalThumbnail = null;
long thumbnailTimestamp = 0; long thumbnailTimestamp = 0;
Map<String, FolderData> folders = new HashMap<>(); 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[] projection = new String[] {
String selection = Images.Media.DATA + " NOT NULL"; Images.Media._ID,
String sortBy = Images.Media.BUCKET_DISPLAY_NAME + " COLLATE NOCASE ASC, " + Images.Media.DATE_TAKEN + " DESC"; 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)) { try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, null, sortBy)) {
while (cursor != null && cursor.moveToNext()) { if (cursor != null) {
String path = cursor.getString(cursor.getColumnIndexOrThrow(projection[0])); int idIndex = cursor.getColumnIndexOrThrow(Images.Media._ID);
Uri thumbnail = Uri.fromFile(new File(path)); int bucketIdIndex = cursor.getColumnIndexOrThrow(Images.Media.BUCKET_ID);
String bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1])); int bucketDisplayNameIndex = cursor.getColumnIndexOrThrow(Images.Media.BUCKET_DISPLAY_NAME);
String title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2])); int dateIndex = cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED);
long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3]));
FolderData folder = Util.getOrDefault(folders, bucketId, new FolderData(thumbnail, title, bucketId)); while (cursor.moveToNext()) {
long rowId = cursor.getLong(idIndex);
folder.incrementCount(); Uri thumbnail = ContentUris.withAppendedId(contentUri, rowId);
folders.put(bucketId, folder); String bucketId = cursor.getString(bucketIdIndex);
String title = cursor.getString(bucketDisplayNameIndex);
if (timestamp > thumbnailTimestamp) { long timestamp = cursor.getLong(dateIndex);
globalThumbnail = thumbnail;
thumbnailTimestamp = timestamp; 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 @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<>(); List<Media> media = new LinkedList<>();
String selection = Images.Media.BUCKET_ID + " = ? AND " + Images.Media.DATA + " NOT NULL"; String selection = Images.Media.BUCKET_ID + " = ?";
String[] selectionArgs = new String[] { bucketId }; String[] selectionArgs = new String[] { bucketId};
String sortBy = Images.Media.DATE_TAKEN + " DESC"; String sortBy = Images.Media.DATE_MODIFIED + " DESC";
String[] projection; String[] projection;
if (hasOrientation) { if (isImage) {
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 }; 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 { } 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)) { if (Media.ALL_MEDIA_BUCKET_ID.equals(bucketId)) {
selection = Images.Media.DATA + " NOT NULL"; selection = null;
selectionArgs = null; selectionArgs = null;
} }
try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, selectionArgs, sortBy)) { try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, selectionArgs, sortBy)) {
while (cursor != null && cursor.moveToNext()) { 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)); String mimetype = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.MIME_TYPE));
long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_TAKEN)); long date = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED));
int orientation = hasOrientation ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0; int orientation = isImage ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0;
int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation))); int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)));
int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation))); int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)));
long size = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.SIZE)); long size = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.SIZE));
String filename = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.DISPLAY_NAME)); 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 static class FolderData {
private final Uri thumbnail; private final Uri thumbnail;
private final String title; private final String title;
private final String bucketId; private final String bucketId;
private int count; 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.thumbnail = thumbnail;
this.title = title; this.title = title;
this.bucketId = bucketId; this.bucketId = bucketId;
this.count = 0;
this.latestTimestamp = 0;
} }
Uri getThumbnail() { Uri getThumbnail() {
@ -324,8 +352,19 @@ class MediaRepository {
void incrementCount(int amount) { void incrementCount(int amount) {
count += amount; count += amount;
} }
void updateTimestamp(long ts) {
if (ts > latestTimestamp) {
latestTimestamp = ts;
}
}
long getLatestTimestamp() {
return latestTimestamp;
}
} }
interface Callback<E> { interface Callback<E> {
void onComplete(@NonNull E result); void onComplete(@NonNull E result);
} }

@ -1,11 +1,8 @@
package org.thoughtcrime.securesms.preferences package org.thoughtcrime.securesms.preferences
import android.Manifest import android.Manifest
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
@ -17,8 +14,11 @@ import android.view.MenuItem
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.DrawableRes
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.asImageBitmap 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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter 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.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.canhub.cropper.CropImageContract 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 com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint 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.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.BuildConfig import network.loki.messenger.BuildConfig
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivitySettingsBinding 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.SSKEnvironment.ProfileManagerProtocol
import org.session.libsession.utilities.StringSubstitutionConstants.VERSION_KEY import org.session.libsession.utilities.StringSubstitutionConstants.VERSION_KEY
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.ScreenLockActionBarActivity
import org.thoughtcrime.securesms.avatar.AvatarSelection
import org.thoughtcrime.securesms.debugmenu.DebugActivity import org.thoughtcrime.securesms.debugmenu.DebugActivity
import org.thoughtcrime.securesms.home.PathActivity import org.thoughtcrime.securesms.home.PathActivity
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity 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.GetString
import org.thoughtcrime.securesms.ui.LargeItemButton import org.thoughtcrime.securesms.ui.LargeItemButton
import org.thoughtcrime.securesms.ui.LargeItemButtonWithDrawable 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.PrimaryOutlineButton
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineCopyButton import org.thoughtcrime.securesms.ui.components.PrimaryOutlineCopyButton
import org.thoughtcrime.securesms.ui.contentDescription 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.setThemedContent
import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions 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.PreviewTheme
import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.ThemeColors
import org.thoughtcrime.securesms.ui.theme.dangerButtonColors 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 org.thoughtcrime.securesms.util.push
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class SettingsActivity : ScreenLockActionBarActivity() { class SettingsActivity : ScreenLockActionBarActivity() {
private val TAG = "SettingsActivity" private val TAG = "SettingsActivity"
@ -126,19 +129,33 @@ class SettingsActivity : ScreenLockActionBarActivity() {
viewModel.onAvatarPicked(result) viewModel.onAvatarPicked(result)
} }
private val onPickImage = registerForActivityResult( private val pickPhotoLauncher: ActivityResultLauncher<PickVisualMediaRequest> =
ActivityResultContracts.StartActivityForResult() registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? ->
){ result -> uri?.let {
if (result.resultCode != RESULT_OK) return@registerForActivityResult showAvatarPickerOptions = false // close the bottom sheet
val outputFile = Uri.fromFile(File(cacheDir, "cropped")) // Handle the selected image URI
val inputFile: Uri? = result.data?.data ?: viewModel.getTempFile()?.let(Uri::fromFile) val outputFile = Uri.fromFile(File(cacheDir, "cropped"))
cropImage(inputFile, outputFile) 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( private val hideRecoveryLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { result -> ) { result ->
if (result.resultCode != RESULT_OK) return@registerForActivityResult if (result.resultCode != RESULT_OK) return@registerForActivityResult
if(result.data?.getBooleanExtra(RecoveryPasswordActivity.RESULT_RECOVERY_HIDDEN, false) == true){ 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 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 { companion object {
private const val SCROLL_STATE = "SCROLL_STATE" private const val SCROLL_STATE = "SCROLL_STATE"
@ -164,19 +186,28 @@ class SettingsActivity : ScreenLockActionBarActivity() {
supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_x) supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_x)
// set the compose dialog content // set the compose dialog content
binding.avatarDialog.setThemedContent { binding.composeLayout.setThemedContent {
if(showAvatarDialog){ SettingsComposeContent(
AvatarDialogContainer( showAvatarDialog = showAvatarDialog,
saveAvatar = viewModel::saveAvatar, startAvatarSelection = ::startAvatarSelection,
removeAvatar = viewModel::removeAvatar, saveAvatar = viewModel::saveAvatar,
startAvatarSelection = ::startAvatarSelection 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 { binding.run {
profilePictureView.setOnClickListener { profilePictureView.setOnClickListener {
binding.avatarDialog.isVisible = true
showAvatarDialog = true showAvatarDialog = true
} }
ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) }
@ -354,20 +385,41 @@ class SettingsActivity : ScreenLockActionBarActivity() {
// Ask for an optional camera permission. // Ask for an optional camera permission.
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.CAMERA) .request(Manifest.permission.CAMERA)
.onAnyResult { .onAnyDenied {
avatarSelection.startAvatarSelection( showAvatarPickerOptionCamera = false
includeClear = false, showAvatarPickerOptions = true
attemptToIncludeCamera = true, }
createTempFile = viewModel::createTempFile .onAllGranted {
) showAvatarPickerOptionCamera = true
showAvatarPickerOptions = true
} }
.execute() .execute()
} }
private fun cropImage(inputFile: Uri?, outputFile: Uri?){ private fun cropImage(inputFile: Uri?, outputFile: Uri?){
avatarSelection.circularCropImage( onAvatarCropped.launch(
inputFile = inputFile, CropImageContractOptions(
outputFile = outputFile, 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 // 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 @Composable
fun AvatarDialogContainer( fun AvatarDialogContainer(
startAvatarSelection: ()->Unit, 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 @Composable
fun AvatarDialog( fun AvatarDialog(
state: SettingsViewModel.AvatarDialogState, state: SettingsViewModel.AvatarDialogState,
@ -528,7 +684,7 @@ class SettingsActivity : ScreenLockActionBarActivity() {
Box( Box(
modifier = Modifier modifier = Modifier
.padding(top = LocalDimensions.current.smallSpacing) .padding(top = LocalDimensions.current.smallSpacing)
.size(dimensionResource(id = R.dimen.large_profile_picture_size)) .size(LocalDimensions.current.iconXXLarge)
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null // the ripple doesn't look nice as a square with the plus icon on top too 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 // temporary image
is TempAvatar -> { is TempAvatar -> {
Image( Image(
modifier = Modifier.size(dimensionResource(id = R.dimen.large_profile_picture_size)) modifier = Modifier.size(LocalDimensions.current.iconXXLarge)
.clip(shape = CircleShape,), .clip(shape = CircleShape,),
bitmap = BitmapFactory.decodeByteArray(s.data, 0, s.data.size).asImageBitmap(), bitmap = BitmapFactory.decodeByteArray(s.data, 0, s.data.size).asImageBitmap(),
contentDescription = null contentDescription = null
@ -562,9 +718,11 @@ class SettingsActivity : ScreenLockActionBarActivity() {
// empty state // empty state
else -> { else -> {
Image( Image(
modifier = Modifier.align(Alignment.Center) modifier = Modifier.fillMaxSize()
.size(40.dp), .padding(LocalDimensions.current.badgeSize)
.align(Alignment.Center),
painter = painterResource(id = R.drawable.ic_image), painter = painterResource(id = R.drawable.ic_image),
contentScale = ContentScale.Fit,
contentDescription = null, contentDescription = null,
colorFilter = ColorFilter.tint(LocalColors.current.textSecondary) 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) { if (restoredModel != null) {
editorModel = restoredModel; editorModel = restoredModel;
restoredModel = null; restoredModel = null;
} else if (savedInstanceState != null) {
editorModel = new Data(savedInstanceState).readModel();
} }
if (editorModel == null) { if (editorModel == null) {

@ -4,14 +4,17 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable 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.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.LocalType 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. * A bottom sheet dialog that displays a list of options.
* *
@ -46,16 +75,10 @@ fun <T> ActionSheet(
) { ) {
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
ModalBottomSheet( BaseBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = sheetState, sheetState = sheetState,
shape = RoundedCornerShape( onDismissRequest = onDismissRequest
topStart = LocalDimensions.current.xsSpacing, ){
topEnd = LocalDimensions.current.xsSpacing
),
dragHandle = {},
containerColor = LocalColors.current.backgroundSecondary,
) {
for (option in options) { for (option in options) {
ActionSheetItem( ActionSheetItem(
text = optionTitle(option), text = optionTitle(option),

@ -26,4 +26,6 @@ data class Dimensions(
val badgeSize: Dp = 20.dp, val badgeSize: Dp = 20.dp,
val iconMedium: Dp = 24.dp, val iconMedium: Dp = 24.dp,
val iconLarge: Dp = 46.dp, val iconLarge: Dp = 46.dp,
val iconXLarge: Dp = 60.dp,
val iconXXLarge: Dp = 80.dp,
) )

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

Loading…
Cancel
Save