Implement ability to view badges and modify whether they appear.

Note: this is available in staging only.
This commit is contained in:
Alex Hart 2021-09-20 17:05:31 -03:00
parent 556ca5a573
commit 77cf029fdc
48 changed files with 1880 additions and 100 deletions

View file

@ -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())
}

View file

@ -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<Badge>, 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
}
}

View file

@ -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<EmptyModel>() {
override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
}
class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(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<Model>() {
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<Model>(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<ImageView, Drawable>(view) {
private val animator: BadgeAnimator = BadgeAnimator()
override fun onLoadFailed(errorDrawable: Drawable?) {
view.setImageDrawable(errorDrawable)
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
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<Badge> {
private val SELECTION_CHANGED = Any()
override fun createFromParcel(parcel: Parcel): Badge {
return Badge(parcel)
}
override fun newArray(size: Int): Array<Badge?> {
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))
}
}
}

View file

@ -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
}
}

View file

@ -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<Model>() {
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<Model>(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<ImageView, Drawable>(view) {
override fun onLoadFailed(errorDrawable: Drawable?) {
view.setImageDrawable(errorDrawable)
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
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)
}
}
}

View file

@ -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<Model> {
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<Model>(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))
}
}
}

View file

@ -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)
}
}
}

View file

@ -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<Badge> = listOf()
)

View file

@ -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<SelectFeaturedBadgeState> = 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 <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(SelectFeaturedBadgeViewModel(badgeRepository)))
}
}
}

View file

@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.badges.self.overview
enum class BadgesOverviewEvent {
FAILED_TO_UPDATE_PROFILE
}

View file

@ -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())
}
)
}
}
}

View file

@ -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<Badge> = listOf(),
val featuredBadge: Badge? = null,
val displayBadgesOnProfile: Boolean = false
) {
enum class Stage {
INIT,
READY,
UPDATING
}
}

View file

@ -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<BadgesOverviewEvent>()
val state: LiveData<BadgesOverviewState> = store.stateLiveData
val events: Observable<BadgesOverviewEvent> = 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 <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository)))
}
}
}

View file

@ -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)
}
}
}
}

View file

@ -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<Badge> = listOf(),
val badgeLoadState: LoadState = LoadState.INIT,
val selectedBadge: Badge? = null,
val recipient: Recipient? = null
) {
enum class LoadState {
INIT,
LOADED
}
}

View file

@ -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<ViewBadgeState> = 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 <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(ViewBadgeViewModel(startBadge, recipientId, repository)))
}
}
}

View file

@ -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)

View file

@ -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))

View file

@ -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<Badge> 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<Badge> parseBadgeList(byte[] serializedBadgeList) {
BadgeList badgeList = null;
if (serializedBadgeList != null) {
try {
badgeList = BadgeList.parseFrom(serializedBadgeList);
} catch (InvalidProtocolBufferException e) {
Log.w(TAG, e);
}
}
List<Badge> badges;
if (badgeList != null) {
List<BadgeList.Badge> 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<Badge> 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<Badge> 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<Badge> 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<Badge> getBadges() {
return badges;
}
long getCapabilities() {
return capabilities;
}

View file

@ -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();

View file

@ -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<Badge, InputStream> {
private final OkHttpClient client;
private BadgeLoader(OkHttpClient client) {
this.client = client;
}
@Override
public @Nullable LoadData<InputStream> 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<Badge, InputStream> {
private final OkHttpClient client;
private Factory(@NonNull OkHttpClient client) {
this.client = client;
}
@Override
public @NonNull ModelLoader<Badge, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) {
return new BadgeLoader(client);
}
@Override
public void teardown() {
}
}
}

View file

@ -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> 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<SignalServiceProfile.Badge> 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<RefreshOwnProfileJob> {
@Override

View file

@ -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<SignalServiceProfile.Badge> 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)

View file

@ -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());
}

View file

