Adjust new avatar picker logic.

* Better emoji rendering support
* Deleting an avatar will deselect it
* Added padding to the bottom of recyclers
* Disabled save if no edit / selection has been made.
* Clearing and saving will remove a user's avatar.
fork-5.53.8
Alex Hart 2021-07-21 13:35:47 -03:00 zatwierdzone przez Greyson Parrelli
rodzic a75f634c0a
commit a27d60f830
20 zmienionych plików z 293 dodań i 119 usunięć

Wyświetl plik

@ -24,8 +24,8 @@ jobs:
- name: Validate Gradle Wrapper - name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1 uses: gradle/wrapper-validation-action@v1
- name: Remove Android S - name: Remove Android 31 (S)
run: $ANDROID_HOME/tools/bin/sdkmanager --uninstall "platforms;android-S" run: $ANDROID_HOME/tools/bin/sdkmanager --uninstall "platforms;android-31"
- name: Build with Gradle - name: Build with Gradle
run: ./gradlew qa run: ./gradlew qa

Wyświetl plik

@ -3,13 +3,11 @@ package org.thoughtcrime.securesms.avatar
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import com.airbnb.lottie.SimpleColorFilter import com.airbnb.lottie.SimpleColorFilter
import com.amulyakhare.textdrawable.TextDrawable
import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.mms.PartAuthority
@ -28,7 +26,7 @@ import javax.annotation.meta.Exhaustive
*/ */
object AvatarRenderer { object AvatarRenderer {
private val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS
fun getTypeface(context: Context): Typeface { fun getTypeface(context: Context): Typeface {
return Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf") return Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf")
@ -50,30 +48,8 @@ object AvatarRenderer {
avatar: Avatar.Text, avatar: Avatar.Text,
inverted: Boolean = false, inverted: Boolean = false,
size: Int = DIMENSIONS, size: Int = DIMENSIONS,
isRect: Boolean = true
): Drawable { ): Drawable {
val typeface = getTypeface(context) return TextAvatarDrawable(context, avatar, inverted, size)
val color: Int = if (inverted) {
avatar.color.backgroundColor
} else {
avatar.color.foregroundColor
}
val builder = TextDrawable
.builder()
.beginConfig()
.fontSize(Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f).toInt())
.textColor(color)
.useFont(typeface)
.width(size)
.height(size)
.endConfig()
return if (isRect) {
builder.buildRect(avatar.text, Color.TRANSPARENT)
} else {
builder.buildRound(avatar.text, Color.TRANSPARENT)
}
} }
private fun renderVector(context: Context, avatar: Avatar.Vector, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) { private fun renderVector(context: Context, avatar: Avatar.Vector, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {

Wyświetl plik

@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.avatar
import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
import android.util.TypedValue
import android.view.Gravity
import android.widget.FrameLayout
import androidx.core.view.updateLayoutParams
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
/**
* Uses EmojiTextView to properly render a Text Avatar with emoji in it.
*/
class TextAvatarDrawable(
context: Context,
avatar: Avatar.Text,
inverted: Boolean = false,
private val size: Int = AvatarRenderer.DIMENSIONS,
) : Drawable() {
private val layout: FrameLayout = FrameLayout(context)
private val textView: EmojiTextView = EmojiTextView(context)
init {
textView.typeface = AvatarRenderer.getTypeface(context)
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f))
textView.text = avatar.text
textView.gravity = Gravity.CENTER
textView.setTextColor(if (inverted) avatar.color.backgroundColor else avatar.color.foregroundColor)
layout.addView(textView)
textView.updateLayoutParams {
width = size
height = size
}
layout.measure(size, size)
layout.layout(0, 0, size, size)
}
override fun getIntrinsicHeight(): Int = size
override fun getIntrinsicWidth(): Int = size
override fun draw(canvas: Canvas) {
layout.draw(canvas)
}
override fun setAlpha(alpha: Int) = Unit
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
override fun getOpacity(): Int = PixelFormat.OPAQUE
}

Wyświetl plik

@ -40,6 +40,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
companion object { companion object {
const val REQUEST_KEY_SELECT_AVATAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR" const val REQUEST_KEY_SELECT_AVATAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR"
const val SELECT_AVATAR_MEDIA = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_MEDIA" const val SELECT_AVATAR_MEDIA = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_MEDIA"
const val SELECT_AVATAR_CLEAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_CLEAR"
private const val REQUEST_CODE_SELECT_IMAGE = 1 private const val REQUEST_CODE_SELECT_IMAGE = 1
} }
@ -94,15 +95,26 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
photoButton.setOnIconClickedListener { openGallery() } photoButton.setOnIconClickedListener { openGallery() }
textButton.setOnIconClickedListener { openTextEditor(null) } textButton.setOnIconClickedListener { openTextEditor(null) }
saveButton.setOnClickListener { v -> saveButton.setOnClickListener { v ->
viewModel.save { viewModel.save(
setFragmentResult( {
REQUEST_KEY_SELECT_AVATAR, setFragmentResult(
Bundle().apply { REQUEST_KEY_SELECT_AVATAR,
putParcelable(SELECT_AVATAR_MEDIA, it) Bundle().apply {
} putParcelable(SELECT_AVATAR_MEDIA, it)
) }
Navigation.findNavController(v).popBackStack() )
} Navigation.findNavController(v).popBackStack()
},
{
setFragmentResult(
REQUEST_KEY_SELECT_AVATAR,
Bundle().apply {
putBoolean(SELECT_AVATAR_CLEAR, true)
}
)
Navigation.findNavController(v).popBackStack()
}
)
} }
clearButton.setOnClickListener { viewModel.clear() } clearButton.setOnClickListener { viewModel.clear() }

Wyświetl plik

@ -6,7 +6,6 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.core.view.setPadding import androidx.core.view.setPadding
import androidx.core.widget.addTextChangedListener
import com.airbnb.lottie.SimpleColorFilter import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar import org.thoughtcrime.securesms.avatar.Avatar
@ -58,17 +57,14 @@ object AvatarPickerItem {
init { init {
textView.typeface = AvatarRenderer.getTypeface(context) textView.typeface = AvatarRenderer.getTypeface(context)
textView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> textView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
updateTextSize() updateAndApplyText(textView.text.toString())
} }
textView.addTextChangedListener(
afterTextChanged = {
updateTextSize()
}
)
} }
private fun updateTextSize() { private fun updateAndApplyText(text: String) {
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, Avatars.getTextSizeForLength(context, textView.text.toString(), textView.measuredWidth * 0.8f, textView.measuredHeight * 0.45f)) val textSize = Avatars.getTextSizeForLength(context, text, textView.measuredWidth * 0.8f, textView.measuredHeight * 0.45f)
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
textView.text = text
} }
override fun bind(model: Model) { override fun bind(model: Model) {
@ -112,9 +108,7 @@ object AvatarPickerItem {
is Avatar.Text -> { is Avatar.Text -> {
textView.visible = true textView.visible = true
if (textView.text.toString() != model.avatar.text) { updateAndApplyText(model.avatar.text)
textView.text = model.avatar.text
}
imageView.setImageDrawable(null) imageView.setImageDrawable(null)
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor) imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)

Wyświetl plik

@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage import org.thoughtcrime.securesms.avatar.AvatarPickerStorage
import org.thoughtcrime.securesms.avatar.AvatarRenderer import org.thoughtcrime.securesms.avatar.AvatarRenderer
import org.thoughtcrime.securesms.avatar.Avatars import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.Media
@ -61,10 +62,10 @@ class AvatarPickerRepository(context: Context) {
) )
} catch (e: IOException) { } catch (e: IOException) {
Log.w(TAG, "Failed to read group avatar!") Log.w(TAG, "Failed to read group avatar!")
getDefaultAvatarForGroup() getDefaultAvatarForGroup(recipient.avatarColor)
} }
} else { } else {
getDefaultAvatarForGroup() getDefaultAvatarForGroup(recipient.avatarColor)
} }
} }
@ -155,12 +156,25 @@ class AvatarPickerRepository(context: Context) {
return if (initials.isNullOrBlank()) { return if (initials.isNullOrBlank()) {
Avatar.getDefaultForSelf() Avatar.getDefaultForSelf()
} else { } else {
Avatar.Text(initials, Avatars.colors.random(), Avatar.DatabaseId.NotSet) Avatar.Text(initials, requireNotNull(Avatars.colorMap[Recipient.self().avatarColor.serialize()]), Avatar.DatabaseId.DoNotPersist)
} }
} }
fun getDefaultAvatarForGroup(): Avatar { fun getDefaultAvatarForGroup(groupId: GroupId): Avatar {
return Avatar.getDefaultForGroup() val recipient = Recipient.externalGroupExact(applicationContext, groupId)
return getDefaultAvatarForGroup(recipient.avatarColor)
}
fun getDefaultAvatarForGroup(color: AvatarColor?): Avatar {
val colorPair = Avatars.colorMap[color?.serialize()]
val defaultColor = Avatar.getDefaultForGroup()
return if (colorPair != null) {
defaultColor.copy(color = colorPair)
} else {
defaultColor
}
} }
fun delete(avatar: Avatar, onDelete: () -> Unit) { fun delete(avatar: Avatar, onDelete: () -> Unit) {

Wyświetl plik

@ -6,5 +6,6 @@ data class AvatarPickerState(
val currentAvatar: Avatar? = null, val currentAvatar: Avatar? = null,
val selectableAvatars: List<Avatar> = listOf(), val selectableAvatars: List<Avatar> = listOf(),
val canSave: Boolean = false, val canSave: Boolean = false,
val canClear: Boolean = false val canClear: Boolean = false,
val isCleared: Boolean = false
) )

Wyświetl plik

@ -27,6 +27,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
fun delete(avatar: Avatar) { fun delete(avatar: Avatar) {
repository.delete(avatar) { repository.delete(avatar) {
refreshAvatar()
refreshSelectableAvatars() refreshSelectableAvatars()
} }
} }
@ -34,22 +35,26 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
fun clear() { fun clear() {
store.update { store.update {
val avatar = getDefaultAvatarFromRepository() val avatar = getDefaultAvatarFromRepository()
it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = false) it.copy(currentAvatar = avatar, canSave = true, canClear = false, isCleared = true)
} }
} }
fun save(onSaved: (Media) -> Unit) { fun save(onSaved: (Media) -> Unit, onCleared: () -> Unit) {
val avatar = store.state.currentAvatar ?: throw AssertionError() if (store.state.isCleared) {
persistAndCreateMedia(avatar, onSaved) onCleared()
} else {
val avatar = store.state.currentAvatar ?: throw AssertionError()
persistAndCreateMedia(avatar, onSaved)
}
} }
fun onAvatarSelectedFromGrid(avatar: Avatar) { fun onAvatarSelectedFromGrid(avatar: Avatar) {
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true) } store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
} }
fun onAvatarEditCompleted(avatar: Avatar) { fun onAvatarEditCompleted(avatar: Avatar) {
persistAvatar(avatar) { saved -> persistAvatar(avatar) { saved ->
store.update { it.copy(currentAvatar = saved, canSave = isSaveable(saved), canClear = true) } store.update { it.copy(currentAvatar = saved, canSave = isSaveable(saved), canClear = true, isCleared = false) }
refreshSelectableAvatars() refreshSelectableAvatars()
} }
} }
@ -57,7 +62,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
fun onAvatarPhotoSelectionCompleted(media: Media) { fun onAvatarPhotoSelectionCompleted(media: Media) {
repository.writeMediaToMultiSessionStorage(media) { multiSessionUri -> repository.writeMediaToMultiSessionStorage(media) { multiSessionUri ->
persistAvatar(Avatar.Photo(multiSessionUri, media.size, Avatar.DatabaseId.NotSet)) { avatar -> persistAvatar(Avatar.Photo(multiSessionUri, media.size, Avatar.DatabaseId.NotSet)) { avatar ->
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true) } store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
refreshSelectableAvatars() refreshSelectableAvatars()
} }
} }
@ -66,7 +71,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
protected fun refreshAvatar() { protected fun refreshAvatar() {
disposables.add( disposables.add(
getAvatar().subscribeOn(Schedulers.io()).subscribe { avatar -> getAvatar().subscribeOn(Schedulers.io()).subscribe { avatar ->
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = !isSaveable(avatar)) } store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = avatar is Avatar.Photo && !isSaveable(avatar), isCleared = false) }
} }
) )
} }
@ -84,7 +89,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
) )
} }
private fun isSaveable(avatar: Avatar) = !(avatar is Avatar.Photo && avatar.databaseId == Avatar.DatabaseId.DoNotPersist) private fun isSaveable(avatar: Avatar) = avatar.databaseId != Avatar.DatabaseId.DoNotPersist
override fun onCleared() { override fun onCleared() {
disposables.dispose() disposables.dispose()
@ -132,7 +137,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
} }
} }
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup() override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(groupId)
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForGroup(groupId) override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForGroup(groupId)
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup() override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
@ -161,11 +166,11 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
return if (initialAvatar != null) { return if (initialAvatar != null) {
Single.just(initialAvatar) Single.just(initialAvatar)
} else { } else {
Single.just(getDefaultAvatarFromRepository()) Single.fromCallable { getDefaultAvatarFromRepository() }
} }
} }
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup() override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(null)
override fun getPersistedAvatars(): Single<List<Avatar>> = Single.just(listOf()) override fun getPersistedAvatars(): Single<List<Avatar>> = Single.just(listOf())
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup() override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) = onPersisted(avatar) override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) = onPersisted(avatar)

