Convert the avatar picker to compose.

This commit is contained in:
Alex Hart
2026-08-21 17:36:10 -04:00
committed by Cody Henthorne
parent 441ba42c3f
commit 5deaab3cf3
19 changed files with 1292 additions and 529 deletions
@@ -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<Unit>) {
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<Unit>): Result<Media> {
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,
@@ -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)
)
}
}
@@ -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
}
@@ -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
}
@@ -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<Avatar.Photo>
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)
@@ -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<Model> {
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<Model>(itemView) {
class ViewHolder(itemView: View) : MappingViewHolder<Model>(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)
@@ -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<Avatar> = 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<Avatar> = 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<List<Avatar>> = Single.fromCallable {
suspend fun getPersistedAvatarsForSelf(): List<Avatar> = withContext(SignalDispatchers.Default) {
SignalDatabase.avatarPicker.getAvatarsForSelf()
}
fun getPersistedAvatarsForGroup(groupId: GroupId): Single<List<Avatar>> = Single.fromCallable {
suspend fun getPersistedAvatarsForGroup(groupId: GroupId): List<Avatar> = withContext(SignalDispatchers.Default) {
SignalDatabase.avatarPicker.getAvatarsForGroup(groupId)
}
fun getDefaultAvatarsForSelf(): Single<List<Avatar>> = Single.fromCallable {
Avatars.defaultAvatarsForSelf.entries.mapIndexed { index, entry ->
fun getDefaultAvatarsForSelf(): List<Avatar> {
return Avatars.defaultAvatarsForSelf.entries.mapIndexed { index, entry ->
Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet)
}
}
fun getDefaultAvatarsForGroup(): Single<List<Avatar>> = Single.fromCallable {
Avatars.defaultAvatarsForGroup.entries.mapIndexed { index, entry ->
fun getDefaultAvatarsForGroup(): List<Avatar> {
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<Throwable, Uri> = 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<Throwable, Avatar> = 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<Throwable, Avatar> = 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<Throwable, Media> = 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<Throwable, Media> = 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<Throwable, Media> = 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<Throwable, Media> = 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()
}
}
}
@@ -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 = {}
)
}
}
@@ -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"
}
@@ -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<AvatarPickerEvents>(TAG) {
val state: LiveData<AvatarPickerState> = store.stateLiveData
private val internalState = MutableStateFlow(AvatarPickerState())
val state: StateFlow<AvatarPickerState> = internalState
protected abstract fun getAvatar(): Single<Avatar>
protected abstract fun getDefaultAvatarFromRepository(): Avatar
protected abstract fun getPersistedAvatars(): Single<List<Avatar>>
protected abstract fun getDefaultAvatars(): Single<List<Avatar>>
protected abstract fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit)
protected abstract fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit)
private val internalActions = Channel<AvatarPickerActions>(Channel.BUFFERED)
val actions: Flow<AvatarPickerActions> = 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<Avatar>
protected abstract suspend fun getDefaultAvatars(): List<Avatar>
protected abstract suspend fun persistAvatar(avatar: Avatar): Either<Throwable, Avatar>
protected abstract suspend fun persistAndCreateMedia(avatar: Avatar): Either<Throwable, Media>
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<Avatar.Vector>().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<Avatar> = repository.getAvatarForSelf()
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForSelf()
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForSelf()
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForSelf()
override suspend fun getAvatar(): Avatar = AvatarPickerRepository.getAvatarForSelf()
override suspend fun getDefaultAvatarFromRepository(): Avatar = AvatarPickerRepository.getDefaultAvatarForSelf()
override suspend fun getPersistedAvatars(): List<Avatar> = AvatarPickerRepository.getPersistedAvatarsForSelf()
override suspend fun getDefaultAvatars(): List<Avatar> = AvatarPickerRepository.getDefaultAvatarsForSelf()
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
repository.persistAvatarForSelf(avatar, onPersisted)
override suspend fun persistAvatar(avatar: Avatar): Either<Throwable, Avatar> {
return AvatarPickerRepository.persistAvatarForSelf(avatar)
}
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) {
repository.persistAndCreateMediaForSelf(avatar, onSaved)
override suspend fun persistAndCreateMedia(avatar: Avatar): Either<Throwable, Media> {
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<Avatar> {
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<List<Avatar>> = repository.getPersistedAvatarsForGroup(groupId)
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
override suspend fun getDefaultAvatarFromRepository(): Avatar = AvatarPickerRepository.getDefaultAvatarForGroup(groupId)
override suspend fun getPersistedAvatars(): List<Avatar> = AvatarPickerRepository.getPersistedAvatarsForGroup(groupId)
override suspend fun getDefaultAvatars(): List<Avatar> = AvatarPickerRepository.getDefaultAvatarsForGroup()
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
repository.persistAvatarForGroup(avatar, groupId, onPersisted)
override suspend fun persistAvatar(avatar: Avatar): Either<Throwable, Avatar> {
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<Throwable, Media> {
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<Avatar> {
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<List<Avatar>> = Single.just(listOf())
override fun getDefaultAvatars(): Single<List<Avatar>> = 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<Avatar> = emptyList()
override suspend fun getDefaultAvatars(): List<Avatar> = AvatarPickerRepository.getDefaultAvatarsForGroup()
override suspend fun persistAvatar(avatar: Avatar): Either<Throwable, Avatar> = avatar.right()
override suspend fun persistAndCreateMedia(avatar: Avatar): Either<Throwable, Media> = 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 <T : ViewModel> create(modelClass: Class<T>): 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))
@@ -1,152 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/avatar_picker_item_image"
android:layout_width="@dimen/avatar_picker_image_width"
android:layout_height="@dimen/avatar_picker_image_width"
android:layout_marginTop="?actionBarSize"
android:background="@drawable/circle_tintable"
android:contentDescription="@string/AvatarPickerFragment__avatar_preview"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
tools:backgroundTint="#DEE3FF" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/avatar_picker_item_text"
android:layout_width="0dp"
android:layout_height="0dp"
android:fontFamily="sans-serif-medium"
android:gravity="center"
app:emoji_forceJumbo="true"
app:layout_constraintBottom_toBottomOf="@id/avatar_picker_item_image"
app:layout_constraintEnd_toEndOf="@id/avatar_picker_item_image"
app:layout_constraintStart_toStartOf="@id/avatar_picker_item_image"
app:layout_constraintTop_toTopOf="@id/avatar_picker_item_image"
tools:ignore="SpUsage"
tools:text="AF" />
<org.thoughtcrime.securesms.components.ButtonStripItemView
android:id="@+id/avatar_picker_camera"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
app:bsiv_icon="@drawable/ic_camera_24"
app:bsiv_icon_contentDescription="@string/AvatarPickerFragment__take_a_picture"
app:bsiv_label="@string/AvatarPickerFragment__camera"
app:layout_constraintEnd_toStartOf="@id/avatar_picker_photo"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/avatar_picker_item_image" />
<org.thoughtcrime.securesms.components.ButtonStripItemView
android:id="@+id/avatar_picker_photo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
app:bsiv_icon="@drawable/ic_photo_24"
app:bsiv_icon_contentDescription="@string/AvatarPickerFragment__choose_a_photo"
app:bsiv_label="@string/AvatarPickerFragment__photo"
app:layout_constraintEnd_toStartOf="@id/avatar_picker_text"
app:layout_constraintStart_toEndOf="@id/avatar_picker_camera"
app:layout_constraintTop_toBottomOf="@id/avatar_picker_item_image" />
<org.thoughtcrime.securesms.components.ButtonStripItemView
android:id="@+id/avatar_picker_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
app:bsiv_icon="@drawable/ic_text_24"
app:bsiv_icon_contentDescription="@string/AvatarPickerFragment__text"
app:bsiv_label="@string/AvatarPickerFragment__text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/avatar_picker_photo"
app:layout_constraintTop_toBottomOf="@id/avatar_picker_item_image" />
<View
android:id="@+id/avatar_picker_divider"
android:layout_width="match_parent"
android:layout_height="1.5dp"
android:layout_marginTop="16.25dp"
android:layout_marginBottom="16.25dp"
android:alpha="0.16"
android:background="@color/signal_colorOutline"
app:layout_constraintTop_toBottomOf="@id/avatar_picker_text" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/avatar_picker_recycler"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="40dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:clipChildren="false"
android:clipToPadding="false"
android:overScrollMode="never"
android:paddingBottom="80dp"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_min="160dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/avatar_picker_divider"
app:spanCount="4"
tools:listitem="@layout/avatar_picker_item" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/avatar_picker_clear"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginEnd="4dp"
android:contentDescription="@string/AvatarPickerFragment__clear_avatar"
android:visibility="gone"
app:backgroundTint="@color/signal_background_primary"
app:fabSize="mini"
app:layout_constraintEnd_toEndOf="@id/avatar_picker_item_image"
app:layout_constraintTop_toTopOf="@id/avatar_picker_item_image"
app:srcCompat="@drawable/ic_x_20"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<androidx.appcompat.widget.Toolbar
android:id="@+id/avatar_picker_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?actionBarSize"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navigationIcon="@drawable/ic_x_tinted" />
<com.google.android.material.button.MaterialButton
android:id="@+id/avatar_picker_save"
style="@style/Signal.Widget.Button.Large.Tonal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:text="@string/AvatarPickerFragment__save"
app:cornerRadius="28dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/action_delete" android:title="@string/delete" />
</menu>
@@ -1,15 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/avatar_picker"
app:startDestination="@id/avatarPickerFragment">
<fragment
android:id="@+id/avatarPickerFragment"
android:name="org.thoughtcrime.securesms.avatar.picker.AvatarPickerFragment"
android:label="fragment_pick_avatar"
tools:layout="@layout/avatar_picker_fragment">
android:label="fragment_pick_avatar">
<action
android:id="@+id/action_avatarPickerFragment_to_textAvatarCreationFragment"
app:destination="@id/textAvatarCreationFragment"
+4 -2
View File
@@ -6321,13 +6321,15 @@
<!-- AvatarPickerFragment -->
<string name="AvatarPickerFragment__avatar_preview">Avatar preview</string>
<string name="AvatarPickerFragment__camera">Camera</string>
<string name="AvatarPickerFragment__take_a_picture">Take a picture</string>
<string name="AvatarPickerFragment__choose_a_photo">Choose a photo</string>
<string name="AvatarPickerFragment__photo">Photo</string>
<!-- Button label during avatar selection/creation that when tapped lets you generate an avatar from typed in text. -->
<string name="AvatarPickerFragment__text">Text</string>
<string name="AvatarPickerFragment__save">Save</string>
<string name="AvatarPickerFragment__clear_avatar">Clear avatar</string>
<!-- Content description for the edit icon displayed over the currently selected avatar. -->
<string name="AvatarPickerFragment__edit_avatar">Edit avatar</string>
<!-- Accessibility label describing the menu shown when long pressing one of your saved avatars. -->
<string name="AvatarPickerFragment__avatar_options">Avatar options</string>
<string name="AvatarPickerRepository__failed_to_save_avatar">Failed to save avatar</string>
<!-- TextAvatarCreationFragment -->
@@ -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<AvatarPickerEvents> {
val emittedEvents = mutableListOf<AvatarPickerEvents>()
composeTestRule.setContent {
SignalTheme {
AvatarPickerScreen(
state = state,
onEvent = { emittedEvents += it }
)
}
}
return emittedEvents
}
private fun state(
currentAvatar: Avatar? = null,
selectableAvatars: List<Avatar> = 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<Avatar> {
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
)
}
}
@@ -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,
@@ -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
)
@@ -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)),
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M6 9.13C6 7.95 6.95 7 8.13 7c1.17 0 2.12 0.95 2.12 2.13 0 1.17-0.95 2.12-2.13 2.12C6.96 11.25 6 10.3 6 9.12Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M7.56 3.13h8.88c0.95 0 1.71 0 2.33 0.05 0.64 0.05 1.2 0.16 1.72 0.42 0.82 0.42 1.49 1.1 1.9 1.91 0.27 0.52 0.38 1.08 0.43 1.72 0.05 0.62 0.05 1.38 0.05 2.33v4.88c0 0.95 0 1.71-0.05 2.33-0.05 0.64-0.16 1.2-0.42 1.72-0.42 0.82-1.1 1.49-1.91 1.9-0.52 0.27-1.08 0.38-1.72 0.43-0.62 0.05-1.38 0.05-2.33 0.05H7.56c-0.95 0-1.71 0-2.33-0.05-0.64-0.05-1.2-0.16-1.72-0.42-0.82-0.42-1.49-1.1-1.9-1.91-0.27-0.52-0.38-1.08-0.43-1.72-0.06-0.62-0.06-1.38-0.05-2.33V9.56c0-0.95 0-1.71 0.05-2.33 0.05-0.64 0.16-1.2 0.42-1.72 0.42-0.82 1.1-1.49 1.91-1.9 0.52-0.27 1.08-0.38 1.72-0.43 0.62-0.06 1.38-0.06 2.33-0.06ZM5.37 4.92C4.84 4.96 4.54 5.04 4.31 5.16 3.8 5.41 3.4 5.81 3.16 6.31c-0.12 0.23-0.2 0.53-0.24 1.06C2.88 7.91 2.88 8.61 2.88 9.6v4.8 0.3l1.66-1.43c0.84-0.72 2.08-0.72 2.92 0l1.7 1.46 4.46-3.82c0.94-0.8 2.32-0.8 3.26 0l4.25 3.64V9.6c0-1 0-1.69-0.05-2.23-0.04-0.53-0.12-0.83-0.24-1.06-0.25-0.5-0.65-0.9-1.15-1.15-0.23-0.12-0.53-0.2-1.06-0.24-0.54-0.04-1.24-0.04-2.23-0.04H7.6c-1 0-1.69 0-2.23 0.04Zm4.95 10.8l0.54 0.46c0.32 0.27 0.35 0.74 0.08 1.06-0.27 0.31-0.74 0.35-1.05 0.08l-3.4-2.91c-0.28-0.24-0.7-0.24-0.98 0l-2.59 2.22c0.04 0.53 0.12 0.83 0.24 1.06 0.25 0.5 0.65 0.9 1.15 1.15 0.23 0.12 0.53 0.2 1.06 0.24 0.54 0.04 1.24 0.05 2.23 0.05h8.8c1 0 1.69 0 2.23-0.05 0.53-0.04 0.83-0.12 1.06-0.24 0.5-0.25 0.9-0.65 1.15-1.15 0.12-0.23 0.2-0.53 0.24-1.06l0.01-0.14-5.19-4.44c-0.37-0.33-0.93-0.33-1.3 0l-4.28 3.66Z"/>
</vector>