@ -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() {

View file

@ -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> extras;
private final boolean hasGroupsInCommon;
private final List<Badge> 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<Badge> 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<Recipient> a, @NonNull List<Recipient> b) {

View file

@ -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<Recipient.Extras> extras;
final boolean hasGroupsInCommon;
final List<Badge> 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) {

View file

@ -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"
}

View file

@ -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<String> 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<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);

View file

@ -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<Recipient, ServiceResponse<ProfileAndCredential>> response = retrieveProfile(context, recipient, requestType, profileService).blockingGet();
Pair<Recipient, ServiceResponse<ProfileAndCredential>> 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> unidentifiedAccess = getUnidentifiedAccess(context, recipient);
return retrieveProfile(context, recipient, requestType, profileService, true);
}
private static Single<Pair<Recipient, ServiceResponse<ProfileAndCredential>>> retrieveProfile(@NonNull Context context,
@NonNull Recipient recipient,
@NonNull SignalServiceProfile.RequestType requestType,
@NonNull ProfileService profileService,
boolean allowUnidentifiedAccess)
{
Optional<UnidentifiedAccess> unidentifiedAccess = allowUnidentifiedAccess ? getUnidentifiedAccess(context, recipient) : Optional.absent();
Optional<ProfileKey> 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<Badge> 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<Badge> badges)
throws IOException
{
List<String> 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);
}

View file

@ -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";

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:innerRadius="0dp"
android:shape="ring"
android:thickness="4dp"
android:useLevel="false">
<solid android:color="@color/signal_inverse_transparent_20" />
</shape>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:innerRadius="0dp"
android:shape="ring"
android:thickness="4dp"
android:useLevel="false">
<solid android:color="@color/signal_accent_primary" />
</shape>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/selected_dot" android:state_selected="true" />
<item android:drawable="@drawable/default_dot" />
</selector>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="16dp">
<ImageView
android:id="@+id/badge"
android:layout_width="64dp"
android:layout_height="64dp"
android:importantForAccessibility="no"
android:scaleType="fitCenter"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/circle_ultramarine" />
<TextView
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4sp"
android:textAlignment="center"
android:textAppearance="@style/Signal.Text.Caption"
app:layout_constraintEnd_toEndOf="@id/badge"
app:layout_constraintStart_toStartOf="@id/badge"
app:layout_constraintTop_toBottomOf="@id/badge"
app:layout_constraintWidth_max="64dp"
tools:text="Signal Sustainer" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="0dp"
app:cardElevation="0dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="24dp"
android:layout_marginBottom="24dp">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/avatar"
android:layout_width="80dp"
android:layout_height="80dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/badge"
android:layout_width="33dp"
android:layout_height="33dp"
android:contentDescription="@string/BadgesOverviewFragment__featured_badge"
app:layout_constraintBottom_toBottomOf="@id/avatar"
app:layout_constraintEnd_toEndOf="@id/avatar" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

View file

@ -227,6 +227,53 @@
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/manage_profile_badges_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/dsl_settings_gutter"
android:paddingEnd="@dimen/dsl_settings_gutter"
android:paddingTop="16dp"
android:paddingBottom="16dp"
android:background="?selectableItemBackground"
app:layout_constraintTop_toBottomOf="@id/manage_profile_about_container">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/manage_profile_badges_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_compose_24"
app:tint="@color/signal_text_primary"
app:layout_constraintTop_toTopOf="@id/manage_profile_badges"
app:layout_constraintBottom_toBottomOf="@id/manage_profile_badges_subtitle"
app:layout_constraintStart_toStartOf="parent"/>
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/manage_profile_badges"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
style="@style/Signal.Text.Body"
android:textAlignment="viewStart"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toEndOf="@id/manage_profile_badges_icon"
app:layout_constraintEnd_toEndOf="parent"
android:text="@string/ManageProfileFragment_badges"
app:emoji_forceCustom="true" />
<TextView
android:id="@+id/manage_profile_badges_subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
style="@style/Signal.Text.Preview"
android:textColor="@color/signal_text_secondary"
app:layout_constraintTop_toBottomOf="@id/manage_profile_badges"
app:layout_constraintStart_toStartOf="@id/manage_profile_badges"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<org.thoughtcrime.securesms.util.views.LearnMoreTextView
android:id="@+id/description_text"
style="@style/Signal.Text.Preview"
@ -239,7 +286,7 @@
android:textColor="@color/signal_text_secondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/manage_profile_about_container"
app:layout_constraintTop_toBottomOf="@+id/manage_profile_badges_container"
app:layout_constraintVertical_bias="1.0" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/dsl_settings_toolbar" />
<FrameLayout
android:id="@+id/section_header_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:paddingStart="@dimen/dsl_settings_gutter"
android:paddingTop="16dp"
android:paddingEnd="@dimen/dsl_settings_gutter"
android:paddingBottom="12dp"
app:layout_constraintTop_toBottomOf="@id/toolbar">
<TextView
android:id="@+id/section_header"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/SelectFeaturedBadgeFragment__preview"
android:textAppearance="@style/TextAppearance.Signal.Body1.Bold"
android:textStyle="bold" />
</FrameLayout>
<include
android:id="@+id/preview"
layout="@layout/featured_badge_preview_preference"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/section_header_container" />
<View
android:id="@+id/scroll_shadow"
android:layout_width="match_parent"
android:layout_height="5dp"
android:alpha="0"
android:background="@drawable/toolbar_shadow"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/recycler" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingBottom="56dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/preview" />
<com.google.android.material.button.MaterialButton
android:id="@+id/save"
style="@style/Signal.Widget.Button.Large.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:minHeight="48dp"
android:text="@string/save"
app:cornerRadius="68dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ImageView
android:id="@+id/pull_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="12dp"
android:src="@drawable/bottom_sheet_handle"
tools:ignore="ContentDescription" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabBackground="@drawable/tab_selector"
app:tabGravity="center"
app:tabIndicatorHeight="0dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/action"
style="@style/Signal.Widget.Button.Large.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="48dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="48dp"
android:layout_marginBottom="32dp"
android:minHeight="48dp"
android:text="@string/ViewBadgeBottomSheetDialogFragment__become_a_sustainer"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/description" />
</LinearLayout>

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/badge"
android:layout_width="200dp"
android:layout_height="200dp"
android:contentDescription="@string/BadgesOverviewFragment__featured_badge"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/test_gradient" />
<TextView
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textAppearance="@style/TextAppearance.Signal.Title2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/badge"
tools:text="Signal Sustainer" />
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:lineSpacingExtra="6sp"
android:lines="3"
android:maxLines="3"
android:minLines="3"
android:paddingStart="32dp"
android:paddingEnd="32dp"
android:textAlignment="center"
android:textAppearance="@style/Signal.Text.Body"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/name"
tools:text="Paige supports Signal by making a monthly donation. Get your own badge by donating below." />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -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">
<action
android:id="@+id/action_manageUsername"
@ -55,6 +55,14 @@
</action>
<action
android:id="@+id/action_manageProfileFragment_to_badgeManageFragment"
app:destination="@id/badgeManageFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
</fragment>
<fragment
@ -75,6 +83,26 @@
android:label="fragment_manage_about"
tools:layout="@layout/edit_about_fragment" />
<fragment
android:id="@+id/badgeManageFragment"
android:name="org.thoughtcrime.securesms.badges.self.overview.BadgesOverviewFragment"
android:label="fragment_manage_badges" >
<action
android:id="@+id/action_badgeManageFragment_to_featuredBadgeFragment"
app:destination="@id/featuredBadgeFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
</fragment>
<fragment
android:id="@+id/featuredBadgeFragment"
android:name="org.thoughtcrime.securesms.badges.self.featured.SelectFeaturedBadgeFragment"
android:label="fragment_featured_badge" />
<include app:graph="@navigation/avatar_picker" />
</navigation>

View file

@ -775,6 +775,7 @@
<string name="ManageProfileFragment_your_name">Your name</string>
<string name="ManageProfileFragment_your_username">Your username</string>
<string name="ManageProfileFragment_failed_to_set_avatar">Failed to set avatar</string>
<string name="ManageProfileFragment_badges">Badges</string>
<!-- ManageRecipientActivity -->
<string name="ManageRecipientActivity_no_groups_in_common">No groups in common</string>
@ -3812,6 +3813,7 @@
<string name="MediaReviewFragment__add_a_message">Add a message</string>
<string name="MediaReviewFragment__add_a_reply">Add a reply</string>
<string name="MediaReviewFragment__send_to">Send to</string>
<string name="MediaReviewFragment__view_once_message">View once message</string>
<string name="ImageEditorHud__cancel">Cancel</string>
<string name="ImageEditorHud__draw">Draw</string>
@ -3822,18 +3824,30 @@
<string name="ImageEditorHud__clear_all">Clear all</string>
<string name="ImageEditorHud__undo">Undo</string>
<string name="ImageEditorHud__toggle_between_marker_and_highlighter">Toggle between marker and highlighter</string>
<string name="ImageEditorHud__delete">Delete</string>
<string name="ImageEditorHud__toggle_between_text_styles">Toggle between text styles</string>
<string name="MediaCountIndicatorButton__send">Send</string>
<string name="MediaReviewSelectedItem__tap_to_remove">Tap to remove</string>
<string name="MediaReviewSelectedItem__tap_to_select">Tap to select</string>
<string name="MediaReviewImagePageFragment__discard">Discard</string>
<string name="MediaReviewImagePageFragment__discard_changes">Discard changes?</string>
<string name="MediaReviewFragment__view_once_message">View once message</string>
<string name="MediaReviewImagePageFragment__youll_lose_any_changes">You\'ll lose any changes you\'ve made to this photo.</string>
<string name="ImageEditorHud__delete">Delete</string>
<string name="CameraFragment__failed_to_open_camera">Failed to open camera</string>
<string name="ImageEditorHud__toggle_between_text_styles">Toggle between text styles</string>
<string name="BadgesOverviewFragment__my_badges">My badges</string>
<string name="BadgesOverviewFragment__featured_badge">Featured badge</string>
<string name="BadgesOverviewFragment__display_badges_on_profile">Display badges on profile</string>
<string name="BadgesOverviewFragment__failed_to_update_profile">Failed to update profile</string>
<string name="BadgeSelectionFragment__select_badges">Select badges</string>
<string name="SelectFeaturedBadgeFragment__preview">Preview</string>
<string name="SelectFeaturedBadgeFragment__select_a_badge">Select a badge</string>
<string name="ViewBadgeBottomSheetDialogFragment__become_a_sustainer">Become a sustainer</string>
<!-- EOF -->

View file

@ -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<Badge> = emptyList()
): Recipient = Recipient(
recipientId,
RecipientDetails(
@ -128,7 +130,8 @@ object RecipientDatabaseTestUtils {
aboutEmoji,
syncExtras,
extras,
hasGroupsInCommon
hasGroupsInCommon,
badges
),
participants
),

View file

@ -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;

View file

@ -37,12 +37,6 @@
android:title="@string/draw"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_rotate_right_90"
android:icon="@drawable/ic_rotate_right_black_24dp"
android:title="@string/rotate_90_right"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_rotate_left_90"
android:icon="@drawable/ic_rotate_left_black_24dp"
@ -55,11 +49,6 @@
android:title="@string/flip_horizontal"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_flip_vertical"
android:title="@string/flip_vertical"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_edit_text"
android:title="@string/edit_text"

View file

@ -722,7 +722,8 @@ public class SignalServiceAccountManager {
String about,
String aboutEmoji,
Optional<SignalServiceProtos.PaymentAddress> paymentsAddress,
StreamDetails avatar)
StreamDetails avatar,
List<String> 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);
}

View file

@ -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<Badge> badges;
@JsonIgnore
private RequestType requestType;
@ -99,6 +105,10 @@ public class SignalServiceProfile {
return capabilities;
}
public List<Badge> 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;

View file

@ -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<String> 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<String> 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() {