Wyświetl plik

@ -41,6 +41,8 @@ class TextAvatarCreationFragment : Fragment(R.layout.text_avatar_creation_fragme
private val withRecyclerSet = ConstraintSet() private val withRecyclerSet = ConstraintSet()
private val withoutRecyclerSet = ConstraintSet() private val withoutRecyclerSet = ConstraintSet()
private var hasBoundFromViewModel: Boolean = false
private fun createFactory(): TextAvatarCreationViewModel.Factory { private fun createFactory(): TextAvatarCreationViewModel.Factory {
val args = TextAvatarCreationFragmentArgs.fromBundle(requireArguments()) val args = TextAvatarCreationFragmentArgs.fromBundle(requireArguments())
val textBundle = args.textAvatar val textBundle = args.textAvatar
@ -83,17 +85,25 @@ class TextAvatarCreationFragment : Fragment(R.layout.text_avatar_creation_fragme
EditTextUtil.setCursorColor(textInput, state.currentAvatar.color.foregroundColor) EditTextUtil.setCursorColor(textInput, state.currentAvatar.color.foregroundColor)
val hadText = textInput.length() > 0 val hadText = textInput.length() > 0
val selectionStart = textInput.selectionStart
val selectionEnd = textInput.selectionEnd
viewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false)) viewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
if (!hadText) { textInput.post {
textInput.setSelection(textInput.length()) if (!hadText) {
textInput.setSelection(textInput.length())
} else {
textInput.setSelection(selectionStart, selectionEnd)
}
} }
adapter.submitList(state.colors().map { AvatarColorItem.Model(it) }) adapter.submitList(state.colors().map { AvatarColorItem.Model(it) })
hasBoundFromViewModel = true
} }
EditTextUtil.addGraphemeClusterLimitFilter(textInput, 3) EditTextUtil.addGraphemeClusterLimitFilter(textInput, 3)
textInput.doAfterTextChanged { textInput.doAfterTextChanged {
if (it != null) { if (it != null && hasBoundFromViewModel) {
viewModel.setText(it.toString()) viewModel.setText(it.toString())
} }
} }

Wyświetl plik

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.avatar.text package org.thoughtcrime.securesms.avatar.text
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.avatar.Avatar import org.thoughtcrime.securesms.avatar.Avatar
@ -11,14 +12,20 @@ class TextAvatarCreationViewModel(initialText: Avatar.Text) : ViewModel() {
private val store = Store(TextAvatarCreationState(initialText)) private val store = Store(TextAvatarCreationState(initialText))
val state: LiveData<TextAvatarCreationState> = store.stateLiveData val state: LiveData<TextAvatarCreationState> = Transformations.distinctUntilChanged(store.stateLiveData)
fun setColor(colorPair: Avatars.ColorPair) { fun setColor(colorPair: Avatars.ColorPair) {
store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) } store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) }
} }
fun setText(text: String) { fun setText(text: String) {
store.update { it.copy(currentAvatar = it.currentAvatar.copy(text = text)) } store.update {
if (it.currentAvatar.text == text) {
it
} else {
it.copy(currentAvatar = it.currentAvatar.copy(text = text))
}
}
} }
fun getCurrentAvatar(): Avatar.Text { fun getCurrentAvatar(): Avatar.Text {

Wyświetl plik

@ -54,7 +54,7 @@ public class GeneratedContactPhoto implements FallbackContactPhoto {
if (!TextUtils.isEmpty(character)) { if (!TextUtils.isEmpty(character)) {
Avatars.ForegroundColor foregroundColor = Avatars.getForegroundColor(color); Avatars.ForegroundColor foregroundColor = Avatars.getForegroundColor(color);
Avatar.Text avatar = new Avatar.Text(character, new Avatars.ColorPair(color, foregroundColor), Avatar.DatabaseId.DoNotPersist.INSTANCE); Avatar.Text avatar = new Avatar.Text(character, new Avatars.ColorPair(color, foregroundColor), Avatar.DatabaseId.DoNotPersist.INSTANCE);
Drawable foreground = AvatarRenderer.createTextDrawable(context, avatar, inverted, targetSize, false); Drawable foreground = AvatarRenderer.createTextDrawable(context, avatar, inverted, targetSize);
Drawable background = Objects.requireNonNull(ContextCompat.getDrawable(context, R.drawable.circle_tintable)); Drawable background = Objects.requireNonNull(ContextCompat.getDrawable(context, R.drawable.circle_tintable));
background.setColorFilter(new SimpleColorFilter(inverted ? foregroundColor.getColorInt() : color.colorInt())); background.setColorFilter(new SimpleColorFilter(inverted ? foregroundColor.getColorInt() : color.colorInt()));

Wyświetl plik

@ -170,6 +170,12 @@ public class AddGroupDetailsFragment extends LoggingFragment {
} }
private void handleMediaResult(Bundle data) { private void handleMediaResult(Bundle data) {
if (data.getBoolean(AvatarPickerFragment.SELECT_AVATAR_CLEAR)) {
viewModel.setAvatarMedia(null);
viewModel.setAvatar(null);
return;
}
final Media result = data.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA); final Media result = data.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA);
final DecryptableStreamUriLoader.DecryptableUri decryptableUri = new DecryptableStreamUriLoader.DecryptableUri(result.getUri()); final DecryptableStreamUriLoader.DecryptableUri decryptableUri = new DecryptableStreamUriLoader.DecryptableUri(result.getUri());

Wyświetl plik

@ -34,6 +34,8 @@ import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.Avatars; import org.thoughtcrime.securesms.avatar.Avatars;
import org.thoughtcrime.securesms.avatar.picker.AvatarPickerFragment; import org.thoughtcrime.securesms.avatar.picker.AvatarPickerFragment;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.ParcelableGroupId; import org.thoughtcrime.securesms.groups.ParcelableGroupId;
import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mediasend.Media;
@ -104,8 +106,14 @@ public class EditProfileFragment extends LoggingFragment {
initializeProfileName(); initializeProfileName();
getParentFragmentManager().setFragmentResultListener(AvatarPickerFragment.REQUEST_KEY_SELECT_AVATAR, getViewLifecycleOwner(), (key, bundle) -> { getParentFragmentManager().setFragmentResultListener(AvatarPickerFragment.REQUEST_KEY_SELECT_AVATAR, getViewLifecycleOwner(), (key, bundle) -> {
Media media = bundle.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA); if (bundle.getBoolean(AvatarPickerFragment.SELECT_AVATAR_CLEAR)) {
handleMediaFromResult(media); viewModel.setAvatarMedia(null);
viewModel.setAvatar(null);
avatar.setImageDrawable(null);
} else {
Media media = bundle.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA);
handleMediaFromResult(media);
}
}); });
} }

Wyświetl plik

@ -1,8 +1,9 @@
package org.thoughtcrime.securesms.profiles.manage; package org.thoughtcrime.securesms.profiles.manage;
import android.content.Intent;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils;
import android.util.TypedValue;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@ -18,28 +19,28 @@ import androidx.core.content.res.ResourcesCompat;
import androidx.lifecycle.ViewModelProviders; import androidx.lifecycle.ViewModelProviders;
import androidx.navigation.Navigation; import androidx.navigation.Navigation;
import com.airbnb.lottie.SimpleColorFilter;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import org.signal.core.util.logging.Log; import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.Avatars;
import org.thoughtcrime.securesms.avatar.picker.AvatarPickerFragment; import org.thoughtcrime.securesms.avatar.picker.AvatarPickerFragment;
import org.thoughtcrime.securesms.components.emoji.EmojiUtil; import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity;
import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.profiles.manage.ManageProfileViewModel.AvatarState; import org.thoughtcrime.securesms.profiles.manage.ManageProfileViewModel.AvatarState;
import org.thoughtcrime.securesms.util.NameUtil;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import static android.app.Activity.RESULT_OK;
public class ManageProfileFragment extends LoggingFragment { public class ManageProfileFragment extends LoggingFragment {
private static final String TAG = Log.tag(ManageProfileFragment.class); private static final String TAG = Log.tag(ManageProfileFragment.class);
private Toolbar toolbar; private Toolbar toolbar;
private ImageView avatarView; private ImageView avatarView;
private View avatarPlaceholderView; private ImageView avatarPlaceholderView;
private TextView profileNameView; private TextView profileNameView;
private View profileNameContainer; private View profileNameContainer;
private TextView usernameView; private TextView usernameView;
@ -48,6 +49,8 @@ public class ManageProfileFragment extends LoggingFragment {
private View aboutContainer; private View aboutContainer;
private ImageView aboutEmojiView; private ImageView aboutEmojiView;
private AlertDialog avatarProgress; private AlertDialog avatarProgress;
private TextView avatarInitials;
private ImageView avatarBackground;
private ManageProfileViewModel viewModel; private ManageProfileViewModel viewModel;
@ -68,6 +71,8 @@ public class ManageProfileFragment extends LoggingFragment {
this.aboutView = view.findViewById(R.id.manage_profile_about); this.aboutView = view.findViewById(R.id.manage_profile_about);
this.aboutContainer = view.findViewById(R.id.manage_profile_about_container); this.aboutContainer = view.findViewById(R.id.manage_profile_about_container);
this.aboutEmojiView = view.findViewById(R.id.manage_profile_about_icon); this.aboutEmojiView = view.findViewById(R.id.manage_profile_about_icon);
this.avatarInitials = view.findViewById(R.id.manage_profile_avatar_initials);
this.avatarBackground = view.findViewById(R.id.manage_profile_avatar_background);
initializeViewModel(); initializeViewModel();
@ -87,8 +92,18 @@ public class ManageProfileFragment extends LoggingFragment {
}); });
getParentFragmentManager().setFragmentResultListener(AvatarPickerFragment.REQUEST_KEY_SELECT_AVATAR, getViewLifecycleOwner(), (key, bundle) -> { getParentFragmentManager().setFragmentResultListener(AvatarPickerFragment.REQUEST_KEY_SELECT_AVATAR, getViewLifecycleOwner(), (key, bundle) -> {
Media result = bundle.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA); if (bundle.getBoolean(AvatarPickerFragment.SELECT_AVATAR_CLEAR)) {
viewModel.onAvatarSelected(requireContext(), result); viewModel.onAvatarSelected(requireContext(), null);
} else {
Media result = bundle.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA);
viewModel.onAvatarSelected(requireContext(), result);
}
});
avatarInitials.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
if (avatarInitials.length() > 0) {
updateInitials(avatarInitials.getText().toString());
}
}); });
} }
@ -111,9 +126,26 @@ public class ManageProfileFragment extends LoggingFragment {
private void presentAvatar(@NonNull AvatarState avatarState) { private void presentAvatar(@NonNull AvatarState avatarState) {
if (avatarState.getAvatar() == null) { if (avatarState.getAvatar() == null) {
avatarView.setImageDrawable(null); avatarView.setImageDrawable(null);
avatarPlaceholderView.setVisibility(View.VISIBLE);
CharSequence initials = NameUtil.getAbbreviation(avatarState.getSelf().getDisplayName(requireContext()));
Avatars.ForegroundColor foregroundColor = Avatars.getForegroundColor(avatarState.getSelf().getAvatarColor());
avatarBackground.setColorFilter(new SimpleColorFilter(avatarState.getSelf().getAvatarColor().colorInt()));
avatarPlaceholderView.setColorFilter(new SimpleColorFilter(foregroundColor.getColorInt()));
avatarInitials.setTextColor(foregroundColor.getColorInt());
if (TextUtils.isEmpty(initials)) {
avatarPlaceholderView.setVisibility(View.VISIBLE);
avatarInitials.setVisibility(View.GONE);
} else {
updateInitials(initials.toString());
avatarPlaceholderView.setVisibility(View.GONE);
avatarInitials.setVisibility(View.VISIBLE);
}
} else { } else {
avatarPlaceholderView.setVisibility(View.GONE); avatarPlaceholderView.setVisibility(View.GONE);
avatarInitials.setVisibility(View.GONE);
Glide.with(this) Glide.with(this)
.load(avatarState.getAvatar()) .load(avatarState.getAvatar())
.circleCrop() .circleCrop()
@ -127,6 +159,11 @@ public class ManageProfileFragment extends LoggingFragment {
} }
} }
private void updateInitials(String initials) {
avatarInitials.setTextSize(TypedValue.COMPLEX_UNIT_PX, Avatars.getTextSizeForLength(requireContext(), initials, avatarInitials.getMeasuredWidth() * 0.8f, avatarInitials.getMeasuredWidth() * 0.45f));
avatarInitials.setText(initials);
}
private void presentProfileName(@Nullable ProfileName profileName) { private void presentProfileName(@Nullable ProfileName profileName) {
if (profileName == null || profileName.isEmpty()) { if (profileName == null || profileName.isEmpty()) {
profileNameView.setText(R.string.ManageProfileFragment_profile_name); profileNameView.setText(R.string.ManageProfileFragment_profile_name);

Wyświetl plik

@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.SingleLiveEvent; import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.whispersystems.signalservice.api.util.StreamDetails; import org.whispersystems.signalservice.api.util.StreamDetails;
import java.io.IOException; import java.io.IOException;
@ -32,26 +33,28 @@ class ManageProfileViewModel extends ViewModel {
private static final String TAG = Log.tag(ManageProfileViewModel.class); private static final String TAG = Log.tag(ManageProfileViewModel.class);
private final MutableLiveData<AvatarState> avatar; private final MutableLiveData<InternalAvatarState> internalAvatarState;
private final MutableLiveData<ProfileName> profileName; private final MutableLiveData<ProfileName> profileName;
private final MutableLiveData<String> username; private final MutableLiveData<String> username;
private final MutableLiveData<String> about; private final MutableLiveData<String> about;
private final MutableLiveData<String> aboutEmoji; private final MutableLiveData<String> aboutEmoji;
private final SingleLiveEvent<Event> events; private final LiveData<AvatarState> avatarState;
private final RecipientForeverObserver observer; private final SingleLiveEvent<Event> events;
private final ManageProfileRepository repository; private final RecipientForeverObserver observer;
private final ManageProfileRepository repository;
private byte[] previousAvatar; private byte[] previousAvatar;
public ManageProfileViewModel() { public ManageProfileViewModel() {
this.avatar = new MutableLiveData<>(); this.internalAvatarState = new MutableLiveData<>();
this.profileName = new MutableLiveData<>(); this.profileName = new MutableLiveData<>();
this.username = new MutableLiveData<>(); this.username = new MutableLiveData<>();
this.about = new MutableLiveData<>(); this.about = new MutableLiveData<>();
this.aboutEmoji = new MutableLiveData<>(); this.aboutEmoji = new MutableLiveData<>();
this.events = new SingleLiveEvent<>(); this.events = new SingleLiveEvent<>();
this.repository = new ManageProfileRepository(); this.repository = new ManageProfileRepository();
this.observer = this::onRecipientChanged; this.observer = this::onRecipientChanged;
this.avatarState = LiveDataUtil.combineLatest(Recipient.self().live().getLiveData(), internalAvatarState, (self, state) -> new AvatarState(state, self));
SignalExecutors.BOUNDED.execute(() -> { SignalExecutors.BOUNDED.execute(() -> {
onRecipientChanged(Recipient.self().fresh()); onRecipientChanged(Recipient.self().fresh());
@ -59,13 +62,13 @@ class ManageProfileViewModel extends ViewModel {
StreamDetails details = AvatarHelper.getSelfProfileAvatarStream(ApplicationDependencies.getApplication()); StreamDetails details = AvatarHelper.getSelfProfileAvatarStream(ApplicationDependencies.getApplication());
if (details != null) { if (details != null) {
try { try {
avatar.postValue(AvatarState.loaded(StreamUtil.readFully(details.getStream()))); internalAvatarState.postValue(InternalAvatarState.loaded(StreamUtil.readFully(details.getStream())));
} catch (IOException e) { } catch (IOException e) {
Log.w(TAG, "Failed to read avatar!"); Log.w(TAG, "Failed to read avatar!");
avatar.postValue(AvatarState.none()); internalAvatarState.postValue(InternalAvatarState.none());
} }
} else { } else {
avatar.postValue(AvatarState.none()); internalAvatarState.postValue(InternalAvatarState.none());
} }
ApplicationDependencies.getJobManager().add(RetrieveProfileJob.forRecipient(Recipient.self().getId())); ApplicationDependencies.getJobManager().add(RetrieveProfileJob.forRecipient(Recipient.self().getId()));
@ -75,7 +78,7 @@ class ManageProfileViewModel extends ViewModel {
} }
public @NonNull LiveData<AvatarState> getAvatar() { public @NonNull LiveData<AvatarState> getAvatar() {
return avatar; return avatarState;
} }
public @NonNull LiveData<ProfileName> getProfileName() { public @NonNull LiveData<ProfileName> getProfileName() {
@ -103,18 +106,18 @@ class ManageProfileViewModel extends ViewModel {
} }
public void onAvatarSelected(@NonNull Context context, @Nullable Media media) { public void onAvatarSelected(@NonNull Context context, @Nullable Media media) {
previousAvatar = avatar.getValue() != null ? avatar.getValue().getAvatar() : null; previousAvatar = internalAvatarState.getValue() != null ? internalAvatarState.getValue().getAvatar() : null;
if (media == null) { if (media == null) {
avatar.postValue(AvatarState.loading(null)); internalAvatarState.postValue(InternalAvatarState.loading(null));
repository.clearAvatar(context, result -> { repository.clearAvatar(context, result -> {
switch (result) { switch (result) {
case SUCCESS: case SUCCESS:
avatar.postValue(AvatarState.loaded(null)); internalAvatarState.postValue(InternalAvatarState.loaded(null));
previousAvatar = null; previousAvatar = null;
break; break;
case FAILURE_NETWORK: case FAILURE_NETWORK:
avatar.postValue(AvatarState.loaded(previousAvatar)); internalAvatarState.postValue(InternalAvatarState.loaded(previousAvatar));
events.postValue(Event.AVATAR_NETWORK_FAILURE); events.postValue(Event.AVATAR_NETWORK_FAILURE);
break; break;
} }
@ -125,16 +128,16 @@ class ManageProfileViewModel extends ViewModel {
InputStream stream = BlobProvider.getInstance().getStream(context, media.getUri()); InputStream stream = BlobProvider.getInstance().getStream(context, media.getUri());
byte[] data = StreamUtil.readFully(stream); byte[] data = StreamUtil.readFully(stream);
avatar.postValue(AvatarState.loading(data)); internalAvatarState.postValue(InternalAvatarState.loading(data));
repository.setAvatar(context, data, media.getMimeType(), result -> { repository.setAvatar(context, data, media.getMimeType(), result -> {
switch (result) { switch (result) {
case SUCCESS: case SUCCESS:
avatar.postValue(AvatarState.loaded(data)); internalAvatarState.postValue(InternalAvatarState.loaded(data));
previousAvatar = data; previousAvatar = data;
break; break;
case FAILURE_NETWORK: case FAILURE_NETWORK:
avatar.postValue(AvatarState.loaded(previousAvatar)); internalAvatarState.postValue(InternalAvatarState.loaded(previousAvatar));
events.postValue(Event.AVATAR_NETWORK_FAILURE); events.postValue(Event.AVATAR_NETWORK_FAILURE);
break; break;
} }
@ -148,7 +151,7 @@ class ManageProfileViewModel extends ViewModel {
} }
public boolean canRemoveAvatar() { public boolean canRemoveAvatar() {
return avatar.getValue() != null; return internalAvatarState.getValue() != null;
} }
private void onRecipientChanged(@NonNull Recipient recipient) { private void onRecipientChanged(@NonNull Recipient recipient) {
@ -163,25 +166,49 @@ class ManageProfileViewModel extends ViewModel {
Recipient.self().live().removeForeverObserver(observer); Recipient.self().live().removeForeverObserver(observer);
} }
public static class AvatarState { public final static class AvatarState {
private final InternalAvatarState internalAvatarState;
private final Recipient self;
public AvatarState(@NonNull InternalAvatarState internalAvatarState,
@NonNull Recipient self)
{
this.internalAvatarState = internalAvatarState;
this.self = self;
}
public @Nullable byte[] getAvatar() {
return internalAvatarState.avatar;
}
public @NonNull LoadingState getLoadingState() {
return internalAvatarState.loadingState;
}
public @NonNull Recipient getSelf() {
return self;
}
}
private final static class InternalAvatarState {
private final byte[] avatar; private final byte[] avatar;
private final LoadingState loadingState; private final LoadingState loadingState;
public AvatarState(@Nullable byte[] avatar, @NonNull LoadingState loadingState) { public InternalAvatarState(@Nullable byte[] avatar, @NonNull LoadingState loadingState) {
this.avatar = avatar; this.avatar = avatar;
this.loadingState = loadingState; this.loadingState = loadingState;
} }
private static @NonNull AvatarState none() { private static @NonNull InternalAvatarState none() {
return new AvatarState(null, LoadingState.LOADED); return new InternalAvatarState(null, LoadingState.LOADED);
} }
private static @NonNull AvatarState loaded(@Nullable byte[] avatar) { private static @NonNull InternalAvatarState loaded(@Nullable byte[] avatar) {
return new AvatarState(avatar, LoadingState.LOADED); return new InternalAvatarState(avatar, LoadingState.LOADED);
} }
private static @NonNull AvatarState loading(@Nullable byte[] avatar) { private static @NonNull InternalAvatarState loading(@Nullable byte[] avatar) {
return new AvatarState(avatar, LoadingState.LOADING); return new InternalAvatarState(avatar, LoadingState.LOADING);
} }
public @Nullable byte[] getAvatar() { public @Nullable byte[] getAvatar() {

Wyświetl plik

@ -111,6 +111,7 @@
android:layout_marginEnd="@dimen/dsl_settings_gutter" android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:clipChildren="false" android:clipChildren="false"
android:clipToPadding="false" android:clipToPadding="false"
android:paddingBottom="80dp"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"

Wyświetl plik

@ -46,6 +46,21 @@
app:layout_constraintTop_toTopOf="@+id/manage_profile_avatar_background" app:layout_constraintTop_toTopOf="@+id/manage_profile_avatar_background"
app:srcCompat="@drawable/ic_profile_outline_40" /> app:srcCompat="@drawable/ic_profile_outline_40" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/manage_profile_avatar_initials"
android:layout_width="0dp"
android:layout_height="0dp"
android:fontFamily="sans-serif-medium"
android:gravity="center"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/manage_profile_avatar_background"
app:layout_constraintEnd_toEndOf="@id/manage_profile_avatar_background"
app:layout_constraintStart_toStartOf="@id/manage_profile_avatar_background"
app:layout_constraintTop_toTopOf="@id/manage_profile_avatar_background"
tools:ignore="SpUsage"
tools:text="AF"
tools:visibility="visible" />
<ImageView <ImageView
android:id="@+id/manage_profile_avatar" android:id="@+id/manage_profile_avatar"
android:layout_width="0dp" android:layout_width="0dp"

Wyświetl plik

@ -100,6 +100,7 @@
android:layout_marginEnd="@dimen/dsl_settings_gutter" android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:clipChildren="false" android:clipChildren="false"
android:clipToPadding="false" android:clipToPadding="false"
android:paddingBottom="80dp"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"

Wyświetl plik

@ -99,6 +99,7 @@
android:layout_marginEnd="@dimen/dsl_settings_gutter" android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:clipChildren="false" android:clipChildren="false"
android:clipToPadding="false" android:clipToPadding="false"
android:paddingBottom="80dp"
android:visibility="gone" android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"

Wyświetl plik

@ -76,6 +76,7 @@
android:layout_marginEnd="@dimen/dsl_settings_gutter" android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:clipChildren="false" android:clipChildren="false"
android:clipToPadding="false" android:clipToPadding="false"
android:paddingBottom="80dp"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"