diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarRenderer.kt index 7f8347c178..41063a8c53 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarRenderer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarRenderer.kt @@ -40,7 +40,7 @@ object AvatarRenderer { when (avatar) { is Avatar.Resource -> renderResource(context, avatar, onAvatarRendered, onRenderFailed) is Avatar.Vector -> renderVector(context, avatar, onAvatarRendered, onRenderFailed) - is Avatar.Photo -> renderPhoto(context, avatar, onAvatarRendered) + is Avatar.Photo -> renderPhoto(context, avatar, onAvatarRendered, onRenderFailed) is Avatar.Text -> renderText(context, avatar, onAvatarRendered, onRenderFailed) } } @@ -78,11 +78,16 @@ object AvatarRenderer { } } - private fun renderPhoto(context: Context, avatar: Avatar.Photo, onAvatarRendered: (Media) -> Unit) { + private fun renderPhoto(context: Context, avatar: Avatar.Photo, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) { SignalExecutors.BOUNDED.execute { - val blob = AppDependencies.blobs - .forData(AvatarPickerStorage.read(context, PartAuthority.getAvatarPickerFilename(avatar.uri)), avatar.size) - .createForSingleSessionOnDisk(context) + val blob = try { + AppDependencies.blobs + .forData(AvatarPickerStorage.read(context, PartAuthority.getAvatarPickerFilename(avatar.uri)), avatar.size) + .createForSingleSessionOnDisk(context) + } catch (e: Throwable) { + onRenderFailed(e) + return@execute + } onAvatarRendered(createMedia(blob, avatar.size)) } @@ -104,32 +109,39 @@ object AvatarRenderer { private fun renderInBackground(context: Context, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit, drawAvatar: (Canvas) -> Result) { SignalExecutors.BOUNDED.execute { - val canvasBitmap = Bitmap.createBitmap(DIMENSIONS, DIMENSIONS, Bitmap.Config.ARGB_8888) - val canvas = Canvas(canvasBitmap) - - val drawResult = drawAvatar(canvas) - if (drawResult.isFailure) { - canvasBitmap.recycle() - onRenderFailed(drawResult.exceptionOrNull()) + val media = try { + renderToMedia(context, drawAvatar) + } catch (e: Throwable) { + Result.failure(e) } - val outStream = ByteArrayOutputStream() - val compressed = canvasBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outStream) - canvasBitmap.recycle() - - if (!compressed) { - onRenderFailed(IOException("Failed to compress bitmap")) - return@execute - } - - val bytes = outStream.toByteArray() - val inStream = ByteArrayInputStream(bytes) - val uri = AppDependencies.blobs.forData(inStream, bytes.size.toLong()).createForSingleSessionOnDisk(context) - - onAvatarRendered(createMedia(uri, bytes.size.toLong())) + media.fold(onSuccess = onAvatarRendered, onFailure = onRenderFailed) } } + private fun renderToMedia(context: Context, drawAvatar: (Canvas) -> Result): Result { + val canvasBitmap = Bitmap.createBitmap(DIMENSIONS, DIMENSIONS, Bitmap.Config.ARGB_8888) + val outStream = ByteArrayOutputStream() + + val compressed = try { + drawAvatar(Canvas(canvasBitmap)).getOrElse { return Result.failure(it) } + + canvasBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outStream) + } finally { + canvasBitmap.recycle() + } + + if (!compressed) { + return Result.failure(IOException("Failed to compress bitmap")) + } + + val bytes = outStream.toByteArray() + val inStream = ByteArrayInputStream(bytes) + val uri = AppDependencies.blobs.forData(inStream, bytes.size.toLong()).createForSingleSessionOnDisk(context) + + return Result.success(createMedia(uri, bytes.size.toLong())) + } + private fun createMedia(uri: Uri, size: Long): Media { return Media( uri = uri, diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/compose/AvatarImage.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/compose/AvatarImage.kt new file mode 100644 index 0000000000..2a8e9ac9c0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/compose/AvatarImage.kt @@ -0,0 +1,219 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.avatar.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.emoji.Emojifier +import org.signal.glide.compose.GlideImage +import org.signal.glide.compose.GlideImageScaleType +import org.signal.glide.decryptableuri.DecryptableUri +import org.thoughtcrime.securesms.avatar.Avatar +import org.thoughtcrime.securesms.avatar.AvatarRenderer +import org.thoughtcrime.securesms.avatar.Avatars +import org.thoughtcrime.securesms.conversation.colors.AvatarColor +import kotlin.math.min + +/** Fraction of the avatar width the text is allowed to occupy. */ +private const val TEXT_WIDTH_PERCENT = 0.8f + +/** Ceiling on the font size, as a fraction of the avatar size. */ +private const val MAX_TEXT_SIZE_PERCENT = 0.45f + +/** Inset applied to each edge of a resource avatar, as a fraction of the avatar size. */ +private const val RESOURCE_PADDING_PERCENT = 0.2f + +/** + * Displays the given Avatar. + */ +@Composable +fun AvatarImage( + avatar: Avatar, + contentDescription: String?, + modifier: Modifier = Modifier +) { + // The avatar is either a vector, text, or a photo or a resource + when (avatar) { + is Avatar.Photo -> AvatarPhoto(avatar, contentDescription, modifier) + is Avatar.Resource -> AvatarResource(avatar, contentDescription, modifier) + is Avatar.Text -> AvatarText(avatar, contentDescription, modifier) + is Avatar.Vector -> AvatarVector(avatar, contentDescription, modifier) + } +} + +@Composable +private fun AvatarPhoto( + avatar: Avatar.Photo, + contentDescription: String?, + modifier: Modifier +) { + GlideImage( + model = remember(avatar.uri) { DecryptableUri(avatar.uri) }, + scaleType = GlideImageScaleType.CENTER_CROP, + modifier = modifier.semantics { + contentDescription?.let { this.contentDescription = it } + } + ) +} + +@Composable +private fun AvatarResource( + avatar: Avatar.Resource, + contentDescription: String?, + modifier: Modifier +) { + BoxWithConstraints( + modifier = modifier + .background(color = Color(avatar.color.backgroundColor)) + ) { + Icon( + imageVector = ImageVector.vectorResource(avatar.resourceId), + contentDescription = contentDescription, + tint = Color(avatar.color.foregroundColor), + modifier = Modifier + .fillMaxSize() + .padding(maxWidth * RESOURCE_PADDING_PERCENT) + ) + } +} + +@Composable +private fun AvatarText( + avatar: Avatar.Text, + contentDescription: String?, + modifier: Modifier +) { + val context = LocalContext.current + val density = LocalDensity.current + val typeface = remember(context) { FontFamily(AvatarRenderer.getTypeface(context)) } + + BoxWithConstraints( + contentAlignment = Alignment.Center, + modifier = modifier + // Merged so the description replaces the initials, rather than being announced alongside them. + .semantics(mergeDescendants = true) { contentDescription?.let { this.contentDescription = it } } + .background(color = Color(avatar.color.backgroundColor)) + ) { + val fontSize = remember(avatar.text, constraints, density) { + val sizePx = min(constraints.maxWidth, constraints.maxHeight).toFloat() + + with(density) { + Avatars.getTextSizeForLength(context, avatar.text, sizePx * TEXT_WIDTH_PERCENT, sizePx * MAX_TEXT_SIZE_PERCENT).toSp() + } + } + + Emojifier( + text = avatar.text + ) { text, inlineContent -> + Text( + text = text, + textAlign = TextAlign.Center, + color = Color(avatar.color.foregroundColor), + fontFamily = typeface, + fontSize = fontSize, + inlineContent = inlineContent + ) + } + } +} + +@Composable +private fun AvatarVector( + avatar: Avatar.Vector, + contentDescription: String?, + modifier: Modifier +) { + val drawableId = remember(avatar.key) { Avatars.getDrawableResource(avatar.key) } + + Box( + modifier = modifier + .background(color = Color(avatar.color.backgroundColor)) + ) { + if (drawableId != null) { + Image( + painter = painterResource(drawableId), + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@DayNightPreviews +@Composable +private fun AvatarTextPreview() { + Previews.Preview { + AvatarImage( + Avatar.Text( + text = "MM", + color = Avatars.ColorPair(foregroundAvatarColor = Avatars.ForegroundColor.A210, backgroundAvatarColor = AvatarColor.A210), + databaseId = Avatar.DatabaseId.NotSet + ), + contentDescription = null, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + ) + } +} + +@DayNightPreviews +@Composable +private fun AvatarVectorPreview() { + Previews.Preview { + AvatarImage( + Avatar.Vector( + key = "avatar_cat", + color = Avatars.ColorPair(foregroundAvatarColor = Avatars.ForegroundColor.A210, backgroundAvatarColor = AvatarColor.A210), + databaseId = Avatar.DatabaseId.NotSet + ), + contentDescription = null, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + ) + } +} + +@DayNightPreviews +@Composable +private fun AvatarResourcePreview() { + Previews.Preview { + AvatarImage( + Avatar.getDefaultForGroup(), + contentDescription = null, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerActions.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerActions.kt new file mode 100644 index 0000000000..1a8fff8aa3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerActions.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.avatar.picker + +import org.signal.core.models.media.Media +import org.thoughtcrime.securesms.avatar.Avatar + +/** + * One-off actions that require an Activity or the nav graph, and so must be carried out by whoever hosts the picker. + */ +sealed interface AvatarPickerActions { + /** Leave the picker without applying any changes. */ + data object Close : AvatarPickerActions + + /** Leave the picker, handing [media] back to whoever launched it. */ + data class FinishWithAvatar(val media: Media) : AvatarPickerActions + + /** Leave the picker, telling whoever launched it that the avatar was cleared. */ + data object FinishWithClearedAvatar : AvatarPickerActions + + /** Open the camera to take a new avatar photo. */ + data object LaunchCameraCapture : AvatarPickerActions + + /** Open the gallery to choose an avatar photo. */ + data object LaunchPhotoSelection : AvatarPickerActions + + /** Open the text avatar creator without a starting avatar. */ + data object LaunchTextAvatarCreation : AvatarPickerActions + + /** Open the editor that corresponds to [avatar]. */ + data class LaunchAvatarEditor(val avatar: Avatar) : AvatarPickerActions + + /** Tell the user their avatar could not be saved. */ + data object ShowSaveFailed : AvatarPickerActions +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerEvents.kt new file mode 100644 index 0000000000..83fa082e06 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerEvents.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.avatar.picker + +import org.signal.core.models.media.Media +import org.thoughtcrime.securesms.avatar.Avatar + +sealed interface AvatarPickerEvents { + /** User has selected an avatar */ + data class AvatarSelected(val avatar: Avatar) : AvatarPickerEvents + + /** User has selected a photo to use as an avatar */ + data class PhotoSelected(val media: Media) : AvatarPickerEvents + + /** User has edited avatar */ + data class AvatarEdited(val avatar: Avatar) : AvatarPickerEvents + + /** User has cleared their current avatar */ + data object ClearAvatar : AvatarPickerEvents + + /** User has deleted an avatar */ + data class DeleteAvatar(val avatar: Avatar) : AvatarPickerEvents + + /** User wants to take a photo */ + data object CapturePhoto : AvatarPickerEvents + + /** User wants to select a photo */ + data object SelectPhoto : AvatarPickerEvents + + /** User wants to select text */ + data object SelectText : AvatarPickerEvents + + /** User wants to edit the avatar */ + data class EditAvatar(val avatar: Avatar) : AvatarPickerEvents + + /** User has saved */ + data object Save : AvatarPickerEvents + + /** User has chosen to close the picker */ + data object Close : AvatarPickerEvents +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt index c0b9e00251..7ecc8327f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt @@ -3,46 +3,40 @@ package org.thoughtcrime.securesms.avatar.picker import android.app.Activity import android.content.Intent import android.os.Bundle -import android.view.Gravity import android.view.View -import android.widget.PopupMenu -import androidx.activity.result.ActivityResultLauncher -import androidx.appcompat.widget.Toolbar -import androidx.fragment.app.Fragment +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.res.stringResource import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.Navigation -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.launch import org.signal.core.models.media.Media -import org.signal.core.ui.WindowBreakpoint -import org.signal.core.ui.getWindowBreakpoint +import org.signal.core.ui.compose.CollectActions +import org.signal.core.ui.compose.ComposeFragment import org.signal.core.ui.permissions.Permissions -import org.signal.core.util.ThreadUtil -import org.signal.core.util.dp import org.signal.core.util.getParcelableExtraCompat import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.avatar.Avatar import org.thoughtcrime.securesms.avatar.AvatarBundler import org.thoughtcrime.securesms.avatar.photo.PhotoEditorActivity -import org.thoughtcrime.securesms.avatar.photo.PhotoEditorFragment import org.thoughtcrime.securesms.avatar.text.TextAvatarCreationFragment import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment -import org.thoughtcrime.securesms.components.ButtonStripItemView -import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity -import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter import org.thoughtcrime.securesms.util.ViewUtil -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.navigation.safeNavigate -import org.thoughtcrime.securesms.util.padding -import org.thoughtcrime.securesms.util.visible /** * Primary Avatar picker fragment, displays current user avatar and a list of recently used avatars and defaults. + * Carries out the [AvatarPickerActions] that need an Activity or the nav graph. */ -class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) { +class AvatarPickerFragment : ComposeFragment() { companion object { const val REQUEST_KEY_SELECT_AVATAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR" @@ -54,122 +48,49 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) { private val viewModel: AvatarPickerViewModel by viewModels(factoryProducer = this::createFactory) - private lateinit var recycler: RecyclerView - private lateinit var photoEditorLauncher: ActivityResultLauncher + private val photoEditorLauncher = registerForActivityResult(PhotoEditorActivity.Contract()) { photo -> + if (photo != null) { + viewModel.onEvent(AvatarPickerEvents.AvatarEdited(photo)) + } + } private fun createFactory(): AvatarPickerViewModel.Factory { val args = AvatarPickerFragmentArgs.fromBundle(requireArguments()) - return AvatarPickerViewModel.Factory(AvatarPickerRepository(requireContext()), args.groupId, args.isNewGroup, args.groupAvatarMedia) + return AvatarPickerViewModel.Factory(args.groupId, args.isNewGroup, args.groupAvatarMedia) + } + + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val saveFailedMessage = stringResource(R.string.AvatarPickerRepository__failed_to_save_avatar) + + CollectActions(viewModel.actions) { action -> + handleAction(action) { + scope.launch { snackbarHostState.showSnackbar(saveFailedMessage) } + } + } + + AvatarPickerScreen( + state = state, + onEvent = viewModel::onEvent, + snackbarHostState = snackbarHostState + ) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - SystemWindowInsetsSetter.attach(view, viewLifecycleOwner) - - val toolbar: Toolbar = view.findViewById(R.id.avatar_picker_toolbar) - val cameraButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_camera) - val photoButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_photo) - val textButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_text) - val saveButton: View = view.findViewById(R.id.avatar_picker_save) - val clearButton: View = view.findViewById(R.id.avatar_picker_clear) - - val spanCount = when (resources.getWindowBreakpoint()) { - is WindowBreakpoint.Small -> 4 - else -> 6 - } - - val recyclerPadding = when (resources.getWindowBreakpoint()) { - is WindowBreakpoint.Small -> 0 - else -> 112.dp - } - - recycler = view.findViewById(R.id.avatar_picker_recycler) - recycler.addItemDecoration(GridDividerDecoration(spanCount, ViewUtil.dpToPx(16))) - recycler.padding( - left = recyclerPadding, - right = recyclerPadding - ) - - val gridLayoutManager: GridLayoutManager = recycler.layoutManager as GridLayoutManager - gridLayoutManager.spanCount = spanCount - - val adapter = MappingAdapter() - AvatarPickerItem.register(adapter, this::onAvatarClick, this::onAvatarLongClick) - - recycler.adapter = adapter - - val avatarViewHolder = AvatarPickerItem.ViewHolder(view) - - viewModel.state.observe(viewLifecycleOwner) { state -> - if (state.currentAvatar != null) { - avatarViewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false)) - } - - clearButton.visible = state.canClear - saveButton.isClickable = state.canSave - - val items = state.selectableAvatars.map { AvatarPickerItem.Model(it, it == state.currentAvatar) } - val selectedPosition = items.indexOfFirst { it.isSelected } - - adapter.submitList(items) { - if (selectedPosition > -1) { - recycler.smoothScrollToPosition(selectedPosition) - } else { - recycler.smoothScrollToPosition(0) - } - } - } - - toolbar.setNavigationOnClickListener { Navigation.findNavController(it).popBackStack() } - cameraButton.setOnIconClickedListener { openCameraCapture() } - photoButton.setOnIconClickedListener { openGallery() } - textButton.setOnIconClickedListener { openTextEditor(null) } - saveButton.setOnClickListener { v -> - if (!saveButton.isEnabled) { - return@setOnClickListener - } - - saveButton.isEnabled = false - viewModel.save( - { - setFragmentResult( - REQUEST_KEY_SELECT_AVATAR, - Bundle().apply { - putParcelable(SELECT_AVATAR_MEDIA, it) - } - ) - ThreadUtil.runOnMain { Navigation.findNavController(v).popBackStack() } - }, - { - setFragmentResult( - REQUEST_KEY_SELECT_AVATAR, - Bundle().apply { - putBoolean(SELECT_AVATAR_CLEAR, true) - } - ) - ThreadUtil.runOnMain { Navigation.findNavController(v).popBackStack() } - } - ) - } - clearButton.setOnClickListener { viewModel.clearAvatar() } + super.onViewCreated(view, savedInstanceState) setFragmentResultListener(TextAvatarCreationFragment.REQUEST_KEY_TEXT) { _, bundle -> val text = AvatarBundler.extractText(bundle) - viewModel.onAvatarEditCompleted(text) + viewModel.onEvent(AvatarPickerEvents.AvatarEdited(text)) } setFragmentResultListener(VectorAvatarCreationFragment.REQUEST_KEY_VECTOR) { _, bundle -> val vector = AvatarBundler.extractVector(bundle) - viewModel.onAvatarEditCompleted(vector) - } - - setFragmentResultListener(PhotoEditorFragment.REQUEST_KEY_EDIT) { _, _ -> - } - - photoEditorLauncher = registerForActivityResult(PhotoEditorActivity.Contract()) { photo -> - if (photo != null) { - viewModel.onAvatarEditCompleted(photo) - } + viewModel.onEvent(AvatarPickerEvents.AvatarEdited(vector)) } } @@ -178,46 +99,34 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) { ViewUtil.hideKeyboard(requireContext(), requireView()) } + private fun handleAction(action: AvatarPickerActions, showSaveFailed: () -> Unit) { + when (action) { + AvatarPickerActions.Close -> findNavController().popBackStack() + AvatarPickerActions.ShowSaveFailed -> showSaveFailed() + AvatarPickerActions.LaunchCameraCapture -> openCameraCapture() + AvatarPickerActions.LaunchPhotoSelection -> openGallery() + AvatarPickerActions.LaunchTextAvatarCreation -> openTextEditor(null) + is AvatarPickerActions.LaunchAvatarEditor -> openEditor(action.avatar) + is AvatarPickerActions.FinishWithAvatar -> finishWithResult { putParcelable(SELECT_AVATAR_MEDIA, action.media) } + AvatarPickerActions.FinishWithClearedAvatar -> finishWithResult { putBoolean(SELECT_AVATAR_CLEAR, true) } + } + } + + private fun finishWithResult(populateResult: Bundle.() -> Unit) { + setFragmentResult(REQUEST_KEY_SELECT_AVATAR, Bundle().apply(populateResult)) + findNavController().popBackStack() + } + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == REQUEST_CODE_SELECT_IMAGE && resultCode == Activity.RESULT_OK && data != null) { val media: Media = requireNotNull(data.getParcelableExtraCompat(AvatarSelectionActivity.EXTRA_MEDIA, Media::class.java)) - viewModel.onAvatarPhotoSelectionCompleted(media) + viewModel.onEvent(AvatarPickerEvents.PhotoSelected(media)) } else { super.onActivityResult(requestCode, resultCode, data) } } - private fun onAvatarClick(avatar: Avatar, isSelected: Boolean) { - if (isSelected) { - openEditor(avatar) - } else { - viewModel.onAvatarSelectedFromGrid(avatar) - } - } - - private fun onAvatarLongClick(anchorView: View, avatar: Avatar): Boolean { - val menuRes = when (avatar) { - is Avatar.Photo -> R.menu.avatar_picker_context - is Avatar.Text -> R.menu.avatar_picker_context - is Avatar.Vector -> return true - is Avatar.Resource -> return true - } - - val popup = PopupMenu(context, anchorView, Gravity.TOP) - popup.menuInflater.inflate(menuRes, popup.menu) - popup.setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.action_delete -> viewModel.delete(avatar) - } - - true - } - popup.show() - - return true - } - private fun openEditor(avatar: Avatar) { when (avatar) { is Avatar.Photo -> openPhotoEditor(avatar) diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerItem.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerItem.kt index af1dd6a215..b8416856a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerItem.kt @@ -14,23 +14,14 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.avatar.Avatar import org.thoughtcrime.securesms.avatar.AvatarRenderer import org.thoughtcrime.securesms.avatar.Avatars -import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder import org.thoughtcrime.securesms.util.visible -typealias OnAvatarClickListener = (Avatar, Boolean) -> Unit -typealias OnAvatarLongClickListener = (View, Avatar) -> Boolean - object AvatarPickerItem { private val SELECTION_CHANGED = Any() - fun register(adapter: MappingAdapter, onAvatarClickListener: OnAvatarClickListener, onAvatarLongClickListener: OnAvatarLongClickListener) { - adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onAvatarClickListener, onAvatarLongClickListener) }, R.layout.avatar_picker_item)) - } - class Model(val avatar: Avatar, val isSelected: Boolean) : MappingModel { override fun areItemsTheSame(newItem: Model): Boolean = avatar.isSameAs(newItem.avatar) @@ -45,11 +36,7 @@ object AvatarPickerItem { } } - class ViewHolder( - itemView: View, - private val onAvatarClickListener: OnAvatarClickListener? = null, - private val onAvatarLongClickListener: OnAvatarLongClickListener? = null - ) : MappingViewHolder(itemView) { + class ViewHolder(itemView: View) : MappingViewHolder(itemView) { private val imageView: ImageView = itemView.findViewById(R.id.avatar_picker_item_image) private val textView: TextView = itemView.findViewById(R.id.avatar_picker_item_text) @@ -81,12 +68,6 @@ object AvatarPickerItem { selectedOverlay?.animate()?.cancel() selectedFader?.animate()?.cancel() - itemView.setOnLongClickListener { - onAvatarLongClickListener?.invoke(itemView, model.avatar) ?: false - } - - itemView.setOnClickListener { onAvatarClickListener?.invoke(model.avatar, model.isSelected) } - if (payload.isNotEmpty() && payload.contains(SELECTION_CHANGED)) { imageView.animate().scaleX(scale).scaleY(scale) textView.animate().scaleX(scale).scaleY(scale) diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerRepository.kt index 755d256f9d..edf7c74731 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerRepository.kt @@ -1,15 +1,16 @@ package org.thoughtcrime.securesms.avatar.picker -import android.content.Context import android.net.Uri -import android.widget.Toast -import io.reactivex.rxjava3.core.Single +import arrow.core.Either +import arrow.core.left +import arrow.core.raise.either +import arrow.core.right +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import org.signal.core.models.media.Media import org.signal.core.util.StreamUtil -import org.signal.core.util.ThreadUtil -import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.logging.Log -import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.avatar.Avatar import org.thoughtcrime.securesms.avatar.AvatarPickerStorage import org.thoughtcrime.securesms.avatar.AvatarRenderer @@ -23,15 +24,14 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.NameUtil import org.whispersystems.signalservice.api.util.StreamDetails import java.io.IOException +import kotlin.coroutines.resume private val TAG = Log.tag(AvatarPickerRepository::class.java) -class AvatarPickerRepository(context: Context) { +object AvatarPickerRepository { - private val applicationContext = context.applicationContext - - fun getAvatarForSelf(): Single = Single.fromCallable { - val details: StreamDetails? = AvatarHelper.getSelfProfileAvatarStream(applicationContext) + suspend fun getAvatarForSelf(): Avatar = withContext(SignalDispatchers.IO) { + val details: StreamDetails? = AvatarHelper.getSelfProfileAvatarStream(AppDependencies.application) if (details != null) { try { val bytes = StreamUtil.readFully(details.stream) @@ -49,15 +49,15 @@ class AvatarPickerRepository(context: Context) { } } - fun getAvatarForGroup(groupId: GroupId): Single = Single.fromCallable { + suspend fun getAvatarForGroup(groupId: GroupId): Avatar = withContext(SignalDispatchers.IO) { val recipient = Recipient.externalGroupExact(groupId) - if (AvatarHelper.hasAvatar(applicationContext, recipient.id)) { + if (AvatarHelper.hasAvatar(AppDependencies.application, recipient.id)) { try { - val bytes = AvatarHelper.getAvatarBytes(applicationContext, recipient.id) + val bytes = AvatarHelper.getAvatarBytes(AppDependencies.application, recipient.id) Avatar.Photo( AppDependencies.blobs.forData(bytes).createForSingleSessionInMemory(), - AvatarHelper.getAvatarLength(applicationContext, recipient.id), + AvatarHelper.getAvatarLength(AppDependencies.application, recipient.id), Avatar.DatabaseId.DoNotPersist ) } catch (e: IOException) { @@ -69,101 +69,97 @@ class AvatarPickerRepository(context: Context) { } } - fun getPersistedAvatarsForSelf(): Single> = Single.fromCallable { + suspend fun getPersistedAvatarsForSelf(): List = withContext(SignalDispatchers.Default) { SignalDatabase.avatarPicker.getAvatarsForSelf() } - fun getPersistedAvatarsForGroup(groupId: GroupId): Single> = Single.fromCallable { + suspend fun getPersistedAvatarsForGroup(groupId: GroupId): List = withContext(SignalDispatchers.Default) { SignalDatabase.avatarPicker.getAvatarsForGroup(groupId) } - fun getDefaultAvatarsForSelf(): Single> = Single.fromCallable { - Avatars.defaultAvatarsForSelf.entries.mapIndexed { index, entry -> + fun getDefaultAvatarsForSelf(): List { + return Avatars.defaultAvatarsForSelf.entries.mapIndexed { index, entry -> Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet) } } - fun getDefaultAvatarsForGroup(): Single> = Single.fromCallable { - Avatars.defaultAvatarsForGroup.entries.mapIndexed { index, entry -> + fun getDefaultAvatarsForGroup(): List { + return Avatars.defaultAvatarsForGroup.entries.mapIndexed { index, entry -> Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet) } } - fun writeMediaToMultiSessionStorage(media: Media, onMediaWrittenToMultiSessionStorage: (Uri) -> Unit) { - SignalExecutors.BOUNDED.execute { - onMediaWrittenToMultiSessionStorage(AvatarPickerStorage.save(applicationContext, media)) + suspend fun writeMediaToMultiSessionStorage(media: Media): Either = withContext(SignalDispatchers.IO) { + try { + AvatarPickerStorage.save(AppDependencies.application, media).right() + } catch (e: IOException) { + e.left() } } - fun persistAvatarForSelf(avatar: Avatar, onPersisted: (Avatar) -> Unit) { - SignalExecutors.BOUNDED.execute { + suspend fun persistAvatarForSelf(avatar: Avatar): Either = withContext(SignalDispatchers.Default) { + try { val avatarDatabase = SignalDatabase.avatarPicker val savedAvatar = avatarDatabase.saveAvatarForSelf(avatar) avatarDatabase.markUsage(savedAvatar) - onPersisted(savedAvatar) + savedAvatar.right() + } catch (e: Exception) { + e.left() } } - fun persistAvatarForGroup(avatar: Avatar, groupId: GroupId, onPersisted: (Avatar) -> Unit) { - SignalExecutors.BOUNDED.execute { + suspend fun persistAvatarForGroup(avatar: Avatar, groupId: GroupId): Either = withContext(SignalDispatchers.Default) { + try { val avatarDatabase = SignalDatabase.avatarPicker val savedAvatar = avatarDatabase.saveAvatarForGroup(avatar, groupId) avatarDatabase.markUsage(savedAvatar) - onPersisted(savedAvatar) + savedAvatar.right() + } catch (e: Exception) { + e.left() } } - fun persistAndCreateMediaForSelf(avatar: Avatar, onSaved: (Media) -> Unit) { - SignalExecutors.BOUNDED.execute { - if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) { - persistAvatarForSelf(avatar) { - AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure) - } - } else { - AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure) - } + suspend fun persistAndCreateMediaForSelf(avatar: Avatar): Either = either { + if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) { + persistAvatarForSelf(avatar).bind() } + + renderAvatar(avatar).bind() } - fun persistAndCreateMediaForGroup(avatar: Avatar, groupId: GroupId, onSaved: (Media) -> Unit) { - SignalExecutors.BOUNDED.execute { - if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) { - persistAvatarForGroup(avatar, groupId) { - AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure) - } - } else { - AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure) - } + suspend fun persistAndCreateMediaForGroup(avatar: Avatar, groupId: GroupId): Either = either { + if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) { + persistAvatarForGroup(avatar, groupId).bind() } + + renderAvatar(avatar).bind() } - fun createMediaForNewGroup(avatar: Avatar, onSaved: (Media) -> Unit) { - SignalExecutors.BOUNDED.execute { - AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure) - } + private suspend fun renderAvatar(avatar: Avatar): Either = suspendCancellableCoroutine { continuation -> + AvatarRenderer.renderAvatar( + context = AppDependencies.application, + avatar = avatar, + onAvatarRendered = { continuation.resume(it.right()) }, + onRenderFailed = { continuation.resume((it ?: IOException("Failed to render avatar.")).left()) } + ) } - fun handleRenderFailure(throwable: Throwable?) { - Log.w(TAG, "Failed to render avatar.", throwable) - ThreadUtil.postToMain { - Toast.makeText(applicationContext, R.string.AvatarPickerRepository__failed_to_save_avatar, Toast.LENGTH_SHORT).show() - } - } + suspend fun createMediaForNewGroup(avatar: Avatar): Either = renderAvatar(avatar) - fun getDefaultAvatarForSelf(): Avatar { - val initials = NameUtil.getAbbreviation(Recipient.self().getDisplayName(applicationContext)) + suspend fun getDefaultAvatarForSelf(): Avatar = withContext(SignalDispatchers.Default) { + val initials = NameUtil.getAbbreviation(Recipient.self().getDisplayName(AppDependencies.application)) - return if (initials.isNullOrBlank()) { + if (initials.isNullOrBlank()) { Avatar.getDefaultForSelf() } else { Avatar.Text(initials, requireNotNull(Avatars.colorMap[Recipient.self().avatarColor.serialize()]), Avatar.DatabaseId.DoNotPersist) } } - fun getDefaultAvatarForGroup(groupId: GroupId): Avatar { + suspend fun getDefaultAvatarForGroup(groupId: GroupId): Avatar = withContext(SignalDispatchers.Default) { val recipient = Recipient.externalGroupExact(groupId) - return getDefaultAvatarForGroup(recipient.avatarColor) + getDefaultAvatarForGroup(recipient.avatarColor) } fun getDefaultAvatarForGroup(color: AvatarColor?): Avatar { @@ -177,13 +173,12 @@ class AvatarPickerRepository(context: Context) { } } - fun delete(avatar: Avatar, onDelete: () -> Unit) { - SignalExecutors.BOUNDED.execute { + suspend fun delete(avatar: Avatar) { + withContext(SignalDispatchers.Default) { if (avatar.databaseId is Avatar.DatabaseId.Saved) { val avatarDatabase = SignalDatabase.avatarPicker avatarDatabase.deleteAvatar(avatar) } - onDelete() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerScreen.kt new file mode 100644 index 0000000000..52ecd93765 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerScreen.kt @@ -0,0 +1,402 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.avatar.picker + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import kotlinx.coroutines.flow.first +import org.signal.core.ui.WindowBreakpoint +import org.signal.core.ui.compose.AllDevicePreviews +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.DropdownMenus +import org.signal.core.ui.compose.IconButtons +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalIcons +import org.signal.core.ui.compose.Snackbars +import org.signal.core.ui.compose.horizontalGutters +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.core.ui.rememberWindowBreakpoint +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.Avatar +import org.thoughtcrime.securesms.avatar.compose.AvatarImage +import org.signal.core.ui.R as CoreUiR + +private val AVATAR_SIZE = 72.dp +private val SELECTED_AVATAR_SIZE = 56.dp +private val AVATAR_SPACING = 24.dp +private val AVATAR_GRID_INSET_LARGE = 112.dp + +private const val HEADER_ITEM_COUNT = 1 + +@Composable +fun AvatarPickerScreen( + state: AvatarPickerState, + onEvent: (AvatarPickerEvents) -> Unit, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } +) { + Scaffolds.Settings( + title = "", + onNavigationClick = { onEvent(AvatarPickerEvents.Close) }, + navigationIcon = SignalIcons.X.imageVector, + snackbarHost = { Snackbars.Host(snackbarHostState) }, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(end = 24.dp, bottom = 24.dp), + contentAlignment = Alignment.CenterEnd + ) { + Buttons.LargePrimary( + onClick = { onEvent(AvatarPickerEvents.Save) }, + enabled = state.canSave, + modifier = Modifier.testTag(AvatarPickerTestTags.SAVE_BUTTON) + ) { + Text(text = stringResource(R.string.AvatarPickerFragment__save)) + } + } + } + ) { contentPadding -> + val gridInset = when (rememberWindowBreakpoint()) { + is WindowBreakpoint.Small -> 0.dp + else -> AVATAR_GRID_INSET_LARGE + } + + val gridState = rememberLazyGridState() + val avatarHeight = with(LocalDensity.current) { AVATAR_SIZE.roundToPx() } + val selectedIndex = state.selectableAvatars.indexOfFirst { it == state.currentAvatar } + + LaunchedEffect(selectedIndex) { + gridState.animateItemIntoView( + index = if (selectedIndex >= 0) selectedIndex + HEADER_ITEM_COUNT else 0, + itemHeight = avatarHeight + ) + } + + BoxWithConstraints(modifier = Modifier.padding(contentPadding)) { + val viewportWidth = maxWidth + + LazyVerticalGrid( + state = gridState, + columns = GridCells.FixedSize(AVATAR_SIZE), + horizontalArrangement = spacedBy(AVATAR_SPACING, Alignment.CenterHorizontally), + verticalArrangement = spacedBy(AVATAR_SPACING), + contentPadding = PaddingValues( + start = dimensionResource(CoreUiR.dimen.gutter) + gridInset, + end = dimensionResource(CoreUiR.dimen.gutter) + gridInset, + bottom = AVATAR_SPACING + ), + modifier = Modifier.testTag(AvatarPickerTestTags.AVATAR_GRID) + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + AvatarPickerHeader( + state = state, + onEvent = onEvent, + // A full span item is only as wide as the cell run, so measure against the viewport instead. + modifier = Modifier + .wrapContentWidth(unbounded = true) + .width(viewportWidth) + ) + } + + items( + items = state.selectableAvatars, + key = { it.gridKey } + ) { avatar -> + SelectableAvatar( + avatar = avatar, + isSelected = state.currentAvatar == avatar, + onEvent = onEvent, + modifier = Modifier.testTag(AvatarPickerTestTags.SELECTABLE_AVATAR) + ) + } + } + } + } +} + +@Composable +private fun AvatarPickerHeader( + state: AvatarPickerState, + onEvent: (AvatarPickerEvents) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + CurrentAvatar( + avatar = state.currentAvatar, + canClear = state.canClear, + onClearClick = { onEvent(AvatarPickerEvents.ClearAvatar) }, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(dimensionResource(R.dimen.avatar_picker_image_width)) + ) + + Row( + horizontalArrangement = spacedBy(32.dp), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 32.dp, bottom = 28.dp) + .horizontalGutters() + ) { + Buttons.ActionButton( + imageVector = SignalIcons.Camera.imageVector, + label = stringResource(R.string.AvatarPickerFragment__camera), + onClick = { onEvent(AvatarPickerEvents.CapturePhoto) }, + modifier = Modifier.testTag(AvatarPickerTestTags.CAMERA_BUTTON) + ) + + Buttons.ActionButton( + imageVector = SignalIcons.Photo.imageVector, + label = stringResource(R.string.AvatarPickerFragment__photo), + onClick = { onEvent(AvatarPickerEvents.SelectPhoto) }, + modifier = Modifier.testTag(AvatarPickerTestTags.PHOTO_BUTTON) + ) + + Buttons.ActionButton( + imageVector = SignalIcons.Text.imageVector, + label = stringResource(R.string.AvatarPickerFragment__text), + onClick = { onEvent(AvatarPickerEvents.SelectText) }, + modifier = Modifier.testTag(AvatarPickerTestTags.TEXT_BUTTON) + ) + } + + Dividers.Default() + } +} + +@Composable +private fun CurrentAvatar( + avatar: Avatar?, + canClear: Boolean, + onClearClick: () -> Unit, + modifier: Modifier = Modifier +) { + Crossfade( + targetState = avatar, + modifier = modifier + ) { targetState -> + if (targetState == null) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape) + ) + } else { + Box(modifier = Modifier.fillMaxSize()) { + AvatarImage( + avatar = targetState, + contentDescription = stringResource(R.string.AvatarPickerFragment__avatar_preview), + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + ) + + if (canClear) { + IconButtons.IconButton( + onClick = onClearClick, + modifier = Modifier + .testTag(AvatarPickerTestTags.CLEAR_AVATAR_BUTTON) + .padding(4.dp) + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = CircleShape + ) + .align(Alignment.TopEnd) + ) { + Icon( + imageVector = SignalIcons.X.imageVector, + contentDescription = stringResource(R.string.AvatarPickerFragment__clear_avatar) + ) + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SelectableAvatar( + avatar: Avatar, + isSelected: Boolean, + onEvent: (AvatarPickerEvents) -> Unit, + modifier: Modifier = Modifier +) { + val haptics = LocalHapticFeedback.current + val menuController = remember { DropdownMenus.MenuController() } + val selectedProgress by animateFloatAsState(targetValue = if (isSelected) 1f else 0f, label = "avatar-selection") + val avatarSize = lerp(AVATAR_SIZE, SELECTED_AVATAR_SIZE, selectedProgress) + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(AVATAR_SIZE) + .border( + width = 3.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = selectedProgress), + shape = CircleShape + ) + .clip(CircleShape) + .combinedClickable( + role = Role.Button, + onClick = { + if (!isSelected) { + onEvent(AvatarPickerEvents.AvatarSelected(avatar)) + } else { + onEvent(AvatarPickerEvents.EditAvatar(avatar)) + } + }, + onLongClick = if (avatar.canDelete) { + { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + menuController.show() + } + } else { + null + }, + onLongClickLabel = stringResource(R.string.AvatarPickerFragment__avatar_options) + ) + ) { + AvatarImage( + avatar = avatar, + contentDescription = null, + modifier = Modifier + .size(avatarSize) + .clip(CircleShape) + ) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .alpha(selectedProgress) + .background(color = Color.Black.copy(alpha = 0.2f), shape = CircleShape) + .size(avatarSize) + ) { + Icon( + imageVector = SignalIcons.Edit.imageVector, + contentDescription = stringResource(R.string.AvatarPickerFragment__edit_avatar), + tint = SignalTheme.colors.colorOnCustom + ) + } + + DropdownMenus.Menu( + controller = menuController, + offsetX = 0.dp, + offsetY = 4.dp + ) { controller -> + DropdownMenus.Item( + text = { Text(text = stringResource(R.string.delete)) }, + onClick = { + controller.hide() + onEvent(AvatarPickerEvents.DeleteAvatar(avatar)) + } + ) + } + } +} + +/** Scrolls [index] into view with the smallest scroll possible. Items already fully on screen are left alone. */ +private suspend fun LazyGridState.animateItemIntoView(index: Int, itemHeight: Int) { + snapshotFlow { layoutInfo.visibleItemsInfo }.first { it.isNotEmpty() } + + val viewportHeight = layoutInfo.viewportSize.height + val item = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } + + if (item != null && item.offset.y >= 0 && item.offset.y + item.size.height <= viewportHeight) { + return + } + + if (index > firstVisibleItemIndex) { + animateScrollToItem(index, scrollOffset = -(viewportHeight - itemHeight)) + } else { + animateScrollToItem(index) + } +} + +/** Stable identity so item state follows the avatar rather than its position. */ +private val Avatar.gridKey: String + get() { + val id = (databaseId as? Avatar.DatabaseId.Saved)?.id + return when (this) { + is Avatar.Photo -> "photo:$id:$uri" + is Avatar.Text -> "text:$id:$text" + is Avatar.Vector -> "vector:$id:$key" + is Avatar.Resource -> "resource:$resourceId" + } + } + +/** Only persisted avatars the user created can be deleted. */ +private val Avatar.canDelete: Boolean + get() = databaseId is Avatar.DatabaseId.Saved && (this is Avatar.Photo || this is Avatar.Text) + +@AllDevicePreviews +@Composable +private fun AvatarPickerScreenPreview() { + Previews.Preview { + val avatars = remember { AvatarPickerRepository.getDefaultAvatarsForSelf() } + + AvatarPickerScreen( + state = remember { + AvatarPickerState( + currentAvatar = avatars.first(), + selectableAvatars = avatars, + canSave = true, + canClear = true + ) + }, + onEvent = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerTestTags.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerTestTags.kt new file mode 100644 index 0000000000..e921499f12 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerTestTags.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.avatar.picker + +object AvatarPickerTestTags { + const val AVATAR_GRID = "avatar_grid" + const val CAMERA_BUTTON = "camera_button" + const val PHOTO_BUTTON = "photo_button" + const val TEXT_BUTTON = "text_button" + const val SAVE_BUTTON = "save_button" + const val CLEAR_AVATAR_BUTTON = "clear_avatar_button" + + /** Shared by every avatar in the grid, in display order. */ + const val SELECTABLE_AVATAR = "selectable_avatar" +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerViewModel.kt index ec74cc66b5..3a60b294c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerViewModel.kt @@ -1,126 +1,170 @@ package org.thoughtcrime.securesms.avatar.picker -import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import androidx.lifecycle.viewModelScope +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.right +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.signal.core.models.media.Media +import org.signal.core.ui.compose.EventDrivenViewModel +import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.avatar.Avatar import org.thoughtcrime.securesms.groups.GroupId -import org.thoughtcrime.securesms.util.livedata.Store -sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepository) : ViewModel() { +private val TAG = Log.tag(AvatarPickerViewModel::class.java) - private val disposables = CompositeDisposable() - private val store = Store(AvatarPickerState()) +sealed class AvatarPickerViewModel : EventDrivenViewModel(TAG) { - val state: LiveData = store.stateLiveData + private val internalState = MutableStateFlow(AvatarPickerState()) + val state: StateFlow = internalState - protected abstract fun getAvatar(): Single - protected abstract fun getDefaultAvatarFromRepository(): Avatar - protected abstract fun getPersistedAvatars(): Single> - protected abstract fun getDefaultAvatars(): Single> - protected abstract fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) - protected abstract fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) + private val internalActions = Channel(Channel.BUFFERED) + val actions: Flow = internalActions.receiveAsFlow() - fun delete(avatar: Avatar) { - repository.delete(avatar) { + protected abstract suspend fun getAvatar(): Avatar + protected abstract suspend fun getDefaultAvatarFromRepository(): Avatar + protected abstract suspend fun getPersistedAvatars(): List + protected abstract suspend fun getDefaultAvatars(): List + protected abstract suspend fun persistAvatar(avatar: Avatar): Either + protected abstract suspend fun persistAndCreateMedia(avatar: Avatar): Either + + override suspend fun processEvent(event: AvatarPickerEvents) { + when (event) { + AvatarPickerEvents.ClearAvatar -> clearAvatar() + is AvatarPickerEvents.DeleteAvatar -> delete(event.avatar) + is AvatarPickerEvents.AvatarEdited -> onAvatarEditCompleted(event.avatar) + AvatarPickerEvents.Save -> save() + is AvatarPickerEvents.AvatarSelected -> onAvatarSelectedFromGrid(event.avatar) + is AvatarPickerEvents.PhotoSelected -> onAvatarPhotoSelectionCompleted(event.media) + is AvatarPickerEvents.EditAvatar -> internalActions.send(AvatarPickerActions.LaunchAvatarEditor(event.avatar)) + AvatarPickerEvents.Close -> internalActions.send(AvatarPickerActions.Close) + AvatarPickerEvents.CapturePhoto -> internalActions.send(AvatarPickerActions.LaunchCameraCapture) + AvatarPickerEvents.SelectPhoto -> internalActions.send(AvatarPickerActions.LaunchPhotoSelection) + AvatarPickerEvents.SelectText -> internalActions.send(AvatarPickerActions.LaunchTextAvatarCreation) + } + } + + private fun delete(avatar: Avatar) { + viewModelScope.launch { + AvatarPickerRepository.delete(avatar) refreshAvatar() refreshSelectableAvatars() } } - fun clearAvatar() { - store.update { - val avatar = getDefaultAvatarFromRepository() - it.copy(currentAvatar = avatar, canSave = true, canClear = false, isCleared = true) + private suspend fun clearAvatar() { + val avatar = getDefaultAvatarFromRepository() + + internalState.update { it.copy(currentAvatar = avatar, canSave = true, canClear = false, isCleared = true) } + } + + /** Saving closes the picker, so [AvatarPickerState.canSave] guards against doing it twice. */ + private suspend fun save() { + if (!internalState.value.canSave) { + return } - } - fun save(onSaved: (Media) -> Unit, onCleared: () -> Unit) { - if (store.state.isCleared) { - onCleared() - } else { - val avatar = store.state.currentAvatar ?: throw AssertionError() - persistAndCreateMedia(avatar, onSaved) + internalState.update { it.copy(canSave = false) } + + if (internalState.value.isCleared) { + internalActions.send(AvatarPickerActions.FinishWithClearedAvatar) + return } - } - fun onAvatarSelectedFromGrid(avatar: Avatar) { - store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) } - } + val avatar = internalState.value.currentAvatar ?: throw AssertionError() - fun onAvatarEditCompleted(avatar: Avatar) { - persistAvatar(avatar) { saved -> - store.update { it.copy(currentAvatar = saved, canSave = isSaveable(saved), canClear = true, isCleared = false) } - refreshSelectableAvatars() - } - } - - fun onAvatarPhotoSelectionCompleted(media: Media) { - repository.writeMediaToMultiSessionStorage(media) { multiSessionUri -> - persistAvatar(Avatar.Photo(multiSessionUri, media.size, Avatar.DatabaseId.NotSet)) { avatar -> - store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) } - refreshSelectableAvatars() + persistAndCreateMedia(avatar) + .onRight { internalActions.send(AvatarPickerActions.FinishWithAvatar(it)) } + .onLeft { + Log.w(TAG, "Failed to save avatar.", it) + internalState.update { state -> state.copy(canSave = true) } + internalActions.send(AvatarPickerActions.ShowSaveFailed) } + } + + private fun onAvatarSelectedFromGrid(avatar: Avatar) { + internalState.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) } + } + + private fun onAvatarEditCompleted(avatar: Avatar) { + viewModelScope.launch { + persistAvatar(avatar) + .onRight { saved -> + internalState.update { it.copy(currentAvatar = saved, canSave = isSaveable(saved), canClear = true, isCleared = false) } + refreshSelectableAvatars() + } + .onLeft { Log.w(TAG, "Failed to persist edited avatar.", it) } + } + } + + private fun onAvatarPhotoSelectionCompleted(media: Media) { + viewModelScope.launch { + either { + val multiSessionUri = AvatarPickerRepository.writeMediaToMultiSessionStorage(media).bind() + persistAvatar(Avatar.Photo(multiSessionUri, media.size, Avatar.DatabaseId.NotSet)).bind() + } + .onRight { avatar -> + internalState.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) } + refreshSelectableAvatars() + } + .onLeft { Log.w(TAG, "Failed to persist selected photo.", it) } } } protected fun refreshAvatar() { - disposables.add( - getAvatar().subscribeOn(Schedulers.io()).subscribe { avatar -> - store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = avatar is Avatar.Photo && !isSaveable(avatar), isCleared = false) } - } - ) + viewModelScope.launch { + val avatar = getAvatar() + internalState.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = avatar is Avatar.Photo && !isSaveable(avatar), isCleared = false) } + } } protected fun refreshSelectableAvatars() { - disposables.add( - Single.zip(getPersistedAvatars(), getDefaultAvatars()) { custom, def -> - val customKeys = custom.filterIsInstance(Avatar.Vector::class.java).map { it.key } - custom + def.filterNot { - it is Avatar.Vector && customKeys.contains(it.key) - } - }.subscribeOn(Schedulers.io()).subscribe { avatars -> - store.update { it.copy(selectableAvatars = avatars) } - } - ) + viewModelScope.launch { + val custom = getPersistedAvatars() + val default = getDefaultAvatars() + val customKeys = custom.filterIsInstance().map { it.key } + + val avatars = custom + default.filterNot { it is Avatar.Vector && customKeys.contains(it.key) } + + internalState.update { it.copy(selectableAvatars = avatars) } + } } private fun isSaveable(avatar: Avatar) = avatar.databaseId != Avatar.DatabaseId.DoNotPersist - override fun onCleared() { - disposables.dispose() - } - - private class SelfAvatarPickerViewModel(private val repository: AvatarPickerRepository) : AvatarPickerViewModel(repository) { + private class SelfAvatarPickerViewModel : AvatarPickerViewModel() { init { refreshAvatar() refreshSelectableAvatars() } - override fun getAvatar(): Single = repository.getAvatarForSelf() - override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForSelf() - override fun getPersistedAvatars(): Single> = repository.getPersistedAvatarsForSelf() - override fun getDefaultAvatars(): Single> = repository.getDefaultAvatarsForSelf() + override suspend fun getAvatar(): Avatar = AvatarPickerRepository.getAvatarForSelf() + override suspend fun getDefaultAvatarFromRepository(): Avatar = AvatarPickerRepository.getDefaultAvatarForSelf() + override suspend fun getPersistedAvatars(): List = AvatarPickerRepository.getPersistedAvatarsForSelf() + override suspend fun getDefaultAvatars(): List = AvatarPickerRepository.getDefaultAvatarsForSelf() - override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) { - repository.persistAvatarForSelf(avatar, onPersisted) + override suspend fun persistAvatar(avatar: Avatar): Either { + return AvatarPickerRepository.persistAvatarForSelf(avatar) } - override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) { - repository.persistAndCreateMediaForSelf(avatar, onSaved) + override suspend fun persistAndCreateMedia(avatar: Avatar): Either { + return AvatarPickerRepository.persistAndCreateMediaForSelf(avatar) } } private class GroupAvatarPickerViewModel( private val groupId: GroupId, - private val repository: AvatarPickerRepository, groupAvatarMedia: Media? - ) : AvatarPickerViewModel(repository) { + ) : AvatarPickerViewModel() { private val initialAvatar: Avatar? = groupAvatarMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) } @@ -129,31 +173,26 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor refreshSelectableAvatars() } - override fun getAvatar(): Single { - return if (initialAvatar != null) { - Single.just(initialAvatar) - } else { - repository.getAvatarForGroup(groupId) - } + override suspend fun getAvatar(): Avatar { + return initialAvatar ?: AvatarPickerRepository.getAvatarForGroup(groupId) } - override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(groupId) - override fun getPersistedAvatars(): Single> = repository.getPersistedAvatarsForGroup(groupId) - override fun getDefaultAvatars(): Single> = repository.getDefaultAvatarsForGroup() + override suspend fun getDefaultAvatarFromRepository(): Avatar = AvatarPickerRepository.getDefaultAvatarForGroup(groupId) + override suspend fun getPersistedAvatars(): List = AvatarPickerRepository.getPersistedAvatarsForGroup(groupId) + override suspend fun getDefaultAvatars(): List = AvatarPickerRepository.getDefaultAvatarsForGroup() - override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) { - repository.persistAvatarForGroup(avatar, groupId, onPersisted) + override suspend fun persistAvatar(avatar: Avatar): Either { + return AvatarPickerRepository.persistAvatarForGroup(avatar, groupId) } - override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) { - repository.persistAndCreateMediaForGroup(avatar, groupId, onSaved) + override suspend fun persistAndCreateMedia(avatar: Avatar): Either { + return AvatarPickerRepository.persistAndCreateMediaForGroup(avatar, groupId) } } private class NewGroupAvatarPickerViewModel( - private val repository: AvatarPickerRepository, initialMedia: Media? - ) : AvatarPickerViewModel(repository) { + ) : AvatarPickerViewModel() { private val initialAvatar: Avatar? = initialMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) } @@ -162,34 +201,29 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor refreshSelectableAvatars() } - override fun getAvatar(): Single { - return if (initialAvatar != null) { - Single.just(initialAvatar) - } else { - Single.fromCallable { getDefaultAvatarFromRepository() } - } + override suspend fun getAvatar(): Avatar { + return initialAvatar ?: getDefaultAvatarFromRepository() } - override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(null) - override fun getPersistedAvatars(): Single> = Single.just(listOf()) - override fun getDefaultAvatars(): Single> = repository.getDefaultAvatarsForGroup() - override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) = onPersisted(avatar) - override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) = repository.createMediaForNewGroup(avatar, onSaved) + override suspend fun getDefaultAvatarFromRepository(): Avatar = AvatarPickerRepository.getDefaultAvatarForGroup(null) + override suspend fun getPersistedAvatars(): List = emptyList() + override suspend fun getDefaultAvatars(): List = AvatarPickerRepository.getDefaultAvatarsForGroup() + override suspend fun persistAvatar(avatar: Avatar): Either = avatar.right() + override suspend fun persistAndCreateMedia(avatar: Avatar): Either = AvatarPickerRepository.createMediaForNewGroup(avatar) } class Factory( - private val repository: AvatarPickerRepository, private val groupId: GroupId?, private val isNewGroup: Boolean, private val groupAvatarMedia: Media? ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { val viewModel = if (groupId == null && !isNewGroup) { - SelfAvatarPickerViewModel(repository) + SelfAvatarPickerViewModel() } else if (groupId == null) { - NewGroupAvatarPickerViewModel(repository, groupAvatarMedia) + NewGroupAvatarPickerViewModel(groupAvatarMedia) } else { - GroupAvatarPickerViewModel(groupId, repository, groupAvatarMedia) + GroupAvatarPickerViewModel(groupId, groupAvatarMedia) } return requireNotNull(modelClass.cast(viewModel)) diff --git a/app/src/main/res/layout/avatar_picker_fragment.xml b/app/src/main/res/layout/avatar_picker_fragment.xml deleted file mode 100644 index 09f368e8ae..0000000000 --- a/app/src/main/res/layout/avatar_picker_fragment.xml +++ /dev/null @@ -1,152 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/avatar_picker_context.xml b/app/src/main/res/menu/avatar_picker_context.xml deleted file mode 100644 index ab89f8d99f..0000000000 --- a/app/src/main/res/menu/avatar_picker_context.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/avatar_picker.xml b/app/src/main/res/navigation/avatar_picker.xml index 1401197ce6..8be44efc6c 100644 --- a/app/src/main/res/navigation/avatar_picker.xml +++ b/app/src/main/res/navigation/avatar_picker.xml @@ -1,15 +1,13 @@ + android:label="fragment_pick_avatar"> Avatar preview Camera - Take a picture - Choose a photo Photo Text Save Clear avatar + + Edit avatar + + Avatar options Failed to save avatar diff --git a/app/src/test/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerScreenTest.kt b/app/src/test/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerScreenTest.kt new file mode 100644 index 0000000000..9d777a61c9 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerScreenTest.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.avatar.picker + +import android.app.Application +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.test.performTouchInput +import androidx.test.core.app.ApplicationProvider +import io.mockk.every +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.ui.CoreUiDependenciesRule +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.emoji.EmojiDependencies +import org.thoughtcrime.securesms.avatar.Avatar +import org.thoughtcrime.securesms.avatar.Avatars +import org.thoughtcrime.securesms.conversation.colors.AvatarColor +import org.thoughtcrime.securesms.dependencies.EmojiDependenciesProvider +import org.thoughtcrime.securesms.keyvalue.InternalValues +import org.thoughtcrime.securesms.testutil.MockSignalStoreRule + +/** + * Tests for AvatarPickerScreen that validate event emissions and state driven visibility. + * Uses Robolectric to run fast JUnit tests without an emulator. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class AvatarPickerScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @get:Rule + val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext()) + + @get:Rule + val signalStore = MockSignalStoreRule(relaxed = setOf(InternalValues::class)) + + @Before + fun setUp() { + // Only the first init per Robolectric sandbox wins, so install the same provider AppDependencies + // would, then keep it on the system emoji font so text avatars render without the emoji sheets. + EmojiDependencies.init(ApplicationProvider.getApplicationContext(), EmojiDependenciesProvider) + every { signalStore.settings.isPreferSystemEmoji } returns true + } + + @Test + fun `when camera is clicked, CapturePhoto event is emitted`() { + val emittedEvents = setContent(state()) + + composeTestRule.onNodeWithTag(AvatarPickerTestTags.CAMERA_BUTTON).performClick() + + assert(emittedEvents == listOf(AvatarPickerEvents.CapturePhoto)) + } + + @Test + fun `when photo is clicked, SelectPhoto event is emitted`() { + val emittedEvents = setContent(state()) + + composeTestRule.onNodeWithTag(AvatarPickerTestTags.PHOTO_BUTTON).performClick() + + assert(emittedEvents == listOf(AvatarPickerEvents.SelectPhoto)) + } + + @Test + fun `when text is clicked, SelectText event is emitted`() { + val emittedEvents = setContent(state()) + + composeTestRule.onNodeWithTag(AvatarPickerTestTags.TEXT_BUTTON).performClick() + + assert(emittedEvents == listOf(AvatarPickerEvents.SelectText)) + } + + @Test + fun `when save is clicked, Save event is emitted`() { + val emittedEvents = setContent(state(canSave = true)) + + composeTestRule.onNodeWithTag(AvatarPickerTestTags.SAVE_BUTTON).assertIsEnabled().performClick() + + assert(emittedEvents == listOf(AvatarPickerEvents.Save)) + } + + @Test + fun `when there is nothing to save, save button is disabled`() { + setContent(state(canSave = false)) + + composeTestRule.onNodeWithTag(AvatarPickerTestTags.SAVE_BUTTON).assertIsNotEnabled() + } + + @Test + fun `when clear avatar is clicked, ClearAvatar event is emitted`() { + val emittedEvents = setContent(state(currentAvatar = avatars()[1], canClear = true)) + + composeTestRule.onNodeWithTag(AvatarPickerTestTags.CLEAR_AVATAR_BUTTON).performClick() + + assert(emittedEvents == listOf(AvatarPickerEvents.ClearAvatar)) + } + + @Test + fun `when the avatar cannot be cleared, clear button is hidden`() { + setContent(state(currentAvatar = avatars()[1], canClear = false)) + + assert(composeTestRule.onAllNodesWithTag(AvatarPickerTestTags.CLEAR_AVATAR_BUTTON).fetchSemanticsNodes().isEmpty()) { + "Expected no clear button when the avatar cannot be cleared" + } + } + + @Test + fun `when an unselected avatar is clicked, AvatarSelected event is emitted`() { + val avatars = avatars() + val emittedEvents = setContent(state(currentAvatar = avatars[0], selectableAvatars = avatars)) + + scrollToAvatars() + composeTestRule.onAllNodesWithTag(AvatarPickerTestTags.SELECTABLE_AVATAR)[1].performClick() + + assert(emittedEvents == listOf(AvatarPickerEvents.AvatarSelected(avatars[1]))) + } + + @Test + fun `when the selected avatar is clicked, EditAvatar event is emitted`() { + val avatars = avatars() + val emittedEvents = setContent(state(currentAvatar = avatars[0], selectableAvatars = avatars)) + + scrollToAvatars() + composeTestRule.onAllNodesWithTag(AvatarPickerTestTags.SELECTABLE_AVATAR)[0].performClick() + + assert(emittedEvents == listOf(AvatarPickerEvents.EditAvatar(avatars[0]))) + } + + @Test + fun `when a saved avatar is long pressed, delete emits DeleteAvatar event`() { + val avatars = avatars() + val emittedEvents = setContent(state(selectableAvatars = avatars)) + + scrollToAvatars() + composeTestRule.onAllNodesWithTag(AvatarPickerTestTags.SELECTABLE_AVATAR)[0].performTouchInput { longClick() } + composeTestRule.onNodeWithText(DELETE_LABEL).assertIsDisplayed().performClick() + + assert(emittedEvents == listOf(AvatarPickerEvents.DeleteAvatar(avatars[0]))) + } + + @Test + fun `when a default avatar is long pressed, no menu is shown`() { + val avatars = avatars() + val emittedEvents = setContent(state(selectableAvatars = avatars)) + + scrollToAvatars() + composeTestRule.onAllNodesWithTag(AvatarPickerTestTags.SELECTABLE_AVATAR)[1].performTouchInput { longClick() } + + assert(composeTestRule.onAllNodesWithText(DELETE_LABEL).fetchSemanticsNodes().isEmpty()) { + "Expected no context menu for an avatar that cannot be deleted" + } + + // With no long press handler the gesture falls through to a click. + assert(emittedEvents == listOf(AvatarPickerEvents.AvatarSelected(avatars[1]))) + } + + /** The header fills a small viewport, so no avatar is composed until the grid scrolls. */ + private fun scrollToAvatars() { + composeTestRule.onNodeWithTag(AvatarPickerTestTags.AVATAR_GRID).performScrollToIndex(FIRST_AVATAR_INDEX) + } + + private fun setContent(state: AvatarPickerState): List { + val emittedEvents = mutableListOf() + + composeTestRule.setContent { + SignalTheme { + AvatarPickerScreen( + state = state, + onEvent = { emittedEvents += it } + ) + } + } + + return emittedEvents + } + + private fun state( + currentAvatar: Avatar? = null, + selectableAvatars: List = emptyList(), + canSave: Boolean = false, + canClear: Boolean = false + ): AvatarPickerState { + return AvatarPickerState( + currentAvatar = currentAvatar, + selectableAvatars = selectableAvatars, + canSave = canSave, + canClear = canClear + ) + } + + /** A deletable avatar followed by two defaults, all in the first row. */ + private fun avatars(): List { + return listOf( + Avatar.Text(text = "AH", color = COLOR, databaseId = Avatar.DatabaseId.Saved(1)), + Avatar.Vector(key = "avatar_cat", color = COLOR, databaseId = Avatar.DatabaseId.NotSet), + Avatar.Vector(key = "avatar_dog", color = COLOR, databaseId = Avatar.DatabaseId.NotSet) + ) + } + + companion object { + private const val DELETE_LABEL = "Delete" + + private const val FIRST_AVATAR_INDEX = 1 + + private val COLOR = Avatars.ColorPair( + foregroundAvatarColor = Avatars.ForegroundColor.A210, + backgroundAvatarColor = AvatarColor.A210 + ) + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Buttons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Buttons.kt index 254cfffa57..41467894f7 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/Buttons.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Buttons.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -175,6 +176,29 @@ object Buttons { ) } + @Composable + fun ActionButton( + onClick: () -> Unit, + imageVector: ImageVector, + label: String, + modifier: Modifier = Modifier, + enabled: Boolean = true + ) { + ActionButton( + enabled = enabled, + onClick = onClick, + label = label, + modifier = modifier + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier.padding(16.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + @Composable fun ActionButton( onClick: () -> Unit, diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt index ad8b7d454b..3608cfff30 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt @@ -93,6 +93,7 @@ object Scaffolds { }, snackbarHost: @Composable () -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, content: @Composable (PaddingValues) -> Unit ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() @@ -110,6 +111,7 @@ object Scaffolds { scrollBehavior = scrollBehavior ) }, + bottomBar = bottomBar, modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), content = content ) diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt index cb452051cb..8283735ce0 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt @@ -78,6 +78,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon { Open(icon(R.drawable.symbol_open_24)), PersonCircle(icon(R.drawable.symbol_person_circle_24)), Phone(icon(R.drawable.symbol_phone_24)), + Photo(icon(R.drawable.symbol_photo_24)), Plus(icon(R.drawable.symbol_plus_24)), QrCode(icon(R.drawable.symbol_qrcode_24)), QualityHigh(icon(R.drawable.symbol_quality_high_24)), diff --git a/core/ui/src/main/res/drawable/symbol_photo_24.xml b/core/ui/src/main/res/drawable/symbol_photo_24.xml new file mode 100644 index 0000000000..e9473a392a --- /dev/null +++ b/core/ui/src/main/res/drawable/symbol_photo_24.xml @@ -0,0 +1,12 @@ + + + +