diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index aeca9fdd7d..39cc12acc7 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -24,8 +24,8 @@ jobs: - name: Validate Gradle Wrapper uses: gradle/wrapper-validation-action@v1 - - name: Remove Android S - run: $ANDROID_HOME/tools/bin/sdkmanager --uninstall "platforms;android-S" + - name: Remove Android 31 (S) + run: $ANDROID_HOME/tools/bin/sdkmanager --uninstall "platforms;android-31" - name: Build with Gradle run: ./gradlew qa diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarRenderer.kt index 937db845ce..a3c4062330 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarRenderer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarRenderer.kt @@ -3,13 +3,11 @@ package org.thoughtcrime.securesms.avatar import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas -import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.Drawable import android.net.Uri import androidx.appcompat.content.res.AppCompatResources import com.airbnb.lottie.SimpleColorFilter -import com.amulyakhare.textdrawable.TextDrawable import org.signal.core.util.concurrent.SignalExecutors import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mms.PartAuthority @@ -28,7 +26,7 @@ import javax.annotation.meta.Exhaustive */ object AvatarRenderer { - private val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS + val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS fun getTypeface(context: Context): Typeface { return Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf") @@ -50,30 +48,8 @@ object AvatarRenderer { avatar: Avatar.Text, inverted: Boolean = false, size: Int = DIMENSIONS, - isRect: Boolean = true ): Drawable { - val typeface = getTypeface(context) - 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) - } + return TextAvatarDrawable(context, avatar, inverted, size) } private fun renderVector(context: Context, avatar: Avatar.Vector, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/TextAvatarDrawable.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/TextAvatarDrawable.kt new file mode 100644 index 0000000000..07bbcf3e83 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/TextAvatarDrawable.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt index 519c92cd93..ee0ba36a77 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt @@ -40,6 +40,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) { companion object { 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_CLEAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_CLEAR" private const val REQUEST_CODE_SELECT_IMAGE = 1 } @@ -94,15 +95,26 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) { photoButton.setOnIconClickedListener { openGallery() } textButton.setOnIconClickedListener { openTextEditor(null) } saveButton.setOnClickListener { v -> - viewModel.save { - setFragmentResult( - REQUEST_KEY_SELECT_AVATAR, - Bundle().apply { - putParcelable(SELECT_AVATAR_MEDIA, it) - } - ) - Navigation.findNavController(v).popBackStack() - } + viewModel.save( + { + setFragmentResult( + REQUEST_KEY_SELECT_AVATAR, + Bundle().apply { + putParcelable(SELECT_AVATAR_MEDIA, it) + } + ) + Navigation.findNavController(v).popBackStack() + }, + { + setFragmentResult( + REQUEST_KEY_SELECT_AVATAR, + Bundle().apply { + putBoolean(SELECT_AVATAR_CLEAR, true) + } + ) + Navigation.findNavController(v).popBackStack() + } + ) } clearButton.setOnClickListener { viewModel.clear() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerItem.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerItem.kt index 65e27cbfac..f7ab75ff8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerItem.kt @@ -6,7 +6,6 @@ import android.widget.ImageView import android.widget.TextView import androidx.appcompat.content.res.AppCompatResources import androidx.core.view.setPadding -import androidx.core.widget.addTextChangedListener import com.airbnb.lottie.SimpleColorFilter import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.avatar.Avatar @@ -58,17 +57,14 @@ object AvatarPickerItem { init { textView.typeface = AvatarRenderer.getTypeface(context) textView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> - updateTextSize() + updateAndApplyText(textView.text.toString()) } - textView.addTextChangedListener( - afterTextChanged = { - updateTextSize() - } - ) } - private fun updateTextSize() { - textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, Avatars.getTextSizeForLength(context, textView.text.toString(), textView.measuredWidth * 0.8f, textView.measuredHeight * 0.45f)) + private fun updateAndApplyText(text: String) { + 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) { @@ -112,9 +108,7 @@ object AvatarPickerItem { is Avatar.Text -> { textView.visible = true - if (textView.text.toString() != model.avatar.text) { - textView.text = model.avatar.text - } + updateAndApplyText(model.avatar.text) imageView.setImageDrawable(null) imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor) diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerRepository.kt index 36443a1f52..3b26c5cc75 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerRepository.kt @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.avatar.Avatar import org.thoughtcrime.securesms.avatar.AvatarPickerStorage import org.thoughtcrime.securesms.avatar.AvatarRenderer import org.thoughtcrime.securesms.avatar.Avatars +import org.thoughtcrime.securesms.conversation.colors.AvatarColor import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.mediasend.Media @@ -61,10 +62,10 @@ class AvatarPickerRepository(context: Context) { ) } catch (e: IOException) { Log.w(TAG, "Failed to read group avatar!") - getDefaultAvatarForGroup() + getDefaultAvatarForGroup(recipient.avatarColor) } } else { - getDefaultAvatarForGroup() + getDefaultAvatarForGroup(recipient.avatarColor) } } @@ -155,12 +156,25 @@ class AvatarPickerRepository(context: Context) { return if (initials.isNullOrBlank()) { Avatar.getDefaultForSelf() } 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 { - return Avatar.getDefaultForGroup() + fun getDefaultAvatarForGroup(groupId: GroupId): Avatar { + 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerState.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerState.kt index e58d166e8d..6fdfd6ff5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerState.kt @@ -6,5 +6,6 @@ data class AvatarPickerState( val currentAvatar: Avatar? = null, val selectableAvatars: List = listOf(), val canSave: Boolean = false, - val canClear: Boolean = false + val canClear: Boolean = false, + val isCleared: Boolean = false ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerViewModel.kt index 436a0f3d14..9f4317a690 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerViewModel.kt @@ -27,6 +27,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor fun delete(avatar: Avatar) { repository.delete(avatar) { + refreshAvatar() refreshSelectableAvatars() } } @@ -34,22 +35,26 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor fun clear() { store.update { 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) { - val avatar = store.state.currentAvatar ?: throw AssertionError() - persistAndCreateMedia(avatar, onSaved) + fun save(onSaved: (Media) -> Unit, onCleared: () -> Unit) { + if (store.state.isCleared) { + onCleared() + } else { + val avatar = store.state.currentAvatar ?: throw AssertionError() + persistAndCreateMedia(avatar, onSaved) + } } 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) { 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() } } @@ -57,7 +62,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor 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) } + store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) } refreshSelectableAvatars() } } @@ -66,7 +71,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor protected fun refreshAvatar() { disposables.add( 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() { 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> = repository.getPersistedAvatarsForGroup(groupId) override fun getDefaultAvatars(): Single> = repository.getDefaultAvatarsForGroup() @@ -161,11 +166,11 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor return if (initialAvatar != null) { Single.just(initialAvatar) } else { - Single.just(getDefaultAvatarFromRepository()) + Single.fromCallable { getDefaultAvatarFromRepository() } } } - override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup() + override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(null) override fun getPersistedAvatars(): Single> = Single.just(listOf()) override fun getDefaultAvatars(): Single> = repository.getDefaultAvatarsForGroup() override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) = onPersisted(avatar) diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationFragment.kt index aae39d6a47..15aa885833 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationFragment.kt @@ -41,6 +41,8 @@ class TextAvatarCreationFragment : Fragment(R.layout.text_avatar_creation_fragme private val withRecyclerSet = ConstraintSet() private val withoutRecyclerSet = ConstraintSet() + private var hasBoundFromViewModel: Boolean = false + private fun createFactory(): TextAvatarCreationViewModel.Factory { val args = TextAvatarCreationFragmentArgs.fromBundle(requireArguments()) val textBundle = args.textAvatar @@ -83,17 +85,25 @@ class TextAvatarCreationFragment : Fragment(R.layout.text_avatar_creation_fragme EditTextUtil.setCursorColor(textInput, state.currentAvatar.color.foregroundColor) val hadText = textInput.length() > 0 + val selectionStart = textInput.selectionStart + val selectionEnd = textInput.selectionEnd + viewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false)) - if (!hadText) { - textInput.setSelection(textInput.length()) + textInput.post { + if (!hadText) { + textInput.setSelection(textInput.length()) + } else { + textInput.setSelection(selectionStart, selectionEnd) + } } adapter.submitList(state.colors().map { AvatarColorItem.Model(it) }) + hasBoundFromViewModel = true } EditTextUtil.addGraphemeClusterLimitFilter(textInput, 3) textInput.doAfterTextChanged { - if (it != null) { + if (it != null && hasBoundFromViewModel) { viewModel.setText(it.toString()) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationViewModel.kt index 891afd51c0..c988b0f4f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationViewModel.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.avatar.text import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import org.thoughtcrime.securesms.avatar.Avatar @@ -11,14 +12,20 @@ class TextAvatarCreationViewModel(initialText: Avatar.Text) : ViewModel() { private val store = Store(TextAvatarCreationState(initialText)) - val state: LiveData = store.stateLiveData + val state: LiveData = Transformations.distinctUntilChanged(store.stateLiveData) fun setColor(colorPair: Avatars.ColorPair) { store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) } } 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java index 35f4d44649..6331c5af2a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java @@ -54,7 +54,7 @@ public class GeneratedContactPhoto implements FallbackContactPhoto { if (!TextUtils.isEmpty(character)) { Avatars.ForegroundColor foregroundColor = Avatars.getForegroundColor(color); 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)); background.setColorFilter(new SimpleColorFilter(inverted ? foregroundColor.getColorInt() : color.colorInt())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java index 5f45876206..c03b23c582 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java @@ -170,6 +170,12 @@ public class AddGroupDetailsFragment extends LoggingFragment { } 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 DecryptableStreamUriLoader.DecryptableUri decryptableUri = new DecryptableStreamUriLoader.DecryptableUri(result.getUri()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java index be3e1b7c14..ded430643d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java @@ -34,6 +34,8 @@ import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.avatar.Avatars; 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.ParcelableGroupId; import org.thoughtcrime.securesms.mediasend.Media; @@ -104,8 +106,14 @@ public class EditProfileFragment extends LoggingFragment { initializeProfileName(); getParentFragmentManager().setFragmentResultListener(AvatarPickerFragment.REQUEST_KEY_SELECT_AVATAR, getViewLifecycleOwner(), (key, bundle) -> { - Media media = bundle.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA); - handleMediaFromResult(media); + if (bundle.getBoolean(AvatarPickerFragment.SELECT_AVATAR_CLEAR)) { + viewModel.setAvatarMedia(null); + viewModel.setAvatar(null); + avatar.setImageDrawable(null); + } else { + Media media = bundle.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA); + handleMediaFromResult(media); + } }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java index cb0fe6c873..979473f7c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java @@ -1,8 +1,9 @@ package org.thoughtcrime.securesms.profiles.manage; -import android.content.Intent; import android.graphics.drawable.Drawable; import android.os.Bundle; +import android.text.TextUtils; +import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -18,28 +19,28 @@ import androidx.core.content.res.ResourcesCompat; import androidx.lifecycle.ViewModelProviders; import androidx.navigation.Navigation; +import com.airbnb.lottie.SimpleColorFilter; import com.bumptech.glide.Glide; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.avatar.Avatars; import org.thoughtcrime.securesms.avatar.picker.AvatarPickerFragment; import org.thoughtcrime.securesms.components.emoji.EmojiUtil; -import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.profiles.manage.ManageProfileViewModel.AvatarState; +import org.thoughtcrime.securesms.util.NameUtil; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; -import static android.app.Activity.RESULT_OK; - public class ManageProfileFragment extends LoggingFragment { private static final String TAG = Log.tag(ManageProfileFragment.class); private Toolbar toolbar; private ImageView avatarView; - private View avatarPlaceholderView; + private ImageView avatarPlaceholderView; private TextView profileNameView; private View profileNameContainer; private TextView usernameView; @@ -48,6 +49,8 @@ public class ManageProfileFragment extends LoggingFragment { private View aboutContainer; private ImageView aboutEmojiView; private AlertDialog avatarProgress; + private TextView avatarInitials; + private ImageView avatarBackground; private ManageProfileViewModel viewModel; @@ -68,6 +71,8 @@ public class ManageProfileFragment extends LoggingFragment { this.aboutView = view.findViewById(R.id.manage_profile_about); this.aboutContainer = view.findViewById(R.id.manage_profile_about_container); 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(); @@ -87,8 +92,18 @@ public class ManageProfileFragment extends LoggingFragment { }); getParentFragmentManager().setFragmentResultListener(AvatarPickerFragment.REQUEST_KEY_SELECT_AVATAR, getViewLifecycleOwner(), (key, bundle) -> { - Media result = bundle.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA); - viewModel.onAvatarSelected(requireContext(), result); + if (bundle.getBoolean(AvatarPickerFragment.SELECT_AVATAR_CLEAR)) { + 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) { if (avatarState.getAvatar() == 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 { avatarPlaceholderView.setVisibility(View.GONE); + avatarInitials.setVisibility(View.GONE); + Glide.with(this) .load(avatarState.getAvatar()) .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) { if (profileName == null || profileName.isEmpty()) { profileNameView.setText(R.string.ManageProfileFragment_profile_name); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileViewModel.java index ce01fac0dc..8c984009a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileViewModel.java @@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import org.whispersystems.signalservice.api.util.StreamDetails; import java.io.IOException; @@ -32,26 +33,28 @@ class ManageProfileViewModel extends ViewModel { private static final String TAG = Log.tag(ManageProfileViewModel.class); - private final MutableLiveData avatar; - private final MutableLiveData profileName; - private final MutableLiveData username; - private final MutableLiveData about; - private final MutableLiveData aboutEmoji; - private final SingleLiveEvent events; - private final RecipientForeverObserver observer; - private final ManageProfileRepository repository; + private final MutableLiveData internalAvatarState; + private final MutableLiveData profileName; + private final MutableLiveData username; + private final MutableLiveData about; + private final MutableLiveData aboutEmoji; + private final LiveData avatarState; + private final SingleLiveEvent events; + private final RecipientForeverObserver observer; + private final ManageProfileRepository repository; private byte[] previousAvatar; public ManageProfileViewModel() { - this.avatar = new MutableLiveData<>(); - this.profileName = new MutableLiveData<>(); - this.username = new MutableLiveData<>(); - this.about = new MutableLiveData<>(); - this.aboutEmoji = new MutableLiveData<>(); - this.events = new SingleLiveEvent<>(); - this.repository = new ManageProfileRepository(); - this.observer = this::onRecipientChanged; + this.internalAvatarState = new MutableLiveData<>(); + this.profileName = new MutableLiveData<>(); + this.username = new MutableLiveData<>(); + this.about = new MutableLiveData<>(); + this.aboutEmoji = new MutableLiveData<>(); + this.events = new SingleLiveEvent<>(); + this.repository = new ManageProfileRepository(); + this.observer = this::onRecipientChanged; + this.avatarState = LiveDataUtil.combineLatest(Recipient.self().live().getLiveData(), internalAvatarState, (self, state) -> new AvatarState(state, self)); SignalExecutors.BOUNDED.execute(() -> { onRecipientChanged(Recipient.self().fresh()); @@ -59,13 +62,13 @@ class ManageProfileViewModel extends ViewModel { StreamDetails details = AvatarHelper.getSelfProfileAvatarStream(ApplicationDependencies.getApplication()); if (details != null) { try { - avatar.postValue(AvatarState.loaded(StreamUtil.readFully(details.getStream()))); + internalAvatarState.postValue(InternalAvatarState.loaded(StreamUtil.readFully(details.getStream()))); } catch (IOException e) { Log.w(TAG, "Failed to read avatar!"); - avatar.postValue(AvatarState.none()); + internalAvatarState.postValue(InternalAvatarState.none()); } } else { - avatar.postValue(AvatarState.none()); + internalAvatarState.postValue(InternalAvatarState.none()); } ApplicationDependencies.getJobManager().add(RetrieveProfileJob.forRecipient(Recipient.self().getId())); @@ -75,7 +78,7 @@ class ManageProfileViewModel extends ViewModel { } public @NonNull LiveData getAvatar() { - return avatar; + return avatarState; } public @NonNull LiveData getProfileName() { @@ -103,18 +106,18 @@ class ManageProfileViewModel extends ViewModel { } 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) { - avatar.postValue(AvatarState.loading(null)); + internalAvatarState.postValue(InternalAvatarState.loading(null)); repository.clearAvatar(context, result -> { switch (result) { case SUCCESS: - avatar.postValue(AvatarState.loaded(null)); + internalAvatarState.postValue(InternalAvatarState.loaded(null)); previousAvatar = null; break; case FAILURE_NETWORK: - avatar.postValue(AvatarState.loaded(previousAvatar)); + internalAvatarState.postValue(InternalAvatarState.loaded(previousAvatar)); events.postValue(Event.AVATAR_NETWORK_FAILURE); break; } @@ -125,16 +128,16 @@ class ManageProfileViewModel extends ViewModel { InputStream stream = BlobProvider.getInstance().getStream(context, media.getUri()); byte[] data = StreamUtil.readFully(stream); - avatar.postValue(AvatarState.loading(data)); + internalAvatarState.postValue(InternalAvatarState.loading(data)); repository.setAvatar(context, data, media.getMimeType(), result -> { switch (result) { case SUCCESS: - avatar.postValue(AvatarState.loaded(data)); + internalAvatarState.postValue(InternalAvatarState.loaded(data)); previousAvatar = data; break; case FAILURE_NETWORK: - avatar.postValue(AvatarState.loaded(previousAvatar)); + internalAvatarState.postValue(InternalAvatarState.loaded(previousAvatar)); events.postValue(Event.AVATAR_NETWORK_FAILURE); break; } @@ -148,7 +151,7 @@ class ManageProfileViewModel extends ViewModel { } public boolean canRemoveAvatar() { - return avatar.getValue() != null; + return internalAvatarState.getValue() != null; } private void onRecipientChanged(@NonNull Recipient recipient) { @@ -163,25 +166,49 @@ class ManageProfileViewModel extends ViewModel { 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 LoadingState loadingState; - public AvatarState(@Nullable byte[] avatar, @NonNull LoadingState loadingState) { + public InternalAvatarState(@Nullable byte[] avatar, @NonNull LoadingState loadingState) { this.avatar = avatar; this.loadingState = loadingState; } - private static @NonNull AvatarState none() { - return new AvatarState(null, LoadingState.LOADED); + private static @NonNull InternalAvatarState none() { + return new InternalAvatarState(null, LoadingState.LOADED); } - private static @NonNull AvatarState loaded(@Nullable byte[] avatar) { - return new AvatarState(avatar, LoadingState.LOADED); + private static @NonNull InternalAvatarState loaded(@Nullable byte[] avatar) { + return new InternalAvatarState(avatar, LoadingState.LOADED); } - private static @NonNull AvatarState loading(@Nullable byte[] avatar) { - return new AvatarState(avatar, LoadingState.LOADING); + private static @NonNull InternalAvatarState loading(@Nullable byte[] avatar) { + return new InternalAvatarState(avatar, LoadingState.LOADING); } public @Nullable byte[] getAvatar() { diff --git a/app/src/main/res/layout/avatar_picker_fragment.xml b/app/src/main/res/layout/avatar_picker_fragment.xml index eed83a10ca..bfb2f7d7bb 100644 --- a/app/src/main/res/layout/avatar_picker_fragment.xml +++ b/app/src/main/res/layout/avatar_picker_fragment.xml @@ -111,6 +111,7 @@ android:layout_marginEnd="@dimen/dsl_settings_gutter" android:clipChildren="false" android:clipToPadding="false" + android:paddingBottom="80dp" app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/manage_profile_fragment.xml b/app/src/main/res/layout/manage_profile_fragment.xml index 4dd01ac0a1..2648a0b628 100644 --- a/app/src/main/res/layout/manage_profile_fragment.xml +++ b/app/src/main/res/layout/manage_profile_fragment.xml @@ -46,6 +46,21 @@ app:layout_constraintTop_toTopOf="@+id/manage_profile_avatar_background" app:srcCompat="@drawable/ic_profile_outline_40" /> + +