diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeRepository.kt new file mode 100644 index 0000000000..dced92c079 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeRepository.kt @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.badges + +import android.content.Context +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.ProfileUtil + +class BadgeRepository(context: Context) { + + private val context = context.applicationContext + + fun setVisibilityForAllBadges(displayBadgesOnProfile: Boolean): Completable = Completable.fromAction { + val badges = Recipient.self().badges.map { it.copy(visible = displayBadgesOnProfile) } + ProfileUtil.uploadProfileWithBadges(context, badges) + + val recipientDatabase: RecipientDatabase = DatabaseFactory.getRecipientDatabase(context) + recipientDatabase.setBadges(Recipient.self().id, badges) + }.subscribeOn(Schedulers.io()) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/Badges.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/Badges.kt new file mode 100644 index 0000000000..02c9fac12b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/Badges.kt @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.badges + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.annotation.ColorInt +import androidx.annotation.Px +import androidx.core.graphics.withScale +import androidx.recyclerview.widget.RecyclerView +import com.airbnb.lottie.SimpleColorFilter +import com.google.android.flexbox.AlignItems +import com.google.android.flexbox.FlexDirection +import com.google.android.flexbox.FlexboxLayoutManager +import com.google.android.flexbox.JustifyContent +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.badges.models.BadgeAnimator +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.util.customizeOnDraw + +object Badges { + fun Drawable.insetWithOutline( + @Px outlineWidth: Float, + @ColorInt outlineColor: Int + ): Drawable { + val clone = mutate().constantState?.newDrawable()?.mutate() + clone?.colorFilter = SimpleColorFilter(outlineColor) + + return customizeOnDraw { wrapped, canvas -> + clone?.bounds = wrapped.bounds + clone?.draw(canvas) + + val scale = 1 - ((outlineWidth * 2) / canvas.width) + + canvas.withScale(x = scale, y = scale, canvas.width / 2f, canvas.height / 2f) { + wrapped.draw(canvas) + } + } + } + + fun Drawable.selectable( + @Px outlineWidth: Float, + @ColorInt outlineColor: Int, + @ColorInt gapColor: Int, + animator: BadgeAnimator + ): Drawable { + val outline = mutate().constantState?.newDrawable()?.mutate() + outline?.colorFilter = SimpleColorFilter(outlineColor) + + val gap = mutate().constantState?.newDrawable()?.mutate() + gap?.colorFilter = SimpleColorFilter(gapColor) + + return customizeOnDraw { wrapped, canvas -> + outline?.bounds = wrapped.bounds + gap?.bounds = wrapped.bounds + + outline?.draw(canvas) + + val scale = 1 - ((outlineWidth * 2) / wrapped.bounds.width()) + val interpolatedScale = scale + (1f - scale) * animator.getFraction() + + canvas.withScale(x = interpolatedScale, y = interpolatedScale, wrapped.bounds.width() / 2f, wrapped.bounds.height() / 2f) { + gap?.draw(canvas) + + canvas.withScale(x = interpolatedScale, y = interpolatedScale, wrapped.bounds.width() / 2f, wrapped.bounds.height() / 2f) { + wrapped.draw(canvas) + } + } + + if (animator.shouldInvalidate()) { + invalidateSelf() + } + } + } + + fun DSLConfiguration.displayBadges(badges: List, selectedBadge: Badge? = null) { + badges + .map { Badge.Model(it, it == selectedBadge) } + .forEach { customPref(it) } + + val empties = (4 - (badges.size % 4)) % 4 + repeat(empties) { + customPref(Badge.EmptyModel()) + } + } + + fun createLayoutManagerForGridWithBadges(context: Context): RecyclerView.LayoutManager { + val layoutManager = FlexboxLayoutManager(context) + + layoutManager.flexDirection = FlexDirection.ROW + layoutManager.alignItems = AlignItems.CENTER + layoutManager.justifyContent = JustifyContent.CENTER + + return layoutManager + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt new file mode 100644 index 0000000000..1a8f1e45ef --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt @@ -0,0 +1,221 @@ +package org.thoughtcrime.securesms.badges.models + +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.bumptech.glide.load.Key +import com.bumptech.glide.request.target.CustomViewTarget +import com.bumptech.glide.request.transition.Transition +import org.signal.core.util.DimensionUnit +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.badges.Badges.selectable +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.MappingViewHolder +import java.security.MessageDigest + +typealias OnBadgeClicked = (Badge, Boolean) -> Unit + +/** + * A Badge that can be collected and displayed by a user. + */ +data class Badge( + val id: String, + val category: Category, + val imageUrl: Uri, + val name: String, + val description: String, + val expirationTimestamp: Long, + val visible: Boolean +) : Parcelable, Key { + + constructor(parcel: Parcel) : this( + requireNotNull(parcel.readString()), + Category.fromCode(requireNotNull(parcel.readString())), + requireNotNull(parcel.readParcelable(Uri::class.java.classLoader)), + requireNotNull(parcel.readString()), + requireNotNull(parcel.readString()), + parcel.readLong(), + parcel.readByte() == 1.toByte() + ) + + override fun describeContents(): Int { + return 0 + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(id) + parcel.writeString(category.code) + parcel.writeParcelable(imageUrl, flags) + parcel.writeString(name) + parcel.writeString(description) + parcel.writeLong(expirationTimestamp) + parcel.writeByte(if (visible) 1 else 0) + } + + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + messageDigest.update(id.toByteArray(Key.CHARSET)) + } + + fun resolveDescription(shortName: String): String { + return description.replace("{short_name}", shortName) + } + + class EmptyModel : PreferenceModel() { + override fun areItemsTheSame(newItem: EmptyModel): Boolean = true + } + + class EmptyViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val name: TextView = itemView.findViewById(R.id.name) + + init { + itemView.isEnabled = false + itemView.isFocusable = false + itemView.isClickable = false + itemView.visibility = View.INVISIBLE + + name.text = " " + } + + override fun bind(model: EmptyModel) = Unit + } + + class Model( + val badge: Badge, + val isSelected: Boolean = false + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean { + return newItem.badge.id == badge.id + } + + override fun areContentsTheSame(newItem: Model): Boolean { + return super.areContentsTheSame(newItem) && badge == newItem.badge && isSelected == newItem.isSelected + } + + override fun getChangePayload(newItem: Model): Any? { + return if (badge == newItem.badge && isSelected != newItem.isSelected) { + SELECTION_CHANGED + } else { + null + } + } + } + + class ViewHolder(itemView: View, private val onBadgeClicked: OnBadgeClicked) : MappingViewHolder(itemView) { + + private val badge: ImageView = itemView.findViewById(R.id.badge) + private val name: TextView = itemView.findViewById(R.id.name) + private val target = Target(badge) + + override fun bind(model: Model) { + itemView.setOnClickListener { + onBadgeClicked(model.badge, model.isSelected) + } + + if (payload.isNotEmpty()) { + if (model.isSelected) { + target.animateToStart() + } else { + target.animateToEnd() + } + return + } + + GlideApp.with(badge) + .load(model.badge) + .into(target) + + if (model.isSelected) { + target.setAnimationToStart() + } else { + target.setAnimationToEnd() + } + + name.text = model.badge.name + } + } + + enum class Category(val code: String) { + Donor("donor"), + Other("other"), + Testing("testing"); // Will be removed before final release + + companion object { + fun fromCode(code: String): Category { + return when (code) { + "donor" -> Donor + "testing" -> Testing + else -> Other + } + } + } + } + + private class Target(view: ImageView) : CustomViewTarget(view) { + + private val animator: BadgeAnimator = BadgeAnimator() + + override fun onLoadFailed(errorDrawable: Drawable?) { + view.setImageDrawable(errorDrawable) + } + + override fun onResourceReady(resource: Drawable, transition: Transition?) { + val drawable = resource.selectable( + DimensionUnit.DP.toPixels(2.5f), + ContextCompat.getColor(view.context, R.color.signal_inverse_primary), + ContextCompat.getColor(view.context, R.color.signal_background_primary), + animator + ) + + view.setImageDrawable(drawable) + } + + override fun onResourceCleared(placeholder: Drawable?) { + view.setImageDrawable(placeholder) + } + + fun setAnimationToStart() { + animator.setState(BadgeAnimator.State.START) + view.drawable?.invalidateSelf() + } + + fun setAnimationToEnd() { + animator.setState(BadgeAnimator.State.END) + view.drawable?.invalidateSelf() + } + + fun animateToStart() { + animator.setState(BadgeAnimator.State.REVERSE) + view.drawable?.invalidateSelf() + } + + fun animateToEnd() { + animator.setState(BadgeAnimator.State.FORWARD) + view.drawable?.invalidateSelf() + } + } + + companion object CREATOR : Parcelable.Creator { + private val SELECTION_CHANGED = Any() + + override fun createFromParcel(parcel: Parcel): Badge { + return Badge(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + + fun register(mappingAdapter: MappingAdapter, onBadgeClicked: OnBadgeClicked) { + mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onBadgeClicked) }, R.layout.badge_preference_view)) + mappingAdapter.registerFactory(EmptyModel::class.java, MappingAdapter.LayoutFactory({ EmptyViewHolder(it) }, R.layout.badge_preference_view)) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgeAnimator.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgeAnimator.kt new file mode 100644 index 0000000000..9279758610 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgeAnimator.kt @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.badges.models + +import org.thoughtcrime.securesms.util.Util + +class BadgeAnimator { + + val duration = 250L + + var state: State = State.START + private set + + private var startTime: Long = 0L + + fun getFraction(): Float { + return when (state) { + State.START -> 0f + State.END -> 1f + State.FORWARD -> Util.clamp((System.currentTimeMillis() - startTime) / duration.toFloat(), 0f, 1f) + State.REVERSE -> 1f - Util.clamp((System.currentTimeMillis() - startTime) / duration.toFloat(), 0f, 1f) + } + } + + fun setState(newState: State) { + shouldInvalidate() + + if (state == newState) { + return + } + + if (newState == State.END || newState == State.START) { + state = newState + startTime = 0L + return + } + + if (state == State.START && newState == State.REVERSE) { + return + } + + if (state == State.END && newState == State.FORWARD) { + return + } + + if (state == State.START && newState == State.FORWARD) { + state = State.FORWARD + startTime = System.currentTimeMillis() + return + } + + if (state == State.END && newState == State.REVERSE) { + state = State.REVERSE + startTime = System.currentTimeMillis() + return + } + + if (state == State.FORWARD && newState == State.REVERSE) { + val elapsed = System.currentTimeMillis() - startTime + val delta = duration - elapsed + startTime -= delta + state = State.REVERSE + return + } + + if (state == State.REVERSE && newState == State.FORWARD) { + val elapsed = System.currentTimeMillis() - startTime + val delta = duration - elapsed + startTime -= delta + state = State.FORWARD + return + } + } + + fun shouldInvalidate(): Boolean { + if (state == State.START || state == State.END) { + return false + } + + if (state == State.FORWARD && getFraction() == 1f) { + state = State.END + return false + } + + if (state == State.REVERSE && getFraction() == 0f) { + state = State.START + return false + } + + return true + } + + enum class State { + START, + FORWARD, + REVERSE, + END + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/FeaturedBadgePreview.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/FeaturedBadgePreview.kt new file mode 100644 index 0000000000..1c0f6bb7ee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/FeaturedBadgePreview.kt @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.badges.models + +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.ImageView +import androidx.core.content.ContextCompat +import com.bumptech.glide.request.target.CustomViewTarget +import com.bumptech.glide.request.transition.Transition +import org.signal.core.util.DimensionUnit +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.badges.Badges.insetWithOutline +import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.MappingViewHolder + +object FeaturedBadgePreview { + + fun register(mappingAdapter: MappingAdapter) { + mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference)) + } + + data class Model(val badge: Badge?) : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean { + return newItem.badge?.id == badge?.id + } + + override fun areContentsTheSame(newItem: Model): Boolean { + return super.areContentsTheSame(newItem) && badge == newItem.badge + } + } + + class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val avatar: AvatarImageView = itemView.findViewById(R.id.avatar) + private val badge: ImageView = itemView.findViewById(R.id.badge) + private val target: Target = Target(badge) + + override fun bind(model: Model) { + avatar.setRecipient(Recipient.self()) + avatar.disableQuickContact() + + if (model.badge != null) { + GlideApp.with(badge) + .load(model.badge) + .into(target) + } else { + GlideApp.with(badge).clear(badge) + badge.setImageDrawable(null) + } + } + } + + private class Target(view: ImageView) : CustomViewTarget(view) { + override fun onLoadFailed(errorDrawable: Drawable?) { + view.setImageDrawable(errorDrawable) + } + + override fun onResourceReady(resource: Drawable, transition: Transition?) { + view.setImageDrawable( + resource.insetWithOutline( + DimensionUnit.DP.toPixels(2.5f), + ContextCompat.getColor(view.context, R.color.signal_background_primary) + ) + ) + } + + override fun onResourceCleared(placeholder: Drawable?) { + view.setImageDrawable(placeholder) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/LargeBadge.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/LargeBadge.kt new file mode 100644 index 0000000000..0f36eee284 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/LargeBadge.kt @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.badges.models + +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.MappingModel +import org.thoughtcrime.securesms.util.MappingViewHolder + +data class LargeBadge( + val badge: Badge +) { + + class Model(val largeBadge: LargeBadge, val shortName: String) : MappingModel { + override fun areItemsTheSame(newItem: Model): Boolean { + return newItem.largeBadge.badge.id == largeBadge.badge.id + } + + override fun areContentsTheSame(newItem: Model): Boolean { + return newItem.largeBadge == largeBadge && newItem.shortName == shortName + } + } + + class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val badge: ImageView = itemView.findViewById(R.id.badge) + private val name: TextView = itemView.findViewById(R.id.name) + private val description: TextView = itemView.findViewById(R.id.description) + + override fun bind(model: Model) { + GlideApp.with(badge) + .load(model.largeBadge.badge) + .into(badge) + + name.text = model.largeBadge.badge.name + description.text = model.largeBadge.badge.resolveDescription(model.shortName) + } + } + + companion object { + fun register(mappingAdapter: MappingAdapter) { + mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page)) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt new file mode 100644 index 0000000000..3e6baea899 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.badges.self.featured + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.badges.BadgeRepository +import org.thoughtcrime.securesms.badges.Badges +import org.thoughtcrime.securesms.badges.Badges.displayBadges +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.badges.models.FeaturedBadgePreview +import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper +import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment +import org.thoughtcrime.securesms.components.settings.configure + +/** + * Fragment which allows user to select one of their badges to be their "Featured" badge. + */ +class SelectFeaturedBadgeFragment : DSLSettingsFragment( + titleId = R.string.BadgesOverviewFragment__featured_badge, + layoutId = R.layout.select_featured_badge_fragment, + layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges +) { + + private val viewModel: SelectFeaturedBadgeViewModel by viewModels(factoryProducer = { SelectFeaturedBadgeViewModel.Factory(BadgeRepository(requireContext())) }) + + private lateinit var scrollShadow: View + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + scrollShadow = view.findViewById(R.id.scroll_shadow) + + super.onViewCreated(view, savedInstanceState) + + val save: View = view.findViewById(R.id.save) + save.setOnClickListener { + viewModel.save() + findNavController().popBackStack() + } + } + + override fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper { + return ToolbarShadowAnimationHelper(scrollShadow) + } + + override fun bindAdapter(adapter: DSLSettingsAdapter) { + Badge.register(adapter) { badge, isSelected -> + if (!isSelected) { + viewModel.setSelectedBadge(badge) + } + } + + val previewView: View = requireView().findViewById(R.id.preview) + val previewViewHolder = FeaturedBadgePreview.ViewHolder(previewView) + + viewModel.state.observe(viewLifecycleOwner) { state -> + previewViewHolder.bind(FeaturedBadgePreview.Model(state.selectedBadge)) + adapter.submitList(getConfiguration(state).toMappingModelList()) + } + } + + private fun getConfiguration(state: SelectFeaturedBadgeState): DSLConfiguration { + return configure { + sectionHeaderPref(R.string.SelectFeaturedBadgeFragment__select_a_badge) + displayBadges(state.allUnlockedBadges, state.selectedBadge) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeState.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeState.kt new file mode 100644 index 0000000000..a224283c0e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeState.kt @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.badges.self.featured + +import org.thoughtcrime.securesms.badges.models.Badge + +data class SelectFeaturedBadgeState( + val selectedBadge: Badge? = null, + val allUnlockedBadges: List = listOf() +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeViewModel.kt new file mode 100644 index 0000000000..ad5436cc50 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeViewModel.kt @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.badges.self.featured + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.thoughtcrime.securesms.badges.BadgeRepository +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.livedata.Store + +class SelectFeaturedBadgeViewModel(repository: BadgeRepository) : ViewModel() { + + private val store = Store(SelectFeaturedBadgeState()) + + val state: LiveData = store.stateLiveData + + private val disposables = CompositeDisposable() + + init { + store.update(Recipient.live(Recipient.self().id).liveDataResolved) { recipient, state -> + state.copy(selectedBadge = recipient.badges.firstOrNull(), allUnlockedBadges = recipient.badges) + } + } + + fun setSelectedBadge(badge: Badge) { + store.update { it.copy(selectedBadge = badge) } + } + + fun save() { + // TODO "Persist selection to database" + } + + override fun onCleared() { + disposables.clear() + } + + class Factory(private val badgeRepository: BadgeRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return requireNotNull(modelClass.cast(SelectFeaturedBadgeViewModel(badgeRepository))) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewEvent.kt new file mode 100644 index 0000000000..3b2f56caac --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewEvent.kt @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.badges.self.overview + +enum class BadgesOverviewEvent { + FAILED_TO_UPDATE_PROFILE +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt new file mode 100644 index 0000000000..db4b812e2f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.badges.self.overview + +import android.widget.Toast +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.badges.BadgeRepository +import org.thoughtcrime.securesms.badges.Badges +import org.thoughtcrime.securesms.badges.Badges.displayBadges +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment +import org.thoughtcrime.securesms.components.settings.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.LifecycleDisposable + +/** + * Fragment to allow user to manage options related to the badges they've unlocked. + */ +class BadgesOverviewFragment : DSLSettingsFragment( + titleId = R.string.ManageProfileFragment_badges, + layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges +) { + + private val lifecycleDisposable = LifecycleDisposable() + private val viewModel: BadgesOverviewViewModel by viewModels(factoryProducer = { BadgesOverviewViewModel.Factory(BadgeRepository(requireContext())) }) + + override fun bindAdapter(adapter: DSLSettingsAdapter) { + Badge.register(adapter) { badge, _ -> + ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge) + } + + lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle) + + viewModel.state.observe(viewLifecycleOwner) { state -> + adapter.submitList(getConfiguration(state).toMappingModelList()) + } + + lifecycleDisposable.add( + viewModel.events.subscribe { event: BadgesOverviewEvent -> + when (event) { + BadgesOverviewEvent.FAILED_TO_UPDATE_PROFILE -> Toast.makeText(requireContext(), R.string.BadgesOverviewFragment__failed_to_update_profile, Toast.LENGTH_LONG).show() + } + } + ) + } + + private fun getConfiguration(state: BadgesOverviewState): DSLConfiguration { + return configure { + sectionHeaderPref(R.string.BadgesOverviewFragment__my_badges) + + displayBadges(state.allUnlockedBadges) + + switchPref( + title = DSLSettingsText.from(R.string.BadgesOverviewFragment__display_badges_on_profile), + isChecked = state.displayBadgesOnProfile, + onClick = { + viewModel.setDisplayBadgesOnProfile(!state.displayBadgesOnProfile) + } + ) + + clickPref( + title = DSLSettingsText.from(R.string.BadgesOverviewFragment__featured_badge), + summary = state.featuredBadge?.name?.let { DSLSettingsText.from(it) }, + isEnabled = state.stage == BadgesOverviewState.Stage.READY, + onClick = { + findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToFeaturedBadgeFragment()) + } + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewState.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewState.kt new file mode 100644 index 0000000000..0a61157ec6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewState.kt @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.badges.self.overview + +import org.thoughtcrime.securesms.badges.models.Badge + +data class BadgesOverviewState( + val stage: Stage = Stage.INIT, + val allUnlockedBadges: List = listOf(), + val featuredBadge: Badge? = null, + val displayBadgesOnProfile: Boolean = false +) { + enum class Stage { + INIT, + READY, + UPDATING + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt new file mode 100644 index 0000000000..c0db53bc81 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.badges.self.overview + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.subjects.PublishSubject +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.badges.BadgeRepository +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.livedata.Store + +private val TAG = Log.tag(BadgesOverviewViewModel::class.java) + +class BadgesOverviewViewModel(private val badgeRepository: BadgeRepository) : ViewModel() { + private val store = Store(BadgesOverviewState()) + private val eventSubject = PublishSubject.create() + + val state: LiveData = store.stateLiveData + val events: Observable = eventSubject + + val disposables = CompositeDisposable() + + init { + store.update(Recipient.live(Recipient.self().id).liveDataResolved) { recipient, state -> + state.copy( + stage = if (state.stage == BadgesOverviewState.Stage.INIT) BadgesOverviewState.Stage.READY else state.stage, + allUnlockedBadges = recipient.badges, + displayBadgesOnProfile = recipient.badges.firstOrNull()?.visible == true + ) + } + } + + fun setDisplayBadgesOnProfile(displayBadgesOnProfile: Boolean) { + disposables += badgeRepository.setVisibilityForAllBadges(displayBadgesOnProfile) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + store.update { it.copy(stage = BadgesOverviewState.Stage.READY) } + }, + { error -> + Log.e(TAG, "Failed to update visibility.", error) + store.update { it.copy(stage = BadgesOverviewState.Stage.READY) } + eventSubject.onNext(BadgesOverviewEvent.FAILED_TO_UPDATE_PROFILE) + } + ) + } + + override fun onCleared() { + disposables.clear() + } + + class Factory(private val badgeRepository: BadgeRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository))) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..697f43dadf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeBottomSheetDialogFragment.kt @@ -0,0 +1,104 @@ +package org.thoughtcrime.securesms.badges.view + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.button.MaterialButton +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.badges.BadgeRepository +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.badges.models.LargeBadge +import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.visible + +class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() { + + private val viewModel: ViewBadgeViewModel by viewModels(factoryProducer = { ViewBadgeViewModel.Factory(getStartBadge(), getRecipientId(), BadgeRepository(requireContext())) }) + + override val peekHeightPercentage: Float = 1f + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.view_badge_bottom_sheet_dialog_fragment, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val pager: ViewPager2 = view.findViewById(R.id.pager) + val tabs: TabLayout = view.findViewById(R.id.tab_layout) + val action: MaterialButton = view.findViewById(R.id.action) + + if (getRecipientId() == Recipient.self().id) { + action.visible = false + } + + val adapter = MappingAdapter() + + LargeBadge.register(adapter) + pager.adapter = adapter + + TabLayoutMediator(tabs, pager) { _, _ -> + }.attach() + + pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + viewModel.onPageSelected(position) + } + }) + + viewModel.state.observe(viewLifecycleOwner) { state -> + if (state.recipient == null || state.badgeLoadState == ViewBadgeState.LoadState.INIT) { + return@observe + } + + if (state.allBadgesVisibleOnProfile.isEmpty()) { + dismissAllowingStateLoss() + } + + adapter.submitList( + state.allBadgesVisibleOnProfile.map { + LargeBadge.Model(LargeBadge(it), state.recipient.getShortDisplayName(requireContext())) + } + ) { + val stateSelectedIndex = state.allBadgesVisibleOnProfile.indexOf(state.selectedBadge) + if (state.selectedBadge != null && pager.currentItem != stateSelectedIndex) { + pager.currentItem = stateSelectedIndex + } + } + } + } + + private fun getStartBadge(): Badge? = requireArguments().getParcelable(ARG_START_BADGE) + + private fun getRecipientId(): RecipientId = requireNotNull(requireArguments().getParcelable(ARG_RECIPIENT_ID)) + + companion object { + + private const val ARG_START_BADGE = "start_badge" + private const val ARG_RECIPIENT_ID = "recipient_id" + + @JvmStatic + fun show( + fragmentManager: FragmentManager, + recipientId: RecipientId, + startBadge: Badge? = null + ) { + ViewBadgeBottomSheetDialogFragment().apply { + arguments = Bundle().apply { + putParcelable(ARG_START_BADGE, startBadge) + putParcelable(ARG_RECIPIENT_ID, recipientId) + } + + show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeState.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeState.kt new file mode 100644 index 0000000000..819d9c5037 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeState.kt @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.badges.view + +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.recipients.Recipient + +data class ViewBadgeState( + val allBadgesVisibleOnProfile: List = listOf(), + val badgeLoadState: LoadState = LoadState.INIT, + val selectedBadge: Badge? = null, + val recipient: Recipient? = null +) { + enum class LoadState { + INIT, + LOADED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeViewModel.kt new file mode 100644 index 0000000000..fd3410e266 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeViewModel.kt @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.badges.view + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.thoughtcrime.securesms.badges.BadgeRepository +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.livedata.Store + +class ViewBadgeViewModel( + private val startBadge: Badge?, + private val recipientId: RecipientId, + private val repository: BadgeRepository +) : ViewModel() { + + private val disposables = CompositeDisposable() + + private val store = Store(ViewBadgeState()) + + val state: LiveData = store.stateLiveData + + init { + store.update(Recipient.live(recipientId).liveData) { recipient, state -> + state.copy( + recipient = recipient, + allBadgesVisibleOnProfile = recipient.badges, + selectedBadge = startBadge ?: recipient.badges.firstOrNull(), + badgeLoadState = ViewBadgeState.LoadState.LOADED + ) + } + } + + override fun onCleared() { + disposables.clear() + } + + fun onPageSelected(position: Int) { + if (position > store.state.allBadgesVisibleOnProfile.size - 1 || position < 0) { + return + } + + store.update { + it.copy(selectedBadge = it.allBadgesVisibleOnProfile[position]) + } + } + + class Factory( + private val startBadge: Badge?, + private val recipientId: RecipientId, + private val repository: BadgeRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return requireNotNull(modelClass.cast(ViewBadgeViewModel(startBadge, recipientId, repository))) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt index 82d08b0ed1..2038dd18bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.components.settings +import android.content.Context import android.os.Build import android.os.Bundle import android.view.View @@ -10,6 +11,7 @@ import androidx.annotation.StringRes import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper @@ -18,7 +20,8 @@ import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimation abstract class DSLSettingsFragment( @StringRes private val titleId: Int = -1, @MenuRes private val menuId: Int = -1, - @LayoutRes layoutId: Int = R.layout.dsl_settings_fragment + @LayoutRes layoutId: Int = R.layout.dsl_settings_fragment, + val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) } ) : Fragment(layoutId) { private lateinit var recyclerView: RecyclerView @@ -46,6 +49,7 @@ abstract class DSLSettingsFragment( scrollAnimationHelper = getOnScrollAnimationHelper(toolbarShadow) val adapter = DSLSettingsAdapter() + recyclerView.layoutManager = layoutManagerProducer(requireContext()) recyclerView.adapter = adapter recyclerView.addOnScrollListener(scrollAnimationHelper) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index bc8ad3d9c2..5be739639e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -17,9 +17,9 @@ import androidx.core.content.ContextCompat import androidx.core.view.doOnPreDraw import androidx.fragment.app.viewModels import androidx.navigation.Navigation -import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import app.cash.exhaustive.Exhaustive +import com.google.android.flexbox.FlexboxLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import org.thoughtcrime.securesms.AvatarPreviewActivity @@ -30,6 +30,10 @@ import org.thoughtcrime.securesms.MuteDialog import org.thoughtcrime.securesms.PushContactSelectionActivity import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.VerifyIdentityActivity +import org.thoughtcrime.securesms.badges.Badges +import org.thoughtcrime.securesms.badges.Badges.displayBadges +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment import org.thoughtcrime.securesms.components.AvatarImageView import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper @@ -85,7 +89,8 @@ private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4 class ConversationSettingsFragment : DSLSettingsFragment( layoutId = R.layout.conversation_settings_fragment, - menuId = R.menu.conversation_settings + menuId = R.menu.conversation_settings, + layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges ) { private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) } @@ -175,6 +180,8 @@ class ConversationSettingsFragment : DSLSettingsFragment( } override fun bindAdapter(adapter: DSLSettingsAdapter) { + val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments()) + BioTextPreference.register(adapter) AvatarPreference.register(adapter) ButtonStripPreference.register(adapter) @@ -185,6 +192,13 @@ class ConversationSettingsFragment : DSLSettingsFragment( GroupDescriptionPreference.register(adapter) LegacyGroupPreference.register(adapter) + val recipientId = args.recipientId + if (recipientId != null) { + Badge.register(adapter) { badge, _ -> + ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, recipientId, badge) + } + } + viewModel.state.observe(viewLifecycleOwner) { state -> if (state.recipient != Recipient.UNKNOWN) { @@ -466,12 +480,20 @@ class ConversationSettingsFragment : DSLSettingsFragment( ) } - state.withRecipientSettingsState { groupState -> - if (groupState.selfHasGroups) { + state.withRecipientSettingsState { recipientSettingsState -> + if (state.recipient.badges.isNotEmpty()) { + dividerPref() + + sectionHeaderPref(R.string.ManageProfileFragment_badges) + + displayBadges(state.recipient.badges) + } + + if (recipientSettingsState.selfHasGroups) { dividerPref() - val groupsInCommonCount = groupState.allGroupsInCommon.size + val groupsInCommonCount = recipientSettingsState.allGroupsInCommon.size sectionHeaderPref( DSLSettingsText.from( if (groupsInCommonCount == 0) { @@ -496,7 +518,7 @@ class ConversationSettingsFragment : DSLSettingsFragment( ) ) - for (group in groupState.groupsInCommon) { + for (group in recipientSettingsState.groupsInCommon) { customPref( RecipientPreference.Model( recipient = group, @@ -508,7 +530,7 @@ class ConversationSettingsFragment : DSLSettingsFragment( ) } - if (groupState.canShowMoreGroupsInCommon) { + if (recipientSettingsState.canShowMoreGroupsInCommon) { customPref( LargeIconClickPreference.Model( title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all), @@ -718,7 +740,7 @@ class ConversationSettingsFragment : DSLSettingsFragment( private val rect = Rect() override fun getAnimationState(recyclerView: RecyclerView): AnimationState { - val layoutManager = recyclerView.layoutManager as LinearLayoutManager + val layoutManager = recyclerView.layoutManager as FlexboxLayoutManager return if (layoutManager.findFirstVisibleItemPosition() == 0) { val firstChild = requireNotNull(layoutManager.getChildAt(0)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index 067ce5d84f..4d6d8a4f66 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -23,6 +23,7 @@ import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.groups.GroupMasterKey; import org.signal.zkgroup.profiles.ProfileKey; import org.signal.zkgroup.profiles.ProfileKeyCredential; +import org.thoughtcrime.securesms.badges.models.Badge; import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.conversation.colors.AvatarColor; import org.thoughtcrime.securesms.conversation.colors.ChatColors; @@ -33,6 +34,7 @@ import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.IdentityRecord; import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList; import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor; import org.thoughtcrime.securesms.database.model.databaseprotos.DeviceLastResetTime; import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileKeyCredentialColumnData; @@ -153,6 +155,7 @@ public class RecipientDatabase extends Database { private static final String GROUPS_IN_COMMON = "groups_in_common"; private static final String CHAT_COLORS = "chat_colors"; private static final String CUSTOM_CHAT_COLORS_ID = "custom_chat_colors_id"; + private static final String BADGES = "badges"; public static final String SEARCH_PROFILE_NAME = "search_signal_profile"; private static final String SORT_NAME = "sort_name"; @@ -188,7 +191,8 @@ public class RecipientDatabase extends Database { MENTION_SETTING, ABOUT, ABOUT_EMOJI, EXTRAS, GROUPS_IN_COMMON, - CHAT_COLORS, CUSTOM_CHAT_COLORS_ID + CHAT_COLORS, CUSTOM_CHAT_COLORS_ID, + BADGES }; private static final String[] ID_PROJECTION = new String[]{ID}; @@ -372,7 +376,8 @@ public class RecipientDatabase extends Database { EXTRAS + " BLOB DEFAULT NULL, " + GROUPS_IN_COMMON + " INTEGER DEFAULT 0, " + CHAT_COLORS + " BLOB DEFAULT NULL, " + - CUSTOM_CHAT_COLORS_ID + " INTEGER DEFAULT 0);"; + CUSTOM_CHAT_COLORS_ID + " INTEGER DEFAULT 0, " + + BADGES + " BLOB DEFAULT NULL);"; private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID + " FROM " + TABLE_NAME + @@ -1208,49 +1213,52 @@ public class RecipientDatabase extends Database { } static @NonNull RecipientSettings getRecipientSettings(@NonNull Context context, @NonNull Cursor cursor, @NonNull String idColumnName) { - long id = CursorUtil.requireLong(cursor, idColumnName); - UUID uuid = UuidUtil.parseOrNull(CursorUtil.requireString(cursor, UUID)); - String username = CursorUtil.requireString(cursor, USERNAME); - String e164 = CursorUtil.requireString(cursor, PHONE); - String email = CursorUtil.requireString(cursor, EMAIL); - GroupId groupId = GroupId.parseNullableOrThrow(CursorUtil.requireString(cursor, GROUP_ID)); - int groupType = CursorUtil.requireInt(cursor, GROUP_TYPE); - boolean blocked = CursorUtil.requireBoolean(cursor, BLOCKED); - String messageRingtone = CursorUtil.requireString(cursor, MESSAGE_RINGTONE); - String callRingtone = CursorUtil.requireString(cursor, CALL_RINGTONE); - int messageVibrateState = CursorUtil.requireInt(cursor, MESSAGE_VIBRATE); - int callVibrateState = CursorUtil.requireInt(cursor, CALL_VIBRATE); - long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL)); - int insightsBannerTier = CursorUtil.requireInt(cursor, SEEN_INVITE_REMINDER); - int defaultSubscriptionId = CursorUtil.requireInt(cursor, DEFAULT_SUBSCRIPTION_ID); - int expireMessages = CursorUtil.requireInt(cursor, MESSAGE_EXPIRATION_TIME); - int registeredState = CursorUtil.requireInt(cursor, REGISTERED); - String profileKeyString = CursorUtil.requireString(cursor, PROFILE_KEY); - String profileKeyCredentialString = CursorUtil.requireString(cursor, PROFILE_KEY_CREDENTIAL); - String systemGivenName = CursorUtil.requireString(cursor, SYSTEM_GIVEN_NAME); - String systemFamilyName = CursorUtil.requireString(cursor, SYSTEM_FAMILY_NAME); - String systemDisplayName = CursorUtil.requireString(cursor, SYSTEM_JOINED_NAME); - String systemContactPhoto = CursorUtil.requireString(cursor, SYSTEM_PHOTO_URI); - String systemPhoneLabel = CursorUtil.requireString(cursor, SYSTEM_PHONE_LABEL); - String systemContactUri = CursorUtil.requireString(cursor, SYSTEM_CONTACT_URI); - String profileGivenName = CursorUtil.requireString(cursor, PROFILE_GIVEN_NAME); - String profileFamilyName = CursorUtil.requireString(cursor, PROFILE_FAMILY_NAME); - String signalProfileAvatar = CursorUtil.requireString(cursor, SIGNAL_PROFILE_AVATAR); - boolean profileSharing = CursorUtil.requireBoolean(cursor, PROFILE_SHARING); - long lastProfileFetch = cursor.getLong(cursor.getColumnIndexOrThrow(LAST_PROFILE_FETCH)); - String notificationChannel = CursorUtil.requireString(cursor, NOTIFICATION_CHANNEL); - int unidentifiedAccessMode = CursorUtil.requireInt(cursor, UNIDENTIFIED_ACCESS_MODE); - boolean forceSmsSelection = CursorUtil.requireBoolean(cursor, FORCE_SMS_SELECTION); - long capabilities = CursorUtil.requireLong(cursor, CAPABILITIES); - String storageKeyRaw = CursorUtil.requireString(cursor, STORAGE_SERVICE_ID); - int mentionSettingId = CursorUtil.requireInt(cursor, MENTION_SETTING); - byte[] wallpaper = CursorUtil.requireBlob(cursor, WALLPAPER); - byte[] serializedChatColors = CursorUtil.requireBlob(cursor, CHAT_COLORS); - long customChatColorsId = CursorUtil.requireLong(cursor, CUSTOM_CHAT_COLORS_ID); - String serializedAvatarColor = CursorUtil.requireString(cursor, AVATAR_COLOR); - String about = CursorUtil.requireString(cursor, ABOUT); - String aboutEmoji = CursorUtil.requireString(cursor, ABOUT_EMOJI); - boolean hasGroupsInCommon = CursorUtil.requireBoolean(cursor, GROUPS_IN_COMMON); + long id = CursorUtil.requireLong(cursor, idColumnName); + UUID uuid = UuidUtil.parseOrNull(CursorUtil.requireString(cursor, UUID)); + String username = CursorUtil.requireString(cursor, USERNAME); + String e164 = CursorUtil.requireString(cursor, PHONE); + String email = CursorUtil.requireString(cursor, EMAIL); + GroupId groupId = GroupId.parseNullableOrThrow(CursorUtil.requireString(cursor, GROUP_ID)); + int groupType = CursorUtil.requireInt(cursor, GROUP_TYPE); + boolean blocked = CursorUtil.requireBoolean(cursor, BLOCKED); + String messageRingtone = CursorUtil.requireString(cursor, MESSAGE_RINGTONE); + String callRingtone = CursorUtil.requireString(cursor, CALL_RINGTONE); + int messageVibrateState = CursorUtil.requireInt(cursor, MESSAGE_VIBRATE); + int callVibrateState = CursorUtil.requireInt(cursor, CALL_VIBRATE); + long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL)); + int insightsBannerTier = CursorUtil.requireInt(cursor, SEEN_INVITE_REMINDER); + int defaultSubscriptionId = CursorUtil.requireInt(cursor, DEFAULT_SUBSCRIPTION_ID); + int expireMessages = CursorUtil.requireInt(cursor, MESSAGE_EXPIRATION_TIME); + int registeredState = CursorUtil.requireInt(cursor, REGISTERED); + String profileKeyString = CursorUtil.requireString(cursor, PROFILE_KEY); + String profileKeyCredentialString = CursorUtil.requireString(cursor, PROFILE_KEY_CREDENTIAL); + String systemGivenName = CursorUtil.requireString(cursor, SYSTEM_GIVEN_NAME); + String systemFamilyName = CursorUtil.requireString(cursor, SYSTEM_FAMILY_NAME); + String systemDisplayName = CursorUtil.requireString(cursor, SYSTEM_JOINED_NAME); + String systemContactPhoto = CursorUtil.requireString(cursor, SYSTEM_PHOTO_URI); + String systemPhoneLabel = CursorUtil.requireString(cursor, SYSTEM_PHONE_LABEL); + String systemContactUri = CursorUtil.requireString(cursor, SYSTEM_CONTACT_URI); + String profileGivenName = CursorUtil.requireString(cursor, PROFILE_GIVEN_NAME); + String profileFamilyName = CursorUtil.requireString(cursor, PROFILE_FAMILY_NAME); + String signalProfileAvatar = CursorUtil.requireString(cursor, SIGNAL_PROFILE_AVATAR); + boolean profileSharing = CursorUtil.requireBoolean(cursor, PROFILE_SHARING); + long lastProfileFetch = cursor.getLong(cursor.getColumnIndexOrThrow(LAST_PROFILE_FETCH)); + String notificationChannel = CursorUtil.requireString(cursor, NOTIFICATION_CHANNEL); + int unidentifiedAccessMode = CursorUtil.requireInt(cursor, UNIDENTIFIED_ACCESS_MODE); + boolean forceSmsSelection = CursorUtil.requireBoolean(cursor, FORCE_SMS_SELECTION); + long capabilities = CursorUtil.requireLong(cursor, CAPABILITIES); + String storageKeyRaw = CursorUtil.requireString(cursor, STORAGE_SERVICE_ID); + int mentionSettingId = CursorUtil.requireInt(cursor, MENTION_SETTING); + byte[] wallpaper = CursorUtil.requireBlob(cursor, WALLPAPER); + byte[] serializedChatColors = CursorUtil.requireBlob(cursor, CHAT_COLORS); + long customChatColorsId = CursorUtil.requireLong(cursor, CUSTOM_CHAT_COLORS_ID); + String serializedAvatarColor = CursorUtil.requireString(cursor, AVATAR_COLOR); + String about = CursorUtil.requireString(cursor, ABOUT); + String aboutEmoji = CursorUtil.requireString(cursor, ABOUT_EMOJI); + boolean hasGroupsInCommon = CursorUtil.requireBoolean(cursor, GROUPS_IN_COMMON); + byte[] serializedBadgeList = CursorUtil.requireBlob(cursor, BADGES); + + List badges = parseBadgeList(serializedBadgeList); byte[] profileKey = null; ProfileKeyCredential profileKeyCredential = null; @@ -1343,7 +1351,40 @@ public class RecipientDatabase extends Database { aboutEmoji, getSyncExtras(cursor), getExtras(cursor), - hasGroupsInCommon); + hasGroupsInCommon, + badges); + } + + private static @NonNull List parseBadgeList(byte[] serializedBadgeList) { + BadgeList badgeList = null; + if (serializedBadgeList != null) { + try { + badgeList = BadgeList.parseFrom(serializedBadgeList); + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, e); + } + } + + List badges; + if (badgeList != null) { + List protoBadges = badgeList.getBadgesList(); + badges = new ArrayList<>(protoBadges.size()); + for (BadgeList.Badge protoBadge : protoBadges) { + badges.add(new Badge( + protoBadge.getId(), + Badge.Category.Companion.fromCode(protoBadge.getCategory()), + Uri.parse(protoBadge.getImageUrl()), + protoBadge.getName(), + protoBadge.getDescription(), + protoBadge.getExpiration(), + protoBadge.getVisible() + )); + } + } else { + badges = Collections.emptyList(); + } + + return badges; } private static @NonNull RecipientSettings.SyncExtras getSyncExtras(@NonNull Cursor cursor) { @@ -1639,6 +1680,28 @@ public class RecipientDatabase extends Database { return DeviceLastResetTime.newBuilder().build(); } + public void setBadges(@NonNull RecipientId id, @NonNull List badges) { + BadgeList.Builder badgeListBuilder = BadgeList.newBuilder(); + + for (final Badge badge : badges) { + badgeListBuilder.addBadges(BadgeList.Badge.newBuilder() + .setId(badge.getId()) + .setCategory(badge.getCategory().getCode()) + .setDescription(badge.getDescription()) + .setExpiration(badge.getExpirationTimestamp()) + .setVisible(badge.getVisible()) + .setName(badge.getName()) + .setImageUrl(badge.getImageUrl().toString())); + } + + ContentValues values = new ContentValues(1); + values.put(BADGES, badgeListBuilder.build().toByteArray()); + + if (update(id, values)) { + Recipient.live(id).refresh(); + } + } + public void setCapabilities(@NonNull RecipientId id, @NonNull SignalServiceProfile.Capabilities capabilities) { long value = 0; @@ -3228,6 +3291,7 @@ public class RecipientDatabase extends Database { private final SyncExtras syncExtras; private final Recipient.Extras extras; private final boolean hasGroupsInCommon; + private final List badges; RecipientSettings(@NonNull RecipientId id, @Nullable UUID uuid, @@ -3271,7 +3335,8 @@ public class RecipientDatabase extends Database { @Nullable String aboutEmoji, @NonNull SyncExtras syncExtras, @Nullable Recipient.Extras extras, - boolean hasGroupsInCommon) + boolean hasGroupsInCommon, + @NonNull List badges) { this.id = id; this.uuid = uuid; @@ -3318,9 +3383,10 @@ public class RecipientDatabase extends Database { this.avatarColor = avatarColor; this.about = about; this.aboutEmoji = aboutEmoji; - this.syncExtras = syncExtras; - this.extras = extras; - this.hasGroupsInCommon = hasGroupsInCommon; + this.syncExtras = syncExtras; + this.extras = extras; + this.hasGroupsInCommon = hasGroupsInCommon; + this.badges = badges; } public RecipientId getId() { @@ -3511,6 +3577,10 @@ public class RecipientDatabase extends Database { return hasGroupsInCommon; } + public @NonNull List getBadges() { + return badges; + } + long getCapabilities() { return capabilities; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 51fae6ee77..0e3501cfeb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -216,8 +216,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab private static final int GROUP_CALL_RING_TABLE = 115; private static final int CLEANUP_SESSION_MIGRATION = 116; private static final int RECEIPT_TIMESTAMP = 117; + private static final int BADGES = 118; - private static final int DATABASE_VERSION = 117; + private static final int DATABASE_VERSION = 118; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -2043,6 +2044,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab db.execSQL("ALTER TABLE mms ADD COLUMN receipt_timestamp INTEGER DEFAULT -1"); } + if (oldVersion < BADGES) { + db.execSQL("ALTER TABLE recipient ADD COLUMN badges BLOB DEFAULT NULL"); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/BadgeLoader.java b/app/src/main/java/org/thoughtcrime/securesms/glide/BadgeLoader.java new file mode 100644 index 0000000000..9b80ba6dcd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/BadgeLoader.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.glide; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.GlideUrl; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; + +import org.thoughtcrime.securesms.badges.models.Badge; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.push.SignalServiceTrustStore; +import org.whispersystems.signalservice.api.push.TrustStore; +import org.whispersystems.signalservice.api.util.Tls12SocketFactory; +import org.whispersystems.signalservice.internal.util.BlacklistingTrustManager; +import org.whispersystems.signalservice.internal.util.Util; + +import java.io.InputStream; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import okhttp3.ConnectionSpec; +import okhttp3.OkHttpClient; + +/** + * A simple model loader for fetching media over http/https using OkHttp. + */ +public class BadgeLoader implements ModelLoader { + + private final OkHttpClient client; + + private BadgeLoader(OkHttpClient client) { + this.client = client; + } + + @Override + public @Nullable LoadData buildLoadData(@NonNull Badge badge, int width, int height, @NonNull Options options) { + return new LoadData<>(badge, new OkHttpStreamFetcher(client, new GlideUrl(badge.getImageUrl().toString()))); + } + + @Override + public boolean handles(@NonNull Badge badge) { + return true; + } + + public static Factory createFactory() { + try { + OkHttpClient baseClient = ApplicationDependencies.getOkHttpClient(); + SSLContext sslContext = SSLContext.getInstance("TLS"); + TrustStore trustStore = new SignalServiceTrustStore(ApplicationDependencies.getApplication()); + TrustManager[] trustManagers = BlacklistingTrustManager.createFor(trustStore); + + sslContext.init(null, trustManagers, null); + + OkHttpClient client = baseClient.newBuilder() + .sslSocketFactory(new Tls12SocketFactory(sslContext.getSocketFactory()), (X509TrustManager) trustManagers[0]) + .connectionSpecs(Util.immutableList(ConnectionSpec.RESTRICTED_TLS)) + .build(); + + return new Factory(client); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + throw new AssertionError(e); + } + } + + public static class Factory implements ModelLoaderFactory { + + private final OkHttpClient client; + + private Factory(@NonNull OkHttpClient client) { + this.client = client; + } + + @Override + public @NonNull ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new BadgeLoader(client); + } + + @Override + public void teardown() { + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java index 8bfae037ce..02d0a97939 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.jobs; +import android.net.Uri; import android.text.TextUtils; import androidx.annotation.NonNull; @@ -8,6 +9,7 @@ import androidx.annotation.Nullable; import org.signal.core.util.logging.Log; import org.signal.zkgroup.profiles.ProfileKey; import org.signal.zkgroup.profiles.ProfileKeyCredential; +import org.thoughtcrime.securesms.badges.models.Badge; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.RecipientDatabase; @@ -28,6 +30,10 @@ import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; import java.io.IOException; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.util.List; +import java.util.stream.Collectors; /** @@ -83,13 +89,14 @@ public class RefreshOwnProfileJob extends BaseJob { } Recipient self = Recipient.self(); - ProfileAndCredential profileAndCredential = ProfileUtil.retrieveProfileSync(context, self, getRequestType(self)); + ProfileAndCredential profileAndCredential = ProfileUtil.retrieveProfileSync(context, self, getRequestType(self), false); SignalServiceProfile profile = profileAndCredential.getProfile(); setProfileName(profile.getName()); setProfileAbout(profile.getAbout(), profile.getAboutEmoji()); setProfileAvatar(profile.getAvatar()); setProfileCapabilities(profile.getCapabilities()); + setProfileBadges(profile.getBadges()); Optional profileKeyCredential = profileAndCredential.getProfileKeyCredential(); if (profileKeyCredential.isPresent()) { setProfileKeyCredential(self, ProfileKeyUtil.getSelfProfileKey(), profileKeyCredential.get()); @@ -159,6 +166,32 @@ public class RefreshOwnProfileJob extends BaseJob { DatabaseFactory.getRecipientDatabase(context).setCapabilities(Recipient.self().getId(), capabilities); } + private void setProfileBadges(@Nullable List badges) { + if (badges == null) { + return; + } + + DatabaseFactory.getRecipientDatabase(context) + .setBadges(Recipient.self().getId(), + badges.stream().map(RefreshOwnProfileJob::adaptFromServiceBadge).collect(Collectors.toList())); + } + + private static Badge adaptFromServiceBadge(@NonNull SignalServiceProfile.Badge serviceBadge) { + return new Badge( + serviceBadge.getId(), + Badge.Category.Companion.fromCode(serviceBadge.getCategory()), + Uri.parse(serviceBadge.getImageUrl()), + serviceBadge.getName(), + serviceBadge.getDescription(), + getTimestamp(serviceBadge.getExpiration()), + serviceBadge.isVisible() + ); + } + + private static long getTimestamp(@NonNull BigDecimal bigDecimal) { + return new Timestamp(bigDecimal.longValue() * 1000).getTime(); + } + public static final class Factory implements Job.Factory { @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java index 5bb55b82cd..54994e78c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.jobs; import android.app.Application; import android.content.Context; +import android.net.Uri; import android.text.TextUtils; import androidx.annotation.NonNull; @@ -15,6 +16,7 @@ import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.signal.zkgroup.profiles.ProfileKey; import org.signal.zkgroup.profiles.ProfileKeyCredential; +import org.thoughtcrime.securesms.badges.models.Badge; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; @@ -32,6 +34,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.transport.RetryLaterException; import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.IdentityUtil; import org.thoughtcrime.securesms.util.ProfileUtil; import org.thoughtcrime.securesms.util.SetUtil; @@ -329,6 +332,7 @@ public class RetrieveProfileJob extends BaseJob { setProfileName(recipient, profile.getName()); setProfileAbout(recipient, profile.getAbout(), profile.getAboutEmoji()); setProfileAvatar(recipient, profile.getAvatar()); + setProfileBadges(recipient, profile.getBadges()); clearUsername(recipient); setProfileCapabilities(recipient, profile.getCapabilities()); setIdentityKey(recipient, profile.getIdentityKey()); @@ -342,6 +346,28 @@ public class RetrieveProfileJob extends BaseJob { } } + private void setProfileBadges(@NonNull Recipient recipient, @Nullable List badges) { + if (badges == null) { + return; + } + + DatabaseFactory.getRecipientDatabase(context) + .setBadges(recipient.getId(), + badges.stream().map(RetrieveProfileJob::adaptFromServiceBadge).collect(java.util.stream.Collectors.toList())); + } + + private static Badge adaptFromServiceBadge(@NonNull SignalServiceProfile.Badge serviceBadge) { + return new Badge( + serviceBadge.getId(), + Badge.Category.Companion.fromCode(serviceBadge.getCategory()), + Uri.parse(serviceBadge.getImageUrl()), + serviceBadge.getName(), + serviceBadge.getDescription(), + 0L, + true + ); + } + private void setProfileKeyCredential(@NonNull Recipient recipient, @NonNull ProfileKey recipientProfileKey, @NonNull ProfileKeyCredential credential) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java index 16872a6243..c3b52ec7c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java @@ -23,6 +23,7 @@ import com.bumptech.glide.load.resource.gif.StreamGifDecoder; import com.bumptech.glide.module.AppGlideModule; import org.signal.glide.apng.decode.APNGDecoder; +import org.thoughtcrime.securesms.badges.models.Badge; import org.thoughtcrime.securesms.blurhash.BlurHash; import org.thoughtcrime.securesms.blurhash.BlurHashModelLoader; import org.thoughtcrime.securesms.blurhash.BlurHashResourceDecoder; @@ -30,6 +31,7 @@ import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.crypto.AttachmentSecret; import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl; +import org.thoughtcrime.securesms.glide.BadgeLoader; import org.thoughtcrime.securesms.glide.ChunkedImageUrlLoader; import org.thoughtcrime.securesms.glide.ContactPhotoLoader; import org.thoughtcrime.securesms.glide.OkHttpUrlLoader; @@ -97,6 +99,7 @@ public class SignalGlideModule extends AppGlideModule { registry.append(ChunkedImageUrl.class, InputStream.class, new ChunkedImageUrlLoader.Factory()); registry.append(StickerRemoteUri.class, InputStream.class, new StickerRemoteUriLoader.Factory()); registry.append(BlurHash.class, BlurHash.class, new BlurHashModelLoader.Factory()); + registry.append(Badge.class, InputStream.class, BadgeLoader.createFactory()); registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory()); } 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 979473f7c7..8477a99456 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 @@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.components.emoji.EmojiUtil; 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.FeatureFlags; import org.thoughtcrime.securesms.util.NameUtil; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; @@ -51,6 +52,7 @@ public class ManageProfileFragment extends LoggingFragment { private AlertDialog avatarProgress; private TextView avatarInitials; private ImageView avatarBackground; + private View badgesContainer; private ManageProfileViewModel viewModel; @@ -73,6 +75,7 @@ public class ManageProfileFragment extends LoggingFragment { 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); + this.badgesContainer = view.findViewById(R.id.manage_profile_badges_container); initializeViewModel(); @@ -105,6 +108,14 @@ public class ManageProfileFragment extends LoggingFragment { updateInitials(avatarInitials.getText().toString()); } }); + + if (FeatureFlags.donorBadges()) { + badgesContainer.setOnClickListener(v -> { + Navigation.findNavController(v).navigate(ManageProfileFragmentDirections.actionManageProfileFragmentToBadgeManageFragment()); + }); + } else { + badgesContainer.setVisibility(View.GONE); + } } private void initializeViewModel() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index cb356cbf74..5d7a6ae0a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -15,6 +15,7 @@ import com.annimon.stream.Stream; import org.signal.core.util.logging.Log; import org.signal.zkgroup.profiles.ProfileKeyCredential; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.badges.models.Badge; import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; @@ -124,6 +125,7 @@ public class Recipient { private final String systemContactName; private final Optional extras; private final boolean hasGroupsInCommon; + private final List badges; /** * Returns a {@link LiveRecipient}, which contains a {@link Recipient} that may or may not be @@ -376,6 +378,7 @@ public class Recipient { this.systemContactName = null; this.extras = Optional.absent(); this.hasGroupsInCommon = false; + this.badges = Collections.emptyList(); } public Recipient(@NonNull RecipientId id, @NonNull RecipientDetails details, boolean resolved) { @@ -429,6 +432,7 @@ public class Recipient { this.systemContactName = details.systemContactName; this.extras = details.extras; this.hasGroupsInCommon = details.hasGroupsInCommon; + this.badges = details.badges; } public @NonNull RecipientId getId() { @@ -1023,6 +1027,10 @@ public class Recipient { return aboutEmoji; } + public @NonNull List getBadges() { + return badges; + } + public @Nullable String getCombinedAboutAndEmoji() { if (!Util.isEmpty(aboutEmoji)) { if (!Util.isEmpty(about)) { @@ -1202,7 +1210,8 @@ public class Recipient { Objects.equals(about, other.about) && Objects.equals(aboutEmoji, other.aboutEmoji) && Objects.equals(extras, other.extras) && - hasGroupsInCommon == other.hasGroupsInCommon; + hasGroupsInCommon == other.hasGroupsInCommon && + Objects.equals(badges, other.badges); } private static boolean allContentsAreTheSame(@NonNull List a, @NonNull List b) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java index 6283099118..d2640e146e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java @@ -7,6 +7,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.signal.zkgroup.profiles.ProfileKeyCredential; +import org.thoughtcrime.securesms.badges.models.Badge; import org.thoughtcrime.securesms.conversation.colors.AvatarColor; import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.database.RecipientDatabase.InsightsBannerTier; @@ -22,6 +23,7 @@ import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; import org.whispersystems.libsignal.util.guava.Optional; +import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.UUID; @@ -77,6 +79,7 @@ public class RecipientDetails { final ProfileName systemProfileName; final Optional extras; final boolean hasGroupsInCommon; + final List badges; public RecipientDetails(@Nullable String groupName, @Nullable String systemContactName, @@ -136,6 +139,7 @@ public class RecipientDetails { this.systemContactName = systemContactName; this.extras = Optional.fromNullable(settings.getExtras()); this.hasGroupsInCommon = settings.hasGroupsInCommon(); + this.badges = settings.getBadges(); } /** @@ -191,6 +195,7 @@ public class RecipientDetails { this.systemContactName = null; this.extras = Optional.absent(); this.hasGroupsInCommon = false; + this.badges = Collections.emptyList(); } public static @NonNull RecipientDetails forIndividual(@NonNull Context context, @NonNull RecipientSettings settings) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt new file mode 100644 index 0000000000..d188ea0b83 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.util + +import org.thoughtcrime.securesms.BuildConfig + +object Environment { + const val IS_STAGING: Boolean = BuildConfig.BUILD_ENVIRONMENT_TYPE == "Staging" +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 760607596a..6ccecf0042 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -84,6 +84,7 @@ public final class FeatureFlags { private static final String MAX_GROUP_CALL_RING_SIZE = "global.calling.maxGroupCallRingSize"; private static final String GROUP_CALL_RINGING = "android.calling.groupCallRinging"; private static final String CHANGE_NUMBER_ENABLED = "android.changeNumber"; + private static final String DONOR_BADGES = "android.donorBadges"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -126,7 +127,8 @@ public final class FeatureFlags { @VisibleForTesting static final Set NOT_REMOTE_CAPABLE = SetUtil.newHashSet( PHONE_NUMBER_PRIVACY_VERSION, - CHANGE_NUMBER_ENABLED + CHANGE_NUMBER_ENABLED, + DONOR_BADGES ); /** @@ -394,11 +396,20 @@ public final class FeatureFlags { return getBoolean(GROUP_CALL_RINGING, false); } - /** Weather or not to show change number in the UI. */ + /** Whether or not to show change number in the UI. */ public static boolean changeNumber() { return getBoolean(CHANGE_NUMBER_ENABLED, false); } + /** Whether or not to show donor badges in the UI. */ + public static boolean donorBadges() { + if (Environment.IS_STAGING) { + return true; + } else { + return getBoolean(DONOR_BADGES, false); + } + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java index 7e7f1e95e0..17427b363d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java @@ -9,6 +9,7 @@ import androidx.annotation.WorkerThread; import org.signal.core.util.logging.Log; import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.profiles.ProfileKey; +import org.thoughtcrime.securesms.badges.models.Badge; import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; @@ -42,6 +43,8 @@ import org.whispersystems.signalservice.internal.ServiceResponse; import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; import io.reactivex.rxjava3.core.Single; @@ -60,12 +63,22 @@ public final class ProfileUtil { @NonNull Recipient recipient, @NonNull SignalServiceProfile.RequestType requestType) throws IOException + { + return retrieveProfileSync(context, recipient, requestType, true); + } + + @WorkerThread + public static @NonNull ProfileAndCredential retrieveProfileSync(@NonNull Context context, + @NonNull Recipient recipient, + @NonNull SignalServiceProfile.RequestType requestType, + boolean allowUnidentifiedAccess) + throws IOException { ProfileService profileService = new ProfileService(ApplicationDependencies.getGroupsV2Operations().getProfileOperations(), ApplicationDependencies.getSignalServiceMessageReceiver(), ApplicationDependencies.getSignalWebSocket()); - Pair> response = retrieveProfile(context, recipient, requestType, profileService).blockingGet(); + Pair> response = retrieveProfile(context, recipient, requestType, profileService, allowUnidentifiedAccess).blockingGet(); return new ProfileService.ProfileResponseProcessor(response.second()).getResultOrThrow(); } @@ -74,7 +87,16 @@ public final class ProfileUtil { @NonNull SignalServiceProfile.RequestType requestType, @NonNull ProfileService profileService) { - Optional unidentifiedAccess = getUnidentifiedAccess(context, recipient); + return retrieveProfile(context, recipient, requestType, profileService, true); + } + + private static Single>> retrieveProfile(@NonNull Context context, + @NonNull Recipient recipient, + @NonNull SignalServiceProfile.RequestType requestType, + @NonNull ProfileService profileService, + boolean allowUnidentifiedAccess) + { + Optional unidentifiedAccess = allowUnidentifiedAccess ? getUnidentifiedAccess(context, recipient) : Optional.absent(); Optional profileKey = ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey()); return Single.fromCallable(() -> toSignalServiceAddress(context, recipient)) @@ -163,6 +185,23 @@ public final class ProfileUtil { return profileKey; } + /** + * Uploads the profile based on all state that's written to disk, except we'll use the provided + * list of badges instead. This is useful when you want to ensure that the profile has been uploaded + * successfully before persisting the change to disk. + */ + public static void uploadProfileWithBadges(@NonNull Context context, @NonNull List badges) throws IOException { + try (StreamDetails avatar = AvatarHelper.getSelfProfileAvatarStream(context)) { + uploadProfile(context, + Recipient.self().getProfileName(), + Optional.fromNullable(Recipient.self().getAbout()).or(""), + Optional.fromNullable(Recipient.self().getAboutEmoji()).or(""), + getSelfPaymentsAddressProtobuf(), + avatar, + badges); + } + } + /** * Uploads the profile based on all state that's written to disk, except we'll use the provided * profile name instead. This is useful when you want to ensure that the profile has been uploaded @@ -175,7 +214,8 @@ public final class ProfileUtil { Optional.fromNullable(Recipient.self().getAbout()).or(""), Optional.fromNullable(Recipient.self().getAboutEmoji()).or(""), getSelfPaymentsAddressProtobuf(), - avatar); + avatar, + Recipient.self().getBadges()); } } @@ -191,7 +231,8 @@ public final class ProfileUtil { about, emoji, getSelfPaymentsAddressProtobuf(), - avatar); + avatar, + Recipient.self().getBadges()); } } @@ -215,7 +256,8 @@ public final class ProfileUtil { Optional.fromNullable(Recipient.self().getAbout()).or(""), Optional.fromNullable(Recipient.self().getAboutEmoji()).or(""), getSelfPaymentsAddressProtobuf(), - avatar); + avatar, + Recipient.self().getBadges()); } private static void uploadProfile(@NonNull Context context, @@ -223,13 +265,21 @@ public final class ProfileUtil { @Nullable String about, @Nullable String aboutEmoji, @Nullable SignalServiceProtos.PaymentAddress paymentsAddress, - @Nullable StreamDetails avatar) + @Nullable StreamDetails avatar, + @NonNull List badges) throws IOException { + + List badgeIds = badges.stream() + .filter(Badge::getVisible) + .map(Badge::getId) + .collect(Collectors.toList()); + Log.d(TAG, "Uploading " + (!Util.isEmpty(about) ? "non-" : "") + "empty about."); Log.d(TAG, "Uploading " + (!Util.isEmpty(aboutEmoji) ? "non-" : "") + "empty emoji."); Log.d(TAG, "Uploading " + (paymentsAddress != null ? "non-" : "") + "empty payments address."); Log.d(TAG, "Uploading " + (avatar != null && avatar.getLength() != 0 ? "non-" : "") + "empty avatar."); + Log.d(TAG, "Uploading " + ((!badgeIds.isEmpty()) ? "non-" : "") + "empty badge list"); ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey(); SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); @@ -239,7 +289,8 @@ public final class ProfileUtil { about, aboutEmoji, Optional.fromNullable(paymentsAddress), - avatar).orNull(); + avatar, + badgeIds).orNull(); DatabaseFactory.getRecipientDatabase(context).setProfileAvatar(Recipient.self().getId(), avatarPath); } diff --git a/app/src/main/proto/Database.proto b/app/src/main/proto/Database.proto index 9935e5feaf..c50dbf33ab 100644 --- a/app/src/main/proto/Database.proto +++ b/app/src/main/proto/Database.proto @@ -23,6 +23,20 @@ message ReactionList { repeated Reaction reactions = 1; } +message BadgeList { + message Badge { + string id = 1; + string category = 2; + string name = 3; + string description = 4; + string imageUrl = 5; + uint64 expiration = 6; + bool visible = 7; + } + + repeated Badge badges = 1; +} + import "SignalService.proto"; import "DecryptedGroups.proto"; diff --git a/app/src/main/res/drawable/default_dot.xml b/app/src/main/res/drawable/default_dot.xml new file mode 100644 index 0000000000..e93215599f --- /dev/null +++ b/app/src/main/res/drawable/default_dot.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selected_dot.xml b/app/src/main/res/drawable/selected_dot.xml new file mode 100644 index 0000000000..a1f301929a --- /dev/null +++ b/app/src/main/res/drawable/selected_dot.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tab_selector.xml b/app/src/main/res/drawable/tab_selector.xml new file mode 100644 index 0000000000..ce49932f8b --- /dev/null +++ b/app/src/main/res/drawable/tab_selector.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/badge_preference_view.xml b/app/src/main/res/layout/badge_preference_view.xml new file mode 100644 index 0000000000..58f0936ec0 --- /dev/null +++ b/app/src/main/res/layout/badge_preference_view.xml @@ -0,0 +1,36 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/featured_badge_preview_preference.xml b/app/src/main/res/layout/featured_badge_preview_preference.xml new file mode 100644 index 0000000000..b3ec0e990c --- /dev/null +++ b/app/src/main/res/layout/featured_badge_preview_preference.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/manage_profile_fragment.xml b/app/src/main/res/layout/manage_profile_fragment.xml index 0abb9455b4..4a7dce7312 100644 --- a/app/src/main/res/layout/manage_profile_fragment.xml +++ b/app/src/main/res/layout/manage_profile_fragment.xml @@ -227,6 +227,53 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/select_featured_badge_fragment.xml b/app/src/main/res/layout/select_featured_badge_fragment.xml new file mode 100644 index 0000000000..c3993c8793 --- /dev/null +++ b/app/src/main/res/layout/select_featured_badge_fragment.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment.xml b/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment.xml new file mode 100644 index 0000000000..28aaa4233b --- /dev/null +++ b/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment_page.xml b/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment_page.xml new file mode 100644 index 0000000000..b8bb443781 --- /dev/null +++ b/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment_page.xml @@ -0,0 +1,48 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/manage_profile.xml b/app/src/main/res/navigation/manage_profile.xml index f3e0088bff..c95e911e65 100644 --- a/app/src/main/res/navigation/manage_profile.xml +++ b/app/src/main/res/navigation/manage_profile.xml @@ -9,7 +9,7 @@ android:id="@+id/manageProfileFragment" android:name="org.thoughtcrime.securesms.profiles.manage.ManageProfileFragment" android:label="fragment_manage_profile" - tools:layout="@layout/profile_create_fragment"> + tools:layout="@layout/manage_profile_fragment"> + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ecb32a40e..970055c437 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -775,6 +775,7 @@ Your name Your username Failed to set avatar + Badges No groups in common @@ -3812,6 +3813,7 @@ Add a message Add a reply Send to + View once message Cancel Draw @@ -3822,18 +3824,30 @@ Clear all Undo Toggle between marker and highlighter + Delete + Toggle between text styles Send Tap to remove Tap to select + Discard Discard changes? - View once message You\'ll lose any changes you\'ve made to this photo. - Delete + Failed to open camera - Toggle between text styles + + My badges + Featured badge + Display badges on profile + Failed to update profile + + + Select badges + Preview + Select a badge + Become a sustainer diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt index 88aee1cb97..4dbbe4db62 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database import android.net.Uri import org.signal.zkgroup.profiles.ProfileKeyCredential +import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.conversation.colors.AvatarColor import org.thoughtcrime.securesms.conversation.colors.ChatColors import org.thoughtcrime.securesms.groups.GroupId @@ -75,7 +76,8 @@ object RecipientDatabaseTestUtils { false ), extras: Recipient.Extras? = null, - hasGroupsInCommon: Boolean = false + hasGroupsInCommon: Boolean = false, + badges: List = emptyList() ): Recipient = Recipient( recipientId, RecipientDetails( @@ -128,7 +130,8 @@ object RecipientDatabaseTestUtils { aboutEmoji, syncExtras, extras, - hasGroupsInCommon + hasGroupsInCommon, + badges ), participants ), diff --git a/image-editor/app/src/main/java/org/signal/imageeditor/app/MainActivity.java b/image-editor/app/src/main/java/org/signal/imageeditor/app/MainActivity.java index cb5a2d5e73..e94c3796c4 100644 --- a/image-editor/app/src/main/java/org/signal/imageeditor/app/MainActivity.java +++ b/image-editor/app/src/main/java/org/signal/imageeditor/app/MainActivity.java @@ -183,10 +183,6 @@ public final class MainActivity extends AppCompatActivity { imageEditorView.startDrawing(0.02f, Paint.Cap.ROUND, false); return true; - case R.id.action_rotate_right_90: - imageEditorView.getModel().rotate90clockwise(); - return true; - case R.id.action_rotate_left_90: imageEditorView.getModel().rotate90anticlockwise(); return true; @@ -195,10 +191,6 @@ public final class MainActivity extends AppCompatActivity { imageEditorView.getModel().flipHorizontal(); return true; - case R.id.action_flip_vertical: - imageEditorView.getModel().flipVertical(); - return true; - case R.id.action_edit_text: editText(); return true; diff --git a/image-editor/app/src/main/res/menu/action_menu.xml b/image-editor/app/src/main/res/menu/action_menu.xml index ac001f4d01..2eb99904f8 100644 --- a/image-editor/app/src/main/res/menu/action_menu.xml +++ b/image-editor/app/src/main/res/menu/action_menu.xml @@ -37,12 +37,6 @@ android:title="@string/draw" app:showAsAction="ifRoom" /> - - - - paymentsAddress, - StreamDetails avatar) + StreamDetails avatar, + List visibleBadgeIds) throws IOException { if (name == null) name = ""; @@ -748,7 +749,8 @@ public class SignalServiceAccountManager { ciphertextEmoji, ciphertextMobileCoinAddress, hasAvatar, - profileKey.getCommitment(uuid).serialize()), + profileKey.getCommitment(uuid).serialize(), + visibleBadgeIds), profileAvatarData); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java index 7f5b0fdf3a..adc312b8df 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java @@ -2,6 +2,7 @@ package org.whispersystems.signalservice.api.profiles; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; @@ -12,6 +13,8 @@ import org.signal.zkgroup.profiles.ProfileKeyCredentialResponse; import org.whispersystems.libsignal.logging.Log; import org.whispersystems.signalservice.internal.util.JsonUtil; +import java.math.BigDecimal; +import java.util.List; import java.util.UUID; public class SignalServiceProfile { @@ -58,6 +61,9 @@ public class SignalServiceProfile { @JsonProperty private byte[] credential; + @JsonProperty + private List badges; + @JsonIgnore private RequestType requestType; @@ -99,6 +105,10 @@ public class SignalServiceProfile { return capabilities; } + public List getBadges() { + return badges; + } + public UUID getUuid() { return uuid; } @@ -111,6 +121,57 @@ public class SignalServiceProfile { this.requestType = requestType; } + public static class Badge { + @JsonProperty + private String id; + + @JsonProperty + private String category; + + @JsonProperty + private String imageUrl; + + @JsonProperty + private String name; + + @JsonProperty + private String description; + + @JsonProperty + private BigDecimal expiration; + + @JsonProperty + private boolean visible; + + public String getId() { + return id; + } + + public String getCategory() { + return category; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public BigDecimal getExpiration() { + return expiration; + } + + public String getImageUrl() { + return imageUrl; + } + + public boolean isVisible() { + return visible; + } + } + public static class Capabilities { @JsonProperty private boolean gv2; diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfileWrite.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfileWrite.java index 1af1a09b13..4d9100d39d 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfileWrite.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfileWrite.java @@ -3,6 +3,8 @@ package org.whispersystems.signalservice.api.profiles; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + public class SignalServiceProfileWrite { @JsonProperty @@ -26,11 +28,14 @@ public class SignalServiceProfileWrite { @JsonProperty private byte[] commitment; + @JsonProperty + private List badgeIds; + @JsonCreator public SignalServiceProfileWrite(){ } - public SignalServiceProfileWrite(String version, byte[] name, byte[] about, byte[] aboutEmoji, byte[] paymentAddress, boolean avatar, byte[] commitment) { + public SignalServiceProfileWrite(String version, byte[] name, byte[] about, byte[] aboutEmoji, byte[] paymentAddress, boolean avatar, byte[] commitment, List badgeIds) { this.version = version; this.name = name; this.about = about; @@ -38,6 +43,7 @@ public class SignalServiceProfileWrite { this.paymentAddress = paymentAddress; this.avatar = avatar; this.commitment = commitment; + this.badgeIds = badgeIds; } public boolean hasAvatar() {