diff --git a/app/build.gradle b/app/build.gradle index bd8325254..38a803422 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -368,7 +368,7 @@ android { dependencies { implementation 'androidx.core:core-ktx:1.5.0' - implementation 'androidx.fragment:fragment-ktx:1.2.5' + implementation 'androidx.fragment:fragment-ktx:1.3.5' lintChecks project(':lintchecks') coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' diff --git a/app/src/main/assets/fonts/Inter-Medium.otf b/app/src/main/assets/fonts/Inter-Medium.otf new file mode 100644 index 000000000..ca7bfcd43 Binary files /dev/null and b/app/src/main/assets/fonts/Inter-Medium.otf differ diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index d1a5e0984..d30689676 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -35,6 +35,7 @@ import org.signal.core.util.logging.Log; import org.signal.core.util.tracing.Tracer; import org.signal.glide.SignalGlideCodecs; import org.signal.ringrtc.CallManager; +import org.thoughtcrime.securesms.avatar.AvatarPickerStorage; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; @@ -152,6 +153,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr }) .addBlocking("blob-provider", this::initializeBlobProvider) .addBlocking("feature-flags", FeatureFlags::init) + .addNonBlocking(this::cleanAvatarStorage) .addNonBlocking(this::initializeRevealableMessageManager) .addNonBlocking(this::initializePendingRetryReceiptManager) .addNonBlocking(this::initializeGcmCheck) @@ -375,6 +377,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr BlobProvider.getInstance().initialize(this); } + @WorkerThread + private void cleanAvatarStorage() { + AvatarPickerStorage.cleanOrphans(this); + } + @WorkerThread private void initializeCleanup() { int deleted = DatabaseFactory.getAttachmentDatabase(this).deleteAbandonedPreuploadedAttachments(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java index 4aca9df1c..21a9cf4fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java @@ -150,6 +150,7 @@ public class DeviceActivity extends PassphraseRequiredActivity }); } + @SuppressLint("MissingSuperCall") @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index 6e3fab17e..551eda0cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -171,6 +171,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity initializeObservers(); } + @SuppressLint("MissingSuperCall") @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); diff --git a/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java b/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java index 118c9b81a..6ef67a5fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java @@ -208,6 +208,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement .execute(); } + @SuppressLint("MissingSuperCall") @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java index 0d0e7f7b0..dc343acde 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java @@ -18,6 +18,7 @@ package org.thoughtcrime.securesms; import android.Manifest; +import android.annotation.SuppressLint; import android.app.PictureInPictureParams; import android.content.Context; import android.content.Intent; @@ -180,6 +181,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan EventBus.getDefault().unregister(this); } + @SuppressLint("MissingSuperCall") @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/Avatar.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/Avatar.kt new file mode 100644 index 000000000..ae66b114c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/Avatar.kt @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.avatar + +import android.net.Uri +import org.thoughtcrime.securesms.R + +/** + * Represents an Avatar which the user can choose, edit, and render into a bitmap via the renderer. + */ +sealed class Avatar( + open val databaseId: DatabaseId +) { + data class Resource( + val resourceId: Int, + val color: Avatars.ColorPair + ) : Avatar(DatabaseId.DoNotPersist) { + override fun isSameAs(other: Avatar): Boolean { + return other is Resource && other.resourceId == resourceId + } + } + + data class Text( + val text: String, + val color: Avatars.ColorPair, + override val databaseId: DatabaseId, + ) : Avatar(databaseId) { + override fun withDatabaseId(databaseId: DatabaseId): Avatar { + return copy(databaseId = databaseId) + } + + override fun isSameAs(other: Avatar): Boolean { + return other is Text && other.databaseId == databaseId + } + } + + data class Vector( + val key: String, + val color: Avatars.ColorPair, + override val databaseId: DatabaseId, + ) : Avatar(databaseId) { + override fun withDatabaseId(databaseId: DatabaseId): Avatar { + return copy(databaseId = databaseId) + } + + override fun isSameAs(other: Avatar): Boolean { + return other is Vector && other.key == key + } + } + + data class Photo( + val uri: Uri, + val size: Long, + override val databaseId: DatabaseId + ) : Avatar(databaseId) { + override fun withDatabaseId(databaseId: DatabaseId): Avatar { + return copy(databaseId = databaseId) + } + + override fun isSameAs(other: Avatar): Boolean { + return other is Photo && databaseId == other.databaseId + } + } + + open fun withDatabaseId(databaseId: DatabaseId): Avatar { + throw UnsupportedOperationException() + } + + abstract fun isSameAs(other: Avatar): Boolean + + companion object { + fun getDefaultForSelf(): Resource = Resource(R.drawable.ic_profile_outline_40, Avatars.colors.random()) + fun getDefaultForGroup(): Resource = Resource(R.drawable.ic_group_outline_40, Avatars.colors.random()) + } + + sealed class DatabaseId { + object DoNotPersist : DatabaseId() + object NotSet : DatabaseId() + data class Saved(val id: Long) : DatabaseId() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarBundler.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarBundler.kt new file mode 100644 index 000000000..f72784247 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarBundler.kt @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.avatar + +import android.os.Bundle +import java.lang.IllegalStateException + +/** + * Utility class which encapsulates reading and writing Avatar objects to and from Bundles. + */ +object AvatarBundler { + + private const val TEXT = "org.thoughtcrime.securesms.avatar.TEXT" + private const val COLOR = "org.thoughtcrime.securesms.avatar.COLOR" + private const val URI = "org.thoughtcrime.securesms.avatar.URI" + private const val KEY = "org.thoughtcrime.securesms.avatar.KEY" + private const val DATABASE_ID = "org.thoughtcrime.securesms.avatar.DATABASE_ID" + private const val SIZE = "org.thoughtcrime.securesms.avatar.SIZE" + + fun bundleText(text: Avatar.Text): Bundle = Bundle().apply { + putString(TEXT, text.text) + putString(COLOR, text.color.code) + putDatabaseId(DATABASE_ID, text.databaseId) + } + + fun extractText(bundle: Bundle): Avatar.Text = Avatar.Text( + text = requireNotNull(bundle.getString(TEXT)), + color = Avatars.colorMap[bundle.getString(COLOR)] ?: throw IllegalStateException(), + databaseId = bundle.getDatabaseId() + ) + + fun bundlePhoto(photo: Avatar.Photo): Bundle = Bundle().apply { + putParcelable(URI, photo.uri) + putLong(SIZE, photo.size) + putDatabaseId(DATABASE_ID, photo.databaseId) + } + + fun extractPhoto(bundle: Bundle): Avatar.Photo = Avatar.Photo( + uri = requireNotNull(bundle.getParcelable(URI)), + size = bundle.getLong(SIZE), + databaseId = bundle.getDatabaseId() + ) + + fun bundleVector(vector: Avatar.Vector): Bundle = Bundle().apply { + putString(KEY, vector.key) + putString(COLOR, vector.color.code) + putDatabaseId(DATABASE_ID, vector.databaseId) + } + + fun extractVector(bundle: Bundle): Avatar.Vector = Avatar.Vector( + key = requireNotNull(bundle.getString(KEY)), + color = Avatars.colorMap[bundle.getString(COLOR)] ?: throw IllegalStateException(), + databaseId = bundle.getDatabaseId() + ) + + private fun Bundle.getDatabaseId(): Avatar.DatabaseId { + val id = getLong(DATABASE_ID, -1L) + + return if (id == -1L) { + Avatar.DatabaseId.NotSet + } else { + Avatar.DatabaseId.Saved(id) + } + } + + private fun Bundle.putDatabaseId(key: String, databaseId: Avatar.DatabaseId) { + if (databaseId is Avatar.DatabaseId.Saved) { + putLong(key, databaseId.id) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarColorItem.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarColorItem.kt new file mode 100644 index 000000000..2780ff912 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarColorItem.kt @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.avatar + +import android.view.View +import android.widget.ImageView +import com.airbnb.lottie.SimpleColorFilter +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.MappingModel +import org.thoughtcrime.securesms.util.MappingViewHolder + +typealias OnAvatarColorClickListener = (Avatars.ColorPair) -> Unit + +/** + * Selectable color item for choosing colors when editing a Text or Vector avatar. + */ +data class AvatarColorItem( + val colors: Avatars.ColorPair, + val selected: Boolean +) { + + companion object { + fun registerViewHolder(adapter: MappingAdapter, onAvatarColorClickListener: OnAvatarColorClickListener) { + adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onAvatarColorClickListener) }, R.layout.avatar_color_item)) + } + } + + class Model(val colorItem: AvatarColorItem) : MappingModel { + override fun areItemsTheSame(newItem: Model): Boolean = newItem.colorItem.colors == colorItem.colors + override fun areContentsTheSame(newItem: Model): Boolean = newItem.colorItem == colorItem + } + + private class ViewHolder(itemView: View, private val onAvatarColorClickListener: OnAvatarColorClickListener) : MappingViewHolder(itemView) { + + private val imageView: ImageView = findViewById(R.id.avatar_color_item) + + override fun bind(model: Model) { + itemView.setOnClickListener { onAvatarColorClickListener(model.colorItem.colors) } + imageView.background.colorFilter = SimpleColorFilter(model.colorItem.colors.backgroundColor) + imageView.isSelected = model.colorItem.selected + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarPickerStorage.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarPickerStorage.kt new file mode 100644 index 000000000..d3e498fec --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarPickerStorage.kt @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.avatar + +import android.content.Context +import android.net.Uri +import android.webkit.MimeTypeMap +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mms.PartAuthority +import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.storage.FileStorage +import java.io.InputStream + +object AvatarPickerStorage { + + private const val DIRECTORY = "avatar_picker" + private const val FILENAME_BASE = "avatar" + + @JvmStatic + fun read(context: Context, fileName: String) = FileStorage.read(context, DIRECTORY, fileName) + + fun save(context: Context, media: Media): Uri { + val fileName = FileStorage.save(context, PartAuthority.getAttachmentStream(context, media.uri), DIRECTORY, FILENAME_BASE, MediaUtil.getExtension(context, media.uri) ?: "") + + return PartAuthority.getAvatarPickerUri(fileName) + } + + fun save(context: Context, inputStream: InputStream): Uri { + val fileName = FileStorage.save(context, inputStream, DIRECTORY, FILENAME_BASE, MimeTypeMap.getSingleton().getExtensionFromMimeType(MediaUtil.IMAGE_JPEG) ?: "") + + return PartAuthority.getAvatarPickerUri(fileName) + } + + @JvmStatic + fun cleanOrphans(context: Context) { + val avatarFiles = FileStorage.getAllFiles(context, DIRECTORY, FILENAME_BASE) + val database = DatabaseFactory.getAvatarPickerDatabase(context) + val photoAvatars = database + .getAllAvatars() + .filterIsInstance() + + val inDatabaseFileNames = photoAvatars.map { PartAuthority.getAvatarPickerFilename(it.uri) } + val onDiskFileNames = avatarFiles.map { it.name } + + val inDatabaseButNotOnDisk = inDatabaseFileNames - onDiskFileNames + val onDiskButNotInDatabase = onDiskFileNames - inDatabaseFileNames + + avatarFiles + .filter { onDiskButNotInDatabase.contains(it.name) } + .forEach { it.delete() } + + photoAvatars + .filter { inDatabaseButNotOnDisk.contains(PartAuthority.getAvatarPickerFilename(it.uri)) } + .forEach { database.deleteAvatar(it) } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarRenderer.kt new file mode 100644 index 000000000..937db845c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarRenderer.kt @@ -0,0 +1,156 @@ +package org.thoughtcrime.securesms.avatar + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.net.Uri +import androidx.appcompat.content.res.AppCompatResources +import com.airbnb.lottie.SimpleColorFilter +import com.amulyakhare.textdrawable.TextDrawable +import org.signal.core.util.concurrent.SignalExecutors +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mms.PartAuthority +import org.thoughtcrime.securesms.profiles.AvatarHelper +import org.thoughtcrime.securesms.providers.BlobProvider +import org.thoughtcrime.securesms.util.MediaUtil +import org.whispersystems.libsignal.util.guava.Optional +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import javax.annotation.meta.Exhaustive + +/** + * Renders Avatar objects into Media objects. This can involve creating a Bitmap, depending on the + * type of Avatar passed to `renderAvatar` + */ +object AvatarRenderer { + + private val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS + + fun getTypeface(context: Context): Typeface { + return Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf") + } + + fun renderAvatar(context: Context, avatar: Avatar, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) { + @Exhaustive + when (avatar) { + is Avatar.Resource -> renderResource(context, avatar, onAvatarRendered, onRenderFailed) + is Avatar.Vector -> renderVector(context, avatar, onAvatarRendered, onRenderFailed) + is Avatar.Photo -> renderPhoto(context, avatar, onAvatarRendered) + is Avatar.Text -> renderText(context, avatar, onAvatarRendered, onRenderFailed) + } + } + + @JvmStatic + fun createTextDrawable( + context: Context, + avatar: Avatar.Text, + inverted: Boolean = false, + size: Int = DIMENSIONS, + isRect: Boolean = true + ): Drawable { + val typeface = getTypeface(context) + val color: Int = if (inverted) { + avatar.color.backgroundColor + } else { + avatar.color.foregroundColor + } + + val builder = TextDrawable + .builder() + .beginConfig() + .fontSize(Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f).toInt()) + .textColor(color) + .useFont(typeface) + .width(size) + .height(size) + .endConfig() + + return if (isRect) { + builder.buildRect(avatar.text, Color.TRANSPARENT) + } else { + builder.buildRound(avatar.text, Color.TRANSPARENT) + } + } + + private fun renderVector(context: Context, avatar: Avatar.Vector, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) { + renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas -> + val drawableResourceId = Avatars.getDrawableResource(avatar.key) ?: return@renderInBackground Result.failure(Exception("Drawable resource for key ${avatar.key} does not exist.")) + val vector: Drawable = requireNotNull(AppCompatResources.getDrawable(context, drawableResourceId)) + vector.setBounds(0, 0, DIMENSIONS, DIMENSIONS) + + canvas.drawColor(avatar.color.backgroundColor) + vector.draw(canvas) + Result.success(Unit) + } + } + + private fun renderText(context: Context, avatar: Avatar.Text, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) { + renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas -> + val textDrawable = createTextDrawable(context, avatar) + + canvas.drawColor(avatar.color.backgroundColor) + textDrawable.draw(canvas) + Result.success(Unit) + } + } + + private fun renderPhoto(context: Context, avatar: Avatar.Photo, onAvatarRendered: (Media) -> Unit) { + SignalExecutors.BOUNDED.execute { + val blob = BlobProvider.getInstance() + .forData(AvatarPickerStorage.read(context, PartAuthority.getAvatarPickerFilename(avatar.uri)), avatar.size) + .createForSingleSessionOnDisk(context) + + onAvatarRendered(createMedia(blob, avatar.size)) + } + } + + private fun renderResource(context: Context, avatar: Avatar.Resource, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) { + renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas -> + val resource: Drawable = requireNotNull(AppCompatResources.getDrawable(context, avatar.resourceId)) + resource.colorFilter = SimpleColorFilter(avatar.color.foregroundColor) + + val padding = (DIMENSIONS * 0.2).toInt() + resource.setBounds(0 + padding, 0 + padding, DIMENSIONS - padding, DIMENSIONS - padding) + + canvas.drawColor(avatar.color.backgroundColor) + resource.draw(canvas) + Result.success(Unit) + } + } + + private fun renderInBackground(context: Context, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit, drawAvatar: (Canvas) -> Result) { + SignalExecutors.BOUNDED.execute { + val canvasBitmap = Bitmap.createBitmap(DIMENSIONS, DIMENSIONS, Bitmap.Config.ARGB_8888) + val canvas = Canvas(canvasBitmap) + + val drawResult = drawAvatar(canvas) + if (drawResult.isFailure) { + canvasBitmap.recycle() + onRenderFailed(drawResult.exceptionOrNull()) + } + + val outStream = ByteArrayOutputStream() + val compressed = canvasBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outStream) + canvasBitmap.recycle() + + if (!compressed) { + onRenderFailed(IOException("Failed to compress bitmap")) + return@execute + } + + val bytes = outStream.toByteArray() + val inStream = ByteArrayInputStream(bytes) + val uri = BlobProvider.getInstance().forData(inStream, bytes.size.toLong()).createForSingleSessionOnDisk(context) + + onAvatarRendered(createMedia(uri, bytes.size.toLong())) + } + } + + private fun createMedia(uri: Uri, size: Long): Media { + return Media(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), DIMENSIONS, DIMENSIONS, size, 0, false, false, Optional.absent(), Optional.absent(), Optional.absent()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/Avatars.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/Avatars.kt new file mode 100644 index 000000000..4ffb9d5da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/Avatars.kt @@ -0,0 +1,153 @@ +package org.thoughtcrime.securesms.avatar + +import android.content.Context +import android.graphics.Paint +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.annotation.Px +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.conversation.colors.AvatarColor +import kotlin.math.abs +import kotlin.math.min + +object Avatars { + + /** + * Enum class mirroring AvatarColors codes but utilizing foreground colors for text or icon tinting. + */ + enum class ForegroundColor(private val code: String, @ColorInt val colorInt: Int) { + A100("A100", 0xFF3838F5.toInt()), + A110("A110", 0xFF1251D3.toInt()), + A120("A120", 0xFF086DA0.toInt()), + A130("A130", 0xFF067906.toInt()), + A140("A140", 0xFF661AFF.toInt()), + A150("A150", 0xFF9F00F0.toInt()), + A160("A160", 0xFFB8057C.toInt()), + A170("A170", 0xFFBE0404.toInt()), + A180("A180", 0xFF836B01.toInt()), + A190("A190", 0xFF7D6F40.toInt()), + A200("A200", 0xFF4F4F6D.toInt()), + A210("A210", 0xFF5C5C5C.toInt()); + + fun deserialize(code: String): ForegroundColor { + return values().find { it.code == code } ?: throw IllegalArgumentException() + } + + fun serialize(): String = code + } + + /** + * Mapping which associates color codes to ColorPair objects containing background and foreground colors. + */ + val colorMap: Map = ForegroundColor.values().map { + ColorPair(AvatarColor.deserialize(it.serialize()), it) + }.associateBy { + it.code + } + + val colors: List = colorMap.values.toList() + + val defaultAvatarsForSelf = linkedMapOf( + "avatar_abstract_01" to DefaultAvatar(R.drawable.ic_avatar_abstract_01, "A130"), + "avatar_abstract_02" to DefaultAvatar(R.drawable.ic_avatar_abstract_02, "A120"), + "avatar_abstract_03" to DefaultAvatar(R.drawable.ic_avatar_abstract_03, "A170"), + "avatar_cat" to DefaultAvatar(R.drawable.ic_avatar_cat, "A190"), + "avatar_dog" to DefaultAvatar(R.drawable.ic_avatar_dog, "A140"), + "avatar_fox" to DefaultAvatar(R.drawable.ic_avatar_fox, "A190"), + "avatar_tucan" to DefaultAvatar(R.drawable.ic_avatar_tucan, "A120"), + "avatar_sloth" to DefaultAvatar(R.drawable.ic_avatar_sloth, "A160"), + "avatar_dinosaur" to DefaultAvatar(R.drawable.ic_avatar_dinosour, "A130"), + "avatar_pig" to DefaultAvatar(R.drawable.ic_avatar_pig, "A180"), + "avatar_incognito" to DefaultAvatar(R.drawable.ic_avatar_incognito, "A220"), + "avatar_ghost" to DefaultAvatar(R.drawable.ic_avatar_ghost, "A100") + ) + + val defaultAvatarsForGroup = linkedMapOf( + "avatar_heart" to DefaultAvatar(R.drawable.ic_avatar_heart, "A180"), + "avatar_house" to DefaultAvatar(R.drawable.ic_avatar_house, "A120"), + "avatar_melon" to DefaultAvatar(R.drawable.ic_avatar_melon, "A110"), + "avatar_drink" to DefaultAvatar(R.drawable.ic_avatar_drink, "A170"), + "avatar_celebration" to DefaultAvatar(R.drawable.ic_avatar_celebration, "A100"), + "avatar_balloon" to DefaultAvatar(R.drawable.ic_avatar_balloon, "A220"), + "avatar_book" to DefaultAvatar(R.drawable.ic_avatar_book, "A100"), + "avatar_briefcase" to DefaultAvatar(R.drawable.ic_avatar_briefcase, "A180"), + "avatar_sunset" to DefaultAvatar(R.drawable.ic_avatar_sunset, "A120"), + "avatar_surfboard" to DefaultAvatar(R.drawable.ic_avatar_surfboard, "A110"), + "avatar_soccerball" to DefaultAvatar(R.drawable.ic_avatar_soccerball, "A130"), + "avatar_football" to DefaultAvatar(R.drawable.ic_avatar_football, "A220"), + ) + + @DrawableRes + fun getDrawableResource(key: String): Int? { + val defaultAvatar = defaultAvatarsForSelf.getOrDefault(key, defaultAvatarsForGroup[key]) + + return defaultAvatar?.vectorDrawableId + } + + private fun textPaint(context: Context) = Paint().apply { + isAntiAlias = true + typeface = AvatarRenderer.getTypeface(context) + textSize = 1f + } + + /** + * Calculate the text size for a give string using a maximum desired width and a maximum desired font size. + */ + @JvmStatic + fun getTextSizeForLength(context: Context, text: String, @Px maxWidth: Float, @Px maxSize: Float): Float { + val paint = textPaint(context) + return branchSizes(0f, maxWidth / 2, maxWidth, maxSize, paint, text) + } + + /** + * Uses binary search to determine optimal font size to within 1% given the input parameters. + */ + private fun branchSizes(@Px lastFontSize: Float, @Px fontSize: Float, @Px target: Float, @Px maxFontSize: Float, paint: Paint, text: String): Float { + paint.textSize = fontSize + val textWidth = paint.measureText(text) + val delta = abs(lastFontSize - fontSize) / 2f + val isWithinThreshold = abs(1f - (textWidth / target)) <= 0.01f + + if (textWidth == 0f) { + return maxFontSize + } + + if (delta == 0f) { + return min(maxFontSize, fontSize) + } + + return when { + fontSize >= maxFontSize -> { + maxFontSize + } + isWithinThreshold -> { + fontSize + } + textWidth > target -> { + branchSizes(fontSize, fontSize - delta, target, maxFontSize, paint, text) + } + else -> { + branchSizes(fontSize, fontSize + delta, target, maxFontSize, paint, text) + } + } + } + + @JvmStatic + fun getForegroundColor(avatarColor: AvatarColor): ForegroundColor { + return ForegroundColor.values().firstOrNull { it.serialize() == avatarColor.serialize() } ?: ForegroundColor.A210 + } + + data class DefaultAvatar( + @DrawableRes val vectorDrawableId: Int, + val colorCode: String + ) + + data class ColorPair( + val backgroundAvatarColor: AvatarColor, + val foregroundAvatarColor: ForegroundColor + ) { + @ColorInt val backgroundColor: Int = backgroundAvatarColor.colorInt() + @ColorInt val foregroundColor: Int = foregroundAvatarColor.colorInt + val code: String = backgroundAvatarColor.serialize() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/photo/PhotoEditorFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/photo/PhotoEditorFragment.kt new file mode 100644 index 000000000..8ba363062 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/photo/PhotoEditorFragment.kt @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.avatar.photo + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import androidx.fragment.app.setFragmentResult +import androidx.navigation.Navigation +import org.signal.core.util.ThreadUtil +import org.signal.core.util.concurrent.SignalExecutors +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.AvatarBundler +import org.thoughtcrime.securesms.avatar.AvatarPickerStorage +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.providers.BlobProvider +import org.thoughtcrime.securesms.scribbles.ImageEditorFragment + +class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), ImageEditorFragment.Controller { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val args = PhotoEditorFragmentArgs.fromBundle(requireArguments()) + val photo = AvatarBundler.extractPhoto(args.photoAvatar) + val imageEditorFragment = ImageEditorFragment.newInstanceForAvatarEdit(photo.uri) + + childFragmentManager.commit { + add(R.id.fragment_container, imageEditorFragment, IMAGE_EDITOR) + } + } + + override fun onTouchEventsNeeded(needed: Boolean) { + } + + override fun onRequestFullScreen(fullScreen: Boolean, hideKeyboard: Boolean) { + } + + override fun onDoneEditing() { + val args = PhotoEditorFragmentArgs.fromBundle(requireArguments()) + val applicationContext = requireContext().applicationContext + val imageEditorFragment: ImageEditorFragment = childFragmentManager.findFragmentByTag(IMAGE_EDITOR) as ImageEditorFragment + + SignalExecutors.BOUNDED.execute { + val editedImageUri = imageEditorFragment.renderToSingleUseBlob() + val size = BlobProvider.getFileSize(editedImageUri) ?: 0 + val inputStream = BlobProvider.getInstance().getStream(applicationContext, editedImageUri) + val onDiskUri = AvatarPickerStorage.save(applicationContext, inputStream) + val photo = AvatarBundler.extractPhoto(args.photoAvatar) + val database = DatabaseFactory.getAvatarPickerDatabase(applicationContext) + val newPhoto = photo.copy(uri = onDiskUri, size = size) + + database.update(newPhoto) + BlobProvider.getInstance().delete(requireContext(), photo.uri) + + ThreadUtil.runOnMain { + setFragmentResult(REQUEST_KEY_EDIT, AvatarBundler.bundlePhoto(newPhoto)) + Navigation.findNavController(requireView()).popBackStack() + } + } + } + + companion object { + const val REQUEST_KEY_EDIT = "org.thoughtcrime.securesms.avatar.photo.EDIT" + + private const val IMAGE_EDITOR = "image_editor" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt new file mode 100644 index 000000000..519c92cd9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt @@ -0,0 +1,223 @@ +package org.thoughtcrime.securesms.avatar.picker + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.widget.PopupMenu +import android.widget.Toast +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.setFragmentResultListener +import androidx.fragment.app.viewModels +import androidx.navigation.Navigation +import androidx.recyclerview.widget.RecyclerView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.Avatar +import org.thoughtcrime.securesms.avatar.AvatarBundler +import org.thoughtcrime.securesms.avatar.photo.PhotoEditorFragment +import org.thoughtcrime.securesms.avatar.text.TextAvatarCreationFragment +import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment +import org.thoughtcrime.securesms.components.ButtonStripItemView +import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration +import org.thoughtcrime.securesms.groups.ParcelableGroupId +import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.visible +import java.util.Objects + +/** + * Primary Avatar picker fragment, displays current user avatar and a list of recently used avatars and defaults. + */ +class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) { + + companion object { + const val REQUEST_KEY_SELECT_AVATAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR" + const val SELECT_AVATAR_MEDIA = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_MEDIA" + + private const val REQUEST_CODE_SELECT_IMAGE = 1 + } + + private val viewModel: AvatarPickerViewModel by viewModels(factoryProducer = this::createFactory) + + private fun createFactory(): AvatarPickerViewModel.Factory { + val args = AvatarPickerFragmentArgs.fromBundle(requireArguments()) + val groupId = ParcelableGroupId.get(args.groupId) + + return AvatarPickerViewModel.Factory(AvatarPickerRepository(requireContext()), groupId, args.isNewGroup, args.groupAvatarMedia) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val toolbar: Toolbar = view.findViewById(R.id.avatar_picker_toolbar) + val recycler: RecyclerView = view.findViewById(R.id.avatar_picker_recycler) + val cameraButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_camera) + val photoButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_photo) + val textButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_text) + val saveButton: View = view.findViewById(R.id.avatar_picker_save) + val clearButton: View = view.findViewById(R.id.avatar_picker_clear) + + recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16))) + + val adapter = MappingAdapter() + AvatarPickerItem.register(adapter, this::onAvatarClick, this::onAvatarLongClick) + + recycler.adapter = adapter + + val avatarViewHolder = AvatarPickerItem.ViewHolder(view) + + viewModel.state.observe(viewLifecycleOwner) { state -> + if (state.currentAvatar != null) { + avatarViewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false)) + } + + clearButton.visible = state.canClear + + val wasEnabled = saveButton.isEnabled + saveButton.isEnabled = state.canSave + if (wasEnabled != state.canSave) { + val alpha = if (state.canSave) 1f else 0.5f + saveButton.animate().cancel() + saveButton.animate().alpha(alpha) + } + + adapter.submitList(state.selectableAvatars.map { AvatarPickerItem.Model(it, it == state.currentAvatar) }) + } + + toolbar.setNavigationOnClickListener { Navigation.findNavController(it).popBackStack() } + cameraButton.setOnIconClickedListener { openCameraCapture() } + photoButton.setOnIconClickedListener { openGallery() } + textButton.setOnIconClickedListener { openTextEditor(null) } + saveButton.setOnClickListener { v -> + viewModel.save { + setFragmentResult( + REQUEST_KEY_SELECT_AVATAR, + Bundle().apply { + putParcelable(SELECT_AVATAR_MEDIA, it) + } + ) + Navigation.findNavController(v).popBackStack() + } + } + clearButton.setOnClickListener { viewModel.clear() } + + setFragmentResultListener(TextAvatarCreationFragment.REQUEST_KEY_TEXT) { _, bundle -> + val text = AvatarBundler.extractText(bundle) + viewModel.onAvatarEditCompleted(text) + } + + setFragmentResultListener(VectorAvatarCreationFragment.REQUEST_KEY_VECTOR) { _, bundle -> + val vector = AvatarBundler.extractVector(bundle) + viewModel.onAvatarEditCompleted(vector) + } + + setFragmentResultListener(PhotoEditorFragment.REQUEST_KEY_EDIT) { _, bundle -> + val photo = AvatarBundler.extractPhoto(bundle) + viewModel.onAvatarEditCompleted(photo) + } + } + + override fun onResume() { + super.onResume() + ViewUtil.hideKeyboard(requireContext(), requireView()) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_CODE_SELECT_IMAGE && resultCode == Activity.RESULT_OK && data != null) { + val media: Media = Objects.requireNonNull(data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA)) + viewModel.onAvatarPhotoSelectionCompleted(media) + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } + + private fun onAvatarClick(avatar: Avatar, isSelected: Boolean) { + if (isSelected) { + openEditor(avatar) + } else { + viewModel.onAvatarSelectedFromGrid(avatar) + } + } + + private fun onAvatarLongClick(anchorView: View, avatar: Avatar): Boolean { + val menuRes = when (avatar) { + is Avatar.Photo -> R.menu.avatar_picker_context + is Avatar.Text -> R.menu.avatar_picker_context + is Avatar.Vector -> return false + is Avatar.Resource -> return false + } + + val popup = PopupMenu(context, anchorView, Gravity.TOP) + popup.menuInflater.inflate(menuRes, popup.menu) + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.action_delete -> viewModel.delete(avatar) + } + + true + } + popup.show() + + return true + } + + fun openEditor(avatar: Avatar) { + when (avatar) { + is Avatar.Photo -> openPhotoEditor(avatar) + is Avatar.Resource -> throw UnsupportedOperationException() + is Avatar.Text -> openTextEditor(avatar) + is Avatar.Vector -> openVectorEditor(avatar) + } + } + + fun openPhotoEditor(photo: Avatar.Photo) { + Navigation.findNavController(requireView()) + .navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToAvatarPhotoEditorFragment(AvatarBundler.bundlePhoto(photo))) + } + + fun openVectorEditor(vector: Avatar.Vector) { + Navigation.findNavController(requireView()) + .navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToVectorAvatarCreationFragment(AvatarBundler.bundleVector(vector))) + } + + fun openTextEditor(text: Avatar.Text?) { + val bundle = if (text != null) AvatarBundler.bundleText(text) else null + Navigation.findNavController(requireView()) + .navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToTextAvatarCreationFragment(bundle)) + } + + fun openCameraCapture() { + Permissions.with(this) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .onAllGranted { + val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext()) + startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE) + } + .onAnyDenied { + Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__taking_a_photo_requires_the_camera_permission, Toast.LENGTH_SHORT) + .show() + } + .execute() + } + + fun openGallery() { + Permissions.with(this) + .request(Manifest.permission.READ_EXTERNAL_STORAGE) + .ifNecessary() + .onAllGranted { + val intent = AvatarSelectionActivity.getIntentForGallery(requireContext()) + startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE) + } + .onAnyDenied { + Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__viewing_your_gallery_requires_the_storage_permission, Toast.LENGTH_SHORT) + .show() + } + .execute() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerItem.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerItem.kt new file mode 100644 index 000000000..65e27cbfa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerItem.kt @@ -0,0 +1,150 @@ +package org.thoughtcrime.securesms.avatar.picker + +import android.util.TypedValue +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.view.setPadding +import androidx.core.widget.addTextChangedListener +import com.airbnb.lottie.SimpleColorFilter +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.Avatar +import org.thoughtcrime.securesms.avatar.AvatarRenderer +import org.thoughtcrime.securesms.avatar.Avatars +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.MappingModel +import org.thoughtcrime.securesms.util.MappingViewHolder +import org.thoughtcrime.securesms.util.visible + +typealias OnAvatarClickListener = (Avatar, Boolean) -> Unit +typealias OnAvatarLongClickListener = (View, Avatar) -> Boolean + +object AvatarPickerItem { + + private val SELECTION_CHANGED = Any() + + fun register(adapter: MappingAdapter, onAvatarClickListener: OnAvatarClickListener, onAvatarLongClickListener: OnAvatarLongClickListener) { + adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onAvatarClickListener, onAvatarLongClickListener) }, R.layout.avatar_picker_item)) + } + + class Model(val avatar: Avatar, val isSelected: Boolean) : MappingModel { + override fun areItemsTheSame(newItem: Model): Boolean = avatar.isSameAs(newItem.avatar) + + override fun areContentsTheSame(newItem: Model): Boolean = avatar == newItem.avatar && isSelected == newItem.isSelected + + override fun getChangePayload(newItem: Model): Any? { + return if (newItem.avatar == avatar && isSelected != newItem.isSelected) { + SELECTION_CHANGED + } else { + null + } + } + } + + class ViewHolder( + itemView: View, + private val onAvatarClickListener: OnAvatarClickListener? = null, + private val onAvatarLongClickListener: OnAvatarLongClickListener? = null + ) : MappingViewHolder(itemView) { + + private val imageView: ImageView = itemView.findViewById(R.id.avatar_picker_item_image) + private val textView: TextView = itemView.findViewById(R.id.avatar_picker_item_text) + private val selectedFader: View? = itemView.findViewById(R.id.avatar_picker_item_fader) + private val selectedOverlay: View? = itemView.findViewById(R.id.avatar_picker_item_selection_overlay) + + init { + textView.typeface = AvatarRenderer.getTypeface(context) + textView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + updateTextSize() + } + textView.addTextChangedListener( + afterTextChanged = { + updateTextSize() + } + ) + } + + private fun updateTextSize() { + textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, Avatars.getTextSizeForLength(context, textView.text.toString(), textView.measuredWidth * 0.8f, textView.measuredHeight * 0.45f)) + } + + override fun bind(model: Model) { + val alpha = if (model.isSelected) 1f else 0f + val scale = if (model.isSelected) 0.9f else 1f + + imageView.animate().cancel() + textView.animate().cancel() + selectedOverlay?.animate()?.cancel() + selectedFader?.animate()?.cancel() + + if (model.isSelected) { + itemView.setOnLongClickListener { + onAvatarLongClickListener?.invoke(itemView, model.avatar) ?: false + } + } else { + itemView.setOnLongClickListener(null) + } + + itemView.setOnClickListener { onAvatarClickListener?.invoke(model.avatar, model.isSelected) } + + if (payload.isNotEmpty() && payload.contains(SELECTION_CHANGED)) { + imageView.animate().scaleX(scale).scaleY(scale) + textView.animate().scaleX(scale).scaleY(scale) + selectedOverlay?.animate()?.alpha(alpha) + selectedFader?.animate()?.alpha(alpha) + return + } + + imageView.scaleX = scale + imageView.scaleY = scale + textView.scaleX = scale + textView.scaleY = scale + selectedFader?.alpha = alpha + selectedOverlay?.alpha = alpha + + imageView.clearColorFilter() + imageView.setPadding(0) + + when (model.avatar) { + is Avatar.Text -> { + textView.visible = true + + if (textView.text.toString() != model.avatar.text) { + textView.text = model.avatar.text + } + + imageView.setImageDrawable(null) + imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor) + textView.setTextColor(model.avatar.color.foregroundColor) + } + is Avatar.Vector -> { + textView.visible = false + + val drawableId = Avatars.getDrawableResource(model.avatar.key) + if (drawableId == null) { + imageView.setImageDrawable(null) + } else { + imageView.setImageDrawable(AppCompatResources.getDrawable(context, drawableId)) + } + + imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor) + } + is Avatar.Photo -> { + textView.visible = false + GlideApp.with(imageView).load(DecryptableStreamUriLoader.DecryptableUri(model.avatar.uri)).into(imageView) + } + is Avatar.Resource -> { + imageView.setPadding((imageView.width * 0.2).toInt()) + textView.visible = false + GlideApp.with(imageView).clear(imageView) + imageView.setImageResource(model.avatar.resourceId) + imageView.colorFilter = SimpleColorFilter(model.avatar.color.foregroundColor) + imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor) + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerRepository.kt new file mode 100644 index 000000000..36443a1f5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerRepository.kt @@ -0,0 +1,175 @@ +package org.thoughtcrime.securesms.avatar.picker + +import android.content.Context +import android.net.Uri +import android.widget.Toast +import io.reactivex.rxjava3.core.Single +import org.signal.core.util.StreamUtil +import org.signal.core.util.ThreadUtil +import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.Avatar +import org.thoughtcrime.securesms.avatar.AvatarPickerStorage +import org.thoughtcrime.securesms.avatar.AvatarRenderer +import org.thoughtcrime.securesms.avatar.Avatars +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.profiles.AvatarHelper +import org.thoughtcrime.securesms.providers.BlobProvider +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.NameUtil +import org.whispersystems.signalservice.api.util.StreamDetails +import java.io.IOException + +private val TAG = Log.tag(AvatarPickerRepository::class.java) + +class AvatarPickerRepository(context: Context) { + + private val applicationContext = context.applicationContext + + fun getAvatarForSelf(): Single = Single.fromCallable { + val details: StreamDetails? = AvatarHelper.getSelfProfileAvatarStream(applicationContext) + if (details != null) { + try { + val bytes = StreamUtil.readFully(details.stream) + Avatar.Photo( + BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(), + details.length, + Avatar.DatabaseId.DoNotPersist + ) + } catch (e: IOException) { + Log.w(TAG, "Failed to read avatar!") + getDefaultAvatarForSelf() + } + } else { + getDefaultAvatarForSelf() + } + } + + fun getAvatarForGroup(groupId: GroupId): Single = Single.fromCallable { + val recipient = Recipient.externalGroupExact(applicationContext, groupId) + + if (AvatarHelper.hasAvatar(applicationContext, recipient.id)) { + try { + val bytes = AvatarHelper.getAvatarBytes(applicationContext, recipient.id) + Avatar.Photo( + BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(), + AvatarHelper.getAvatarLength(applicationContext, recipient.id), + Avatar.DatabaseId.DoNotPersist + ) + } catch (e: IOException) { + Log.w(TAG, "Failed to read group avatar!") + getDefaultAvatarForGroup() + } + } else { + getDefaultAvatarForGroup() + } + } + + fun getPersistedAvatarsForSelf(): Single> = Single.fromCallable { + DatabaseFactory.getAvatarPickerDatabase(applicationContext).getAvatarsForSelf() + } + + fun getPersistedAvatarsForGroup(groupId: GroupId): Single> = Single.fromCallable { + DatabaseFactory.getAvatarPickerDatabase(applicationContext).getAvatarsForGroup(groupId) + } + + fun getDefaultAvatarsForSelf(): Single> = Single.fromCallable { + Avatars.defaultAvatarsForSelf.entries.mapIndexed { index, entry -> + Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet) + } + } + + fun getDefaultAvatarsForGroup(): Single> = Single.fromCallable { + Avatars.defaultAvatarsForGroup.entries.mapIndexed { index, entry -> + Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet) + } + } + + fun writeMediaToMultiSessionStorage(media: Media, onMediaWrittenToMultiSessionStorage: (Uri) -> Unit) { + SignalExecutors.BOUNDED.execute { + onMediaWrittenToMultiSessionStorage(AvatarPickerStorage.save(applicationContext, media)) + } + } + + fun persistAvatarForSelf(avatar: Avatar, onPersisted: (Avatar) -> Unit) { + SignalExecutors.BOUNDED.execute { + val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext) + val savedAvatar = avatarDatabase.saveAvatarForSelf(avatar) + avatarDatabase.markUsage(savedAvatar) + onPersisted(savedAvatar) + } + } + + fun persistAvatarForGroup(avatar: Avatar, groupId: GroupId, onPersisted: (Avatar) -> Unit) { + SignalExecutors.BOUNDED.execute { + val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext) + val savedAvatar = avatarDatabase.saveAvatarForGroup(avatar, groupId) + avatarDatabase.markUsage(savedAvatar) + onPersisted(savedAvatar) + } + } + + fun persistAndCreateMediaForSelf(avatar: Avatar, onSaved: (Media) -> Unit) { + SignalExecutors.BOUNDED.execute { + if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) { + persistAvatarForSelf(avatar) { + AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure) + } + } else { + AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure) + } + } + } + + fun persistAndCreateMediaForGroup(avatar: Avatar, groupId: GroupId, onSaved: (Media) -> Unit) { + SignalExecutors.BOUNDED.execute { + if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) { + persistAvatarForGroup(avatar, groupId) { + AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure) + } + } else { + AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure) + } + } + } + + fun createMediaForNewGroup(avatar: Avatar, onSaved: (Media) -> Unit) { + SignalExecutors.BOUNDED.execute { + AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure) + } + } + + fun handleRenderFailure(throwable: Throwable?) { + Log.w(TAG, "Failed to render avatar.", throwable) + ThreadUtil.postToMain { + Toast.makeText(applicationContext, R.string.AvatarPickerRepository__failed_to_save_avatar, Toast.LENGTH_SHORT).show() + } + } + + fun getDefaultAvatarForSelf(): Avatar { + val initials = NameUtil.getAbbreviation(Recipient.self().getDisplayName(applicationContext)) + + return if (initials.isNullOrBlank()) { + Avatar.getDefaultForSelf() + } else { + Avatar.Text(initials, Avatars.colors.random(), Avatar.DatabaseId.NotSet) + } + } + + fun getDefaultAvatarForGroup(): Avatar { + return Avatar.getDefaultForGroup() + } + + fun delete(avatar: Avatar, onDelete: () -> Unit) { + SignalExecutors.BOUNDED.execute { + if (avatar.databaseId is Avatar.DatabaseId.Saved) { + val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext) + avatarDatabase.deleteAvatar(avatar) + } + onDelete() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerState.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerState.kt new file mode 100644 index 000000000..e58d166e8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerState.kt @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.avatar.picker + +import org.thoughtcrime.securesms.avatar.Avatar + +data class AvatarPickerState( + val currentAvatar: Avatar? = null, + val selectableAvatars: List = listOf(), + val canSave: Boolean = false, + val canClear: Boolean = false +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerViewModel.kt new file mode 100644 index 000000000..436a0f3d1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerViewModel.kt @@ -0,0 +1,193 @@ +package org.thoughtcrime.securesms.avatar.picker + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.avatar.Avatar +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.util.livedata.Store + +sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepository) : ViewModel() { + + private val disposables = CompositeDisposable() + private val store = Store(AvatarPickerState()) + + val state: LiveData = store.stateLiveData + + protected abstract fun getAvatar(): Single + protected abstract fun getDefaultAvatarFromRepository(): Avatar + protected abstract fun getPersistedAvatars(): Single> + protected abstract fun getDefaultAvatars(): Single> + protected abstract fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) + protected abstract fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) + + fun delete(avatar: Avatar) { + repository.delete(avatar) { + refreshSelectableAvatars() + } + } + + fun clear() { + store.update { + val avatar = getDefaultAvatarFromRepository() + it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = false) + } + } + + fun save(onSaved: (Media) -> Unit) { + val avatar = store.state.currentAvatar ?: throw AssertionError() + persistAndCreateMedia(avatar, onSaved) + } + + fun onAvatarSelectedFromGrid(avatar: Avatar) { + store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true) } + } + + fun onAvatarEditCompleted(avatar: Avatar) { + persistAvatar(avatar) { saved -> + store.update { it.copy(currentAvatar = saved, canSave = isSaveable(saved), canClear = true) } + refreshSelectableAvatars() + } + } + + fun onAvatarPhotoSelectionCompleted(media: Media) { + repository.writeMediaToMultiSessionStorage(media) { multiSessionUri -> + persistAvatar(Avatar.Photo(multiSessionUri, media.size, Avatar.DatabaseId.NotSet)) { avatar -> + store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true) } + refreshSelectableAvatars() + } + } + } + + protected fun refreshAvatar() { + disposables.add( + getAvatar().subscribeOn(Schedulers.io()).subscribe { avatar -> + store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = !isSaveable(avatar)) } + } + ) + } + + protected fun refreshSelectableAvatars() { + disposables.add( + Single.zip(getPersistedAvatars(), getDefaultAvatars()) { custom, def -> + val customKeys = custom.filterIsInstance(Avatar.Vector::class.java).map { it.key } + custom + def.filterNot { + it is Avatar.Vector && customKeys.contains(it.key) + } + }.subscribeOn(Schedulers.io()).subscribe { avatars -> + store.update { it.copy(selectableAvatars = avatars) } + } + ) + } + + private fun isSaveable(avatar: Avatar) = !(avatar is Avatar.Photo && avatar.databaseId == Avatar.DatabaseId.DoNotPersist) + + override fun onCleared() { + disposables.dispose() + } + + private class SelfAvatarPickerViewModel(private val repository: AvatarPickerRepository) : AvatarPickerViewModel(repository) { + + init { + refreshAvatar() + refreshSelectableAvatars() + } + + override fun getAvatar(): Single = repository.getAvatarForSelf() + override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForSelf() + override fun getPersistedAvatars(): Single> = repository.getPersistedAvatarsForSelf() + override fun getDefaultAvatars(): Single> = repository.getDefaultAvatarsForSelf() + + override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) { + repository.persistAvatarForSelf(avatar, onPersisted) + } + + override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) { + repository.persistAndCreateMediaForSelf(avatar, onSaved) + } + } + + private class GroupAvatarPickerViewModel( + private val groupId: GroupId, + private val repository: AvatarPickerRepository, + groupAvatarMedia: Media? + ) : AvatarPickerViewModel(repository) { + + private val initialAvatar: Avatar? = groupAvatarMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) } + + init { + refreshAvatar() + refreshSelectableAvatars() + } + + override fun getAvatar(): Single { + return if (initialAvatar != null) { + Single.just(initialAvatar) + } else { + repository.getAvatarForGroup(groupId) + } + } + + override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup() + override fun getPersistedAvatars(): Single> = repository.getPersistedAvatarsForGroup(groupId) + override fun getDefaultAvatars(): Single> = repository.getDefaultAvatarsForGroup() + + override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) { + repository.persistAvatarForGroup(avatar, groupId, onPersisted) + } + + override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) { + repository.persistAndCreateMediaForGroup(avatar, groupId, onSaved) + } + } + + private class NewGroupAvatarPickerViewModel( + private val repository: AvatarPickerRepository, + initialMedia: Media? + ) : AvatarPickerViewModel(repository) { + + private val initialAvatar: Avatar? = initialMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) } + + init { + refreshAvatar() + refreshSelectableAvatars() + } + + override fun getAvatar(): Single { + return if (initialAvatar != null) { + Single.just(initialAvatar) + } else { + Single.just(getDefaultAvatarFromRepository()) + } + } + + override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup() + override fun getPersistedAvatars(): Single> = Single.just(listOf()) + override fun getDefaultAvatars(): Single> = repository.getDefaultAvatarsForGroup() + override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) = onPersisted(avatar) + override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) = repository.createMediaForNewGroup(avatar, onSaved) + } + + class Factory( + private val repository: AvatarPickerRepository, + private val groupId: GroupId?, + private val isNewGroup: Boolean, + private val groupAvatarMedia: Media? + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + val viewModel = if (groupId == null && !isNewGroup) { + SelfAvatarPickerViewModel(repository) + } else if (groupId == null) { + NewGroupAvatarPickerViewModel(repository, groupAvatarMedia) + } else { + GroupAvatarPickerViewModel(groupId, repository, groupAvatarMedia) + } + + return requireNotNull(modelClass.cast(viewModel)) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationFragment.kt new file mode 100644 index 000000000..aae39d6a4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationFragment.kt @@ -0,0 +1,148 @@ +package org.thoughtcrime.securesms.avatar.text + +import android.os.Bundle +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import androidx.appcompat.widget.Toolbar +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.navigation.Navigation +import androidx.recyclerview.widget.RecyclerView +import androidx.transition.TransitionManager +import com.google.android.material.tabs.TabLayout +import org.signal.core.util.EditTextUtil +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.Avatar +import org.thoughtcrime.securesms.avatar.AvatarBundler +import org.thoughtcrime.securesms.avatar.AvatarColorItem +import org.thoughtcrime.securesms.avatar.Avatars +import org.thoughtcrime.securesms.avatar.picker.AvatarPickerItem +import org.thoughtcrime.securesms.components.BoldSelectionTabItem +import org.thoughtcrime.securesms.components.ControllableTabLayout +import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.ViewUtil + +/** + * Fragment to create an avatar based off of a Vector or Text (via a pager) + */ +class TextAvatarCreationFragment : Fragment(R.layout.text_avatar_creation_fragment_hidden_recycler) { + + private val viewModel: TextAvatarCreationViewModel by viewModels(factoryProducer = this::createFactory) + + private lateinit var textInput: EditText + private lateinit var recycler: RecyclerView + + private val withRecyclerSet = ConstraintSet() + private val withoutRecyclerSet = ConstraintSet() + + private fun createFactory(): TextAvatarCreationViewModel.Factory { + val args = TextAvatarCreationFragmentArgs.fromBundle(requireArguments()) + val textBundle = args.textAvatar + val text = if (textBundle != null) { + AvatarBundler.extractText(textBundle) + } else { + Avatar.Text("", Avatars.colors.random(), Avatar.DatabaseId.NotSet) + } + + return TextAvatarCreationViewModel.Factory(text) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val toolbar: Toolbar = view.findViewById(R.id.text_avatar_creation_toolbar) + val tabLayout: ControllableTabLayout = view.findViewById(R.id.text_avatar_creation_tabs) + val doneButton: View = view.findViewById(R.id.text_avatar_creation_done) + + withRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment) + withoutRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment_hidden_recycler) + + recycler = view.findViewById(R.id.text_avatar_creation_recycler) + textInput = view.findViewById(R.id.avatar_picker_item_text) + + toolbar.setNavigationOnClickListener { Navigation.findNavController(it).popBackStack() } + BoldSelectionTabItem.registerListeners(tabLayout) + + val onTabSelectedListener = OnTabSelectedListener() + tabLayout.addOnTabSelectedListener(onTabSelectedListener) + onTabSelectedListener.onTabSelected(requireNotNull(tabLayout.getTabAt(tabLayout.selectedTabPosition))) + + val adapter = MappingAdapter() + recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16))) + AvatarColorItem.registerViewHolder(adapter) { + viewModel.setColor(it) + } + recycler.adapter = adapter + + val viewHolder = AvatarPickerItem.ViewHolder(view) + viewModel.state.observe(viewLifecycleOwner) { state -> + EditTextUtil.setCursorColor(textInput, state.currentAvatar.color.foregroundColor) + + val hadText = textInput.length() > 0 + viewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false)) + if (!hadText) { + textInput.setSelection(textInput.length()) + } + + adapter.submitList(state.colors().map { AvatarColorItem.Model(it) }) + } + + EditTextUtil.addGraphemeClusterLimitFilter(textInput, 3) + textInput.doAfterTextChanged { + if (it != null) { + viewModel.setText(it.toString()) + } + } + + doneButton.setOnClickListener { v -> + setFragmentResult(REQUEST_KEY_TEXT, AvatarBundler.bundleText(viewModel.getCurrentAvatar())) + Navigation.findNavController(v).popBackStack() + } + + textInput.setOnEditorActionListener { v, actionId, event -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + doneButton.performClick() + true + } else { + false + } + } + } + + private inner class OnTabSelectedListener : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + when (tab.position) { + 0 -> { + textInput.isEnabled = true + ViewUtil.focusAndShowKeyboard(textInput) + + val constraintLayout = requireView() as ConstraintLayout + TransitionManager.endTransitions(constraintLayout) + withoutRecyclerSet.applyTo(constraintLayout) + TransitionManager.beginDelayedTransition(constraintLayout) + textInput.setSelection(textInput.length()) + } + 1 -> { + textInput.isEnabled = false + ViewUtil.hideKeyboard(requireContext(), textInput) + + val constraintLayout = requireView() as ConstraintLayout + TransitionManager.endTransitions(constraintLayout) + withRecyclerSet.applyTo(constraintLayout) + TransitionManager.beginDelayedTransition(constraintLayout) + } + } + } + + override fun onTabUnselected(tab: TabLayout.Tab?) = Unit + override fun onTabReselected(tab: TabLayout.Tab?) = Unit + } + + companion object { + const val REQUEST_KEY_TEXT = "org.thoughtcrime.securesms.avatar.text.TEXT" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationState.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationState.kt new file mode 100644 index 000000000..52493ad3a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationState.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.avatar.text + +import org.thoughtcrime.securesms.avatar.Avatar +import org.thoughtcrime.securesms.avatar.AvatarColorItem +import org.thoughtcrime.securesms.avatar.Avatars + +data class TextAvatarCreationState( + val currentAvatar: Avatar.Text, +) { + fun colors(): List = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationViewModel.kt new file mode 100644 index 000000000..891afd51c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationViewModel.kt @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.avatar.text + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import org.thoughtcrime.securesms.avatar.Avatar +import org.thoughtcrime.securesms.avatar.Avatars +import org.thoughtcrime.securesms.util.livedata.Store + +class TextAvatarCreationViewModel(initialText: Avatar.Text) : ViewModel() { + + private val store = Store(TextAvatarCreationState(initialText)) + + val state: LiveData = store.stateLiveData + + fun setColor(colorPair: Avatars.ColorPair) { + store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) } + } + + fun setText(text: String) { + store.update { it.copy(currentAvatar = it.currentAvatar.copy(text = text)) } + } + + fun getCurrentAvatar(): Avatar.Text { + return store.state.currentAvatar + } + + class Factory(private val initialText: Avatar.Text) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return requireNotNull(modelClass.cast(TextAvatarCreationViewModel(initialText))) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/vector/VectorAvatarCreationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/vector/VectorAvatarCreationFragment.kt new file mode 100644 index 000000000..802ecd7d5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/vector/VectorAvatarCreationFragment.kt @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.avatar.vector + +import android.os.Bundle +import android.view.View +import android.widget.ImageView +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.navigation.Navigation +import androidx.recyclerview.widget.RecyclerView +import com.airbnb.lottie.SimpleColorFilter +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.AvatarBundler +import org.thoughtcrime.securesms.avatar.AvatarColorItem +import org.thoughtcrime.securesms.avatar.Avatars +import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.ViewUtil + +/** + * Fragment to create an avatar based off a default vector. + */ +class VectorAvatarCreationFragment : Fragment(R.layout.vector_avatar_creation_fragment) { + + private val viewModel: VectorAvatarCreationViewModel by viewModels(factoryProducer = this::createFactory) + + private fun createFactory(): VectorAvatarCreationViewModel.Factory { + val args = VectorAvatarCreationFragmentArgs.fromBundle(requireArguments()) + val vectorBundle = args.vectorAvatar + + return VectorAvatarCreationViewModel.Factory(AvatarBundler.extractVector(vectorBundle)) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val toolbar: Toolbar = view.findViewById(R.id.vector_avatar_creation_toolbar) + val recycler: RecyclerView = view.findViewById(R.id.vector_avatar_creation_recycler) + val doneButton: View = view.findViewById(R.id.vector_avatar_creation_done) + val preview: ImageView = view.findViewById(R.id.vector_avatar_creation_image) + + val adapter = MappingAdapter() + recycler.adapter = adapter + recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16))) + AvatarColorItem.registerViewHolder(adapter) { + viewModel.setColor(it) + } + + viewModel.state.observe(viewLifecycleOwner) { state -> + preview.background.colorFilter = SimpleColorFilter(state.currentAvatar.color.backgroundColor) + preview.setImageResource(requireNotNull(Avatars.getDrawableResource(state.currentAvatar.key))) + adapter.submitList(state.colors().map { AvatarColorItem.Model(it) }) + } + + toolbar.setNavigationOnClickListener { Navigation.findNavController(view).popBackStack() } + doneButton.setOnClickListener { + setFragmentResult(REQUEST_KEY_VECTOR, AvatarBundler.bundleVector(viewModel.getCurrentAvatar())) + Navigation.findNavController(it).popBackStack() + } + } + + companion object { + const val REQUEST_KEY_VECTOR = "org.thoughtcrime.securesms.avatar.text.VECTOR" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/vector/VectorAvatarCreationState.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/vector/VectorAvatarCreationState.kt new file mode 100644 index 000000000..20da33108 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/vector/VectorAvatarCreationState.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.avatar.vector + +import org.thoughtcrime.securesms.avatar.Avatar +import org.thoughtcrime.securesms.avatar.AvatarColorItem +import org.thoughtcrime.securesms.avatar.Avatars + +data class VectorAvatarCreationState( + val currentAvatar: Avatar.Vector, +) { + fun colors(): List = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/vector/VectorAvatarCreationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/vector/VectorAvatarCreationViewModel.kt new file mode 100644 index 000000000..de5cee8db --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/vector/VectorAvatarCreationViewModel.kt @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.avatar.vector + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import org.thoughtcrime.securesms.avatar.Avatar +import org.thoughtcrime.securesms.avatar.Avatars +import org.thoughtcrime.securesms.util.livedata.Store + +class VectorAvatarCreationViewModel(initialAvatar: Avatar.Vector) : ViewModel() { + + private val store = Store(VectorAvatarCreationState(initialAvatar)) + + val state: LiveData = store.stateLiveData + + fun setColor(colorPair: Avatars.ColorPair) { + store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) } + } + + fun getCurrentAvatar() = store.state.currentAvatar + + class Factory(private val initialAvatar: Avatar.Vector) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return requireNotNull(modelClass.cast(VectorAvatarCreationViewModel(initialAvatar))) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java index 8025dc393..9ab63d0a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java @@ -106,7 +106,7 @@ public final class AvatarImageView extends AppCompatImageView { outlinePaint = ThemeUtil.isDarkTheme(context) ? DARK_THEME_OUTLINE_PAINT : LIGHT_THEME_OUTLINE_PAINT; - unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(context, AvatarColor.UNKNOWN.colorInt(), inverted); + unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(context, AvatarColor.UNKNOWN, inverted); blurred = false; chatColors = null; } @@ -248,7 +248,7 @@ public final class AvatarImageView extends AppCompatImageView { requestManager.clear(this); if (fallbackPhotoProvider != null) { setImageDrawable(fallbackPhotoProvider.getPhotoForRecipientWithoutName() - .asDrawable(getContext(), AvatarColor.UNKNOWN.colorInt(), inverted)); + .asDrawable(getContext(), AvatarColor.UNKNOWN, inverted)); } else { setImageDrawable(unknownRecipientDrawable); } @@ -285,7 +285,7 @@ public final class AvatarImageView extends AppCompatImageView { { Drawable fallback = Util.firstNonNull(fallbackPhotoProvider, Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER) .getPhotoForGroup() - .asDrawable(getContext(), color.colorInt()); + .asDrawable(getContext(), color); GlideApp.with(this) .load(avatarBytes) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/BoldSelectionTabItem.kt b/app/src/main/java/org/thoughtcrime/securesms/components/BoldSelectionTabItem.kt new file mode 100644 index 000000000..6912d415f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/BoldSelectionTabItem.kt @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.components + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import android.widget.TextView +import androidx.core.widget.doAfterTextChanged +import com.google.android.material.tabs.TabLayout +import org.thoughtcrime.securesms.R +import java.util.Objects + +/** + * Custom View for Tabs which will render bold text when the view is selected + */ +class BoldSelectionTabItem @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private lateinit var unselectedTextView: TextView + private lateinit var selectedTextView: TextView + + override fun onFinishInflate() { + super.onFinishInflate() + + unselectedTextView = findViewById(android.R.id.text1) + selectedTextView = findViewById(R.id.text1_bold) + + unselectedTextView.doAfterTextChanged { + selectedTextView.text = it + } + } + + fun select() { + unselectedTextView.alpha = 0f + selectedTextView.alpha = 1f + } + + fun unselect() { + unselectedTextView.alpha = 1f + selectedTextView.alpha = 0f + } + + companion object { + @JvmStatic + fun registerListeners(tabLayout: ControllableTabLayout) { + val newTabListener = NewTabListener() + val onTabSelectedListener = OnTabSelectedListener() + + (0 until tabLayout.tabCount).mapNotNull { tabLayout.getTabAt(it) }.forEach { + newTabListener.onNewTab(it) + + if (it.isSelected) { + onTabSelectedListener.onTabSelected(it) + } else { + onTabSelectedListener.onTabUnselected(it) + } + } + + tabLayout.setNewTabListener(newTabListener) + tabLayout.addOnTabSelectedListener(onTabSelectedListener) + } + } + + private class NewTabListener : ControllableTabLayout.NewTabListener { + override fun onNewTab(tab: TabLayout.Tab) { + val customView = tab.customView + if (customView == null) { + tab.setCustomView(R.layout.bold_selection_tab_item) + } + } + } + + private class OnTabSelectedListener : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + val view = Objects.requireNonNull(tab.customView) as BoldSelectionTabItem + view.select() + } + + override fun onTabUnselected(tab: TabLayout.Tab) { + val view = Objects.requireNonNull(tab.customView) as BoldSelectionTabItem + view.unselect() + } + + override fun onTabReselected(tab: TabLayout.Tab) = Unit + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ButtonStripItemView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ButtonStripItemView.kt new file mode 100644 index 000000000..9f94331cb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ButtonStripItemView.kt @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.components + +import android.content.Context +import android.util.AttributeSet +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import org.thoughtcrime.securesms.R + +class ButtonStripItemView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val iconView: ImageView + private val labelView: TextView + + init { + inflate(context, R.layout.button_strip_item_view, this) + + iconView = findViewById(R.id.icon) + labelView = findViewById(R.id.label) + + val array = context.obtainStyledAttributes(attrs, R.styleable.ButtonStripItemView) + + val icon = array.getDrawable(R.styleable.ButtonStripItemView_bsiv_icon) + val contentDescription = array.getString(R.styleable.ButtonStripItemView_bsiv_icon_contentDescription) + val label = array.getString(R.styleable.ButtonStripItemView_bsiv_label) + + iconView.setImageDrawable(icon) + iconView.contentDescription = contentDescription + labelView.text = label + + array.recycle() + } + + fun setOnIconClickedListener(onIconClickedListener: (() -> Unit)?) { + iconView.setOnClickListener { onIconClickedListener?.invoke() } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/GridDividerDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/GridDividerDecoration.kt new file mode 100644 index 000000000..1c70de409 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/GridDividerDecoration.kt @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.components.recyclerview + +import android.graphics.Rect +import android.view.View +import androidx.annotation.Px +import androidx.recyclerview.widget.RecyclerView +import org.thoughtcrime.securesms.util.ViewUtil + +/** + * Decoration which will add an equal amount of space between each item in a grid. + */ +open class GridDividerDecoration( + private val spanCount: Int, + @Px private val space: Int +) : RecyclerView.ItemDecoration() { + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + return setItemOffsets(parent.getChildAdapterPosition(view), view, outRect) + } + + protected fun setItemOffsets(position: Int, view: View, outRect: Rect) { + val column = position % spanCount + val isRtl = ViewUtil.isRtl(view) + + val distanceFromEnd = spanCount - 1 - column + + val spaceStart = (column / spanCount.toFloat()) * space + val spaceEnd = (distanceFromEnd / spanCount.toFloat()) * space + + outRect.setStart(spaceStart.toInt(), isRtl) + outRect.setEnd(spaceEnd.toInt(), isRtl) + outRect.bottom = space + } + + private fun Rect.setEnd(end: Int, isRtl: Boolean) { + if (isRtl) { + left = end + } else { + right = end + } + } + + private fun Rect.setStart(start: Int, isRtl: Boolean) { + if (isRtl) { + right = start + } else { + left = start + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackContactPhoto.java index 9a4e265c6..6ee00d7fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackContactPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackContactPhoto.java @@ -3,11 +3,15 @@ package org.thoughtcrime.securesms.contacts.avatars; import android.content.Context; import android.graphics.drawable.Drawable; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.conversation.colors.AvatarColor; + public interface FallbackContactPhoto { - public Drawable asDrawable(Context context, int color); - public Drawable asDrawable(Context context, int color, boolean inverted); - public Drawable asSmallDrawable(Context context, int color, boolean inverted); - public Drawable asCallCard(Context context); + Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color); + Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted); + Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted); + Drawable asCallCard(@NonNull Context context); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto20dp.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto20dp.java index 96c535e03..fe2630b73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto20dp.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto20dp.java @@ -10,6 +10,8 @@ import androidx.appcompat.content.res.AppCompatResources; import androidx.core.graphics.drawable.DrawableCompat; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.avatar.Avatars; +import org.thoughtcrime.securesms.conversation.colors.AvatarColor; import org.thoughtcrime.securesms.util.ViewUtil; import java.util.Objects; @@ -26,33 +28,33 @@ public final class FallbackPhoto20dp implements FallbackContactPhoto { } @Override - public Drawable asDrawable(Context context, int color) { + public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color) { return buildDrawable(context, color); } @Override - public Drawable asDrawable(Context context, int color, boolean inverted) { + public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) { return buildDrawable(context, color); } @Override - public Drawable asSmallDrawable(Context context, int color, boolean inverted) { + public Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) { return buildDrawable(context, color); } @Override - public Drawable asCallCard(Context context) { + public Drawable asCallCard(@NonNull Context context) { throw new UnsupportedOperationException(); } - private @NonNull Drawable buildDrawable(@NonNull Context context, int color) { + private @NonNull Drawable buildDrawable(@NonNull Context context, @NonNull AvatarColor color) { Drawable background = DrawableCompat.wrap(Objects.requireNonNull(AppCompatResources.getDrawable(context, R.drawable.circle_tintable))).mutate(); - Drawable foreground = AppCompatResources.getDrawable(context, drawable20dp); - Drawable gradient = AppCompatResources.getDrawable(context, R.drawable.avatar_gradient); - LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground, gradient}); + Drawable foreground = Objects.requireNonNull(AppCompatResources.getDrawable(context, drawable20dp)); + LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground}); int foregroundInset = ViewUtil.dpToPx(2); - DrawableCompat.setTint(background, color); + DrawableCompat.setTint(background, color.colorInt()); + DrawableCompat.setTint(foreground, Avatars.getForegroundColor(color).getColorInt()); drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto80dp.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto80dp.java index f4c2b64cd..4d19fe5fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto80dp.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto80dp.java @@ -1,57 +1,55 @@ package org.thoughtcrime.securesms.contacts.avatars; import android.content.Context; -import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; -import android.graphics.drawable.GradientDrawable; import android.graphics.drawable.LayerDrawable; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.appcompat.content.res.AppCompatResources; -import androidx.core.content.ContextCompat; import androidx.core.graphics.drawable.DrawableCompat; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.avatar.Avatars; +import org.thoughtcrime.securesms.conversation.colors.AvatarColor; import org.thoughtcrime.securesms.util.ViewUtil; import java.util.Objects; public final class FallbackPhoto80dp implements FallbackContactPhoto { - @DrawableRes private final int drawable80dp; - private final int backgroundColor; + @DrawableRes private final int drawable80dp; + private final AvatarColor color; - public FallbackPhoto80dp(@DrawableRes int drawable80dp, int backgroundColor) { - this.drawable80dp = drawable80dp; - this.backgroundColor = backgroundColor; + public FallbackPhoto80dp(@DrawableRes int drawable80dp, @NonNull AvatarColor color) { + this.drawable80dp = drawable80dp; + this.color = color; } @Override - public Drawable asDrawable(Context context, int color) { + public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color) { return buildDrawable(context); } @Override - public Drawable asDrawable(Context context, int color, boolean inverted) { + public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) { return buildDrawable(context); } @Override - public Drawable asSmallDrawable(Context context, int color, boolean inverted) { + public Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) { throw new UnsupportedOperationException(); } @Override - public Drawable asCallCard(Context context) { - Drawable background = new ColorDrawable(backgroundColor); - Drawable foreground = AppCompatResources.getDrawable(context, drawable80dp); - int transparent20 = ContextCompat.getColor(context, R.color.signal_transparent_20); - Drawable gradient = new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, new int[]{ Color.TRANSPARENT, transparent20 }); - LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground, gradient}); + public Drawable asCallCard(@NonNull Context context) { + Drawable background = new ColorDrawable(color.colorInt()); + Drawable foreground = Objects.requireNonNull(AppCompatResources.getDrawable(context, drawable80dp)); + LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground}); int foregroundInset = ViewUtil.dpToPx(24); + DrawableCompat.setTint(foreground, Avatars.getForegroundColor(color).getColorInt()); drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset); return drawable; @@ -59,12 +57,12 @@ public final class FallbackPhoto80dp implements FallbackContactPhoto { private @NonNull Drawable buildDrawable(@NonNull Context context) { Drawable background = DrawableCompat.wrap(Objects.requireNonNull(AppCompatResources.getDrawable(context, R.drawable.circle_tintable))).mutate(); - Drawable foreground = AppCompatResources.getDrawable(context, drawable80dp); - Drawable gradient = AppCompatResources.getDrawable(context, R.drawable.avatar_gradient); - LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground, gradient}); + Drawable foreground = Objects.requireNonNull(AppCompatResources.getDrawable(context, drawable80dp)); + LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground}); int foregroundInset = ViewUtil.dpToPx(24); - DrawableCompat.setTint(background, backgroundColor); + DrawableCompat.setTint(background, color.colorInt()); + DrawableCompat.setTint(foreground, Avatars.getForegroundColor(color).getColorInt()); drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java index eae791436..35f4d4464 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java @@ -1,79 +1,72 @@ package org.thoughtcrime.securesms.contacts.avatars; import android.content.Context; -import android.graphics.Color; -import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; import android.text.TextUtils; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.content.ContextCompat; -import com.amulyakhare.textdrawable.TextDrawable; +import com.airbnb.lottie.SimpleColorFilter; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.avatar.Avatar; +import org.thoughtcrime.securesms.avatar.AvatarRenderer; +import org.thoughtcrime.securesms.avatar.Avatars; +import org.thoughtcrime.securesms.conversation.colors.AvatarColor; import org.thoughtcrime.securesms.util.ContextUtil; -import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.NameUtil; -import java.util.regex.Pattern; +import java.util.Objects; public class GeneratedContactPhoto implements FallbackContactPhoto { - private static final Pattern PATTERN = Pattern.compile("[^\\p{L}\\p{Nd}\\p{S}]+"); - private static final Typeface TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL); - private final String name; private final int fallbackResId; private final int targetSize; - private final int fontSize; public GeneratedContactPhoto(@NonNull String name, @DrawableRes int fallbackResId) { - this(name, fallbackResId, -1, ViewUtil.dpToPx(24)); + this(name, fallbackResId, -1); } - public GeneratedContactPhoto(@NonNull String name, @DrawableRes int fallbackResId, int targetSize, int fontSize) { + public GeneratedContactPhoto(@NonNull String name, @DrawableRes int fallbackResId, int targetSize) { this.name = name; this.fallbackResId = fallbackResId; this.targetSize = targetSize; - this.fontSize = fontSize; } @Override - public Drawable asDrawable(Context context, int color) { + public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color) { return asDrawable(context, color,false); } @Override - public Drawable asDrawable(Context context, int color, boolean inverted) { + public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) { int targetSize = this.targetSize != -1 ? this.targetSize : context.getResources().getDimensionPixelSize(R.dimen.contact_photo_target_size); - String character = getAbbreviation(name); + String character = NameUtil.getAbbreviation(name); if (!TextUtils.isEmpty(character)) { - Drawable base = TextDrawable.builder() - .beginConfig() - .width(targetSize) - .height(targetSize) - .useFont(TYPEFACE) - .fontSize(fontSize) - .textColor(inverted ? color : Color.WHITE) - .endConfig() - .buildRound(character, inverted ? Color.WHITE : color); + Avatars.ForegroundColor foregroundColor = Avatars.getForegroundColor(color); + Avatar.Text avatar = new Avatar.Text(character, new Avatars.ColorPair(color, foregroundColor), Avatar.DatabaseId.DoNotPersist.INSTANCE); + Drawable foreground = AvatarRenderer.createTextDrawable(context, avatar, inverted, targetSize, false); + Drawable background = Objects.requireNonNull(ContextCompat.getDrawable(context, R.drawable.circle_tintable)); - Drawable gradient = ContextUtil.requireDrawable(context, R.drawable.avatar_gradient); - return new LayerDrawable(new Drawable[] { base, gradient }); + background.setColorFilter(new SimpleColorFilter(inverted ? foregroundColor.getColorInt() : color.colorInt())); + + return new LayerDrawable(new Drawable[] { background, foreground }); } return newFallbackDrawable(context, color, inverted); } @Override - public Drawable asSmallDrawable(Context context, int color, boolean inverted) { + public Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) { return asDrawable(context, color, inverted); } @@ -81,32 +74,12 @@ public class GeneratedContactPhoto implements FallbackContactPhoto { return fallbackResId; } - protected Drawable newFallbackDrawable(@NonNull Context context, int color, boolean inverted) { + protected Drawable newFallbackDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) { return new ResourceContactPhoto(fallbackResId).asDrawable(context, color, inverted); } - private @Nullable String getAbbreviation(String name) { - String[] parts = name.split(" "); - StringBuilder builder = new StringBuilder(); - int count = 0; - - for (int i = 0; i < parts.length && count < 2; i++) { - String cleaned = PATTERN.matcher(parts[i]).replaceFirst(""); - if (!TextUtils.isEmpty(cleaned)) { - builder.appendCodePoint(cleaned.codePointAt(0)); - count++; - } - } - - if (builder.length() == 0) { - return null; - } else { - return builder.toString(); - } - } - @Override - public Drawable asCallCard(Context context) { + public Drawable asCallCard(@NonNull Context context) { return AppCompatResources.getDrawable(context, R.drawable.ic_person_large); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java index 272310d5e..63e3cb763 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.contacts.avatars; import android.content.Context; -import android.graphics.Color; import android.graphics.PorterDuff; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; @@ -15,8 +14,9 @@ import androidx.appcompat.content.res.AppCompatResources; import com.amulyakhare.textdrawable.TextDrawable; import com.makeramen.roundedimageview.RoundedDrawable; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.util.ContextUtil; +import org.jetbrains.annotations.NotNull; +import org.thoughtcrime.securesms.avatar.Avatars; +import org.thoughtcrime.securesms.conversation.colors.AvatarColor; public class ResourceContactPhoto implements FallbackContactPhoto { @@ -45,38 +45,34 @@ public class ResourceContactPhoto implements FallbackContactPhoto { } @Override - public @NonNull Drawable asDrawable(@NonNull Context context, int color) { + public @NonNull Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color) { return asDrawable(context, color, false); } @Override - public @NonNull Drawable asDrawable(@NonNull Context context, int color, boolean inverted) { + public @NonNull Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) { return buildDrawable(context, resourceId, color, inverted); } @Override - public @NonNull Drawable asSmallDrawable(@NonNull Context context, int color, boolean inverted) { + public @NonNull Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) { return buildDrawable(context, smallResourceId, color, inverted); } - private @NonNull Drawable buildDrawable(@NonNull Context context, int resourceId, int color, boolean inverted) { - Drawable background = TextDrawable.builder().buildRound(" ", inverted ? Color.WHITE : color); - RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(AppCompatResources.getDrawable(context, resourceId)); + private @NonNull Drawable buildDrawable(@NonNull Context context, int resourceId, @NonNull AvatarColor color, boolean inverted) { + Avatars.ForegroundColor foregroundColor = Avatars.getForegroundColor(color); + Drawable background = TextDrawable.builder().buildRound(" ", inverted ? foregroundColor.getColorInt() : color.colorInt()); + RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(AppCompatResources.getDrawable(context, resourceId)); //noinspection ConstantConditions foreground.setScaleType(scaleType); + foreground.setColorFilter(inverted ? color.colorInt() : foregroundColor.getColorInt(), PorterDuff.Mode.SRC_ATOP); - if (inverted) { - foreground.setColorFilter(color, PorterDuff.Mode.SRC_ATOP); - } - - Drawable gradient = ContextUtil.requireDrawable(context, R.drawable.avatar_gradient); - - return new ExpandingLayerDrawable(new Drawable[] {background, foreground, gradient}); + return new ExpandingLayerDrawable(new Drawable[] {background, foreground}); } @Override - public @Nullable Drawable asCallCard(@NonNull Context context) { + public @Nullable Drawable asCallCard(@NotNull @NonNull Context context) { return AppCompatResources.getDrawable(context, callCardResourceId); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/TransparentContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/TransparentContactPhoto.java index f38e6f0f1..6046d025a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/TransparentContactPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/TransparentContactPhoto.java @@ -3,33 +3,36 @@ package org.thoughtcrime.securesms.contacts.avatars; import android.content.Context; import android.graphics.drawable.Drawable; +import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; import com.makeramen.roundedimageview.RoundedDrawable; +import org.jetbrains.annotations.NotNull; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.colors.AvatarColor; public class TransparentContactPhoto implements FallbackContactPhoto { public TransparentContactPhoto() {} @Override - public Drawable asDrawable(Context context, int color) { + public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color) { return asDrawable(context, color, false); } @Override - public Drawable asDrawable(Context context, int color, boolean inverted) { + public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) { return RoundedDrawable.fromDrawable(context.getResources().getDrawable(android.R.color.transparent)); } @Override - public Drawable asSmallDrawable(Context context, int color, boolean inverted) { + public Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) { return asDrawable(context, color, inverted); } @Override - public Drawable asCallCard(Context context) { + public Drawable asCallCard(@NonNull Context context) { return ContextCompat.getDrawable(context, R.drawable.ic_contact_picture_large); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 529518fb7..38cc54fb5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -1129,6 +1129,7 @@ public class ConversationActivity extends PassphraseRequiredActivity updateReminders(); } + @SuppressLint("MissingSuperCall") @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); @@ -1302,7 +1303,7 @@ public class ConversationActivity extends PassphraseRequiredActivity GlideApp.with(this) .asBitmap() .load(recipient.getContactPhoto()) - .error(recipient.getFallbackContactPhoto().asDrawable(this, recipient.getAvatarColor().colorInt(), false)) + .error(recipient.getFallbackContactPhoto().asDrawable(this, recipient.getAvatarColor(), false)) .into(new CustomTarget() { @Override public void onLoadFailed(@Nullable Drawable errorDrawable) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/AvatarColor.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/AvatarColor.java index ca1231eab..5ae92eabf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/AvatarColor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/AvatarColor.java @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.colors; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.util.HashMap; import java.util.Map; @@ -11,56 +12,20 @@ import java.util.Objects; * A serializable set of color constants that can be used for avatars. */ public enum AvatarColor { - C000("C000", 0xFFD00B0B), - C010("C010", 0xFFC72A0A), - C020("C020", 0xFFB34209), - C030("C030", 0xFF9C5711), - C040("C040", 0xFF866118), - C050("C050", 0xFF76681E), - C060("C060", 0xFF6C6C13), - C070("C070", 0xFF5E6E0C), - C080("C080", 0xFF507406), - C090("C090", 0xFF3D7406), - C100("C100", 0xFF2D7906), - C110("C110", 0xFF1A7906), - C120("C120", 0xFF067906), - C130("C130", 0xFF067919), - C140("C140", 0xFF06792D), - C150("C150", 0xFF067940), - C160("C160", 0xFF067953), - C170("C170", 0xFF067462), - C180("C180", 0xFF067474), - C190("C190", 0xFF077288), - C200("C200", 0xFF086DA0), - C210("C210", 0xFF0A69C7), - C220("C220", 0xFF0D59F2), - C230("C230", 0xFF3454F4), - C240("C240", 0xFF5151F6), - C250("C250", 0xFF6447F5), - C260("C260", 0xFF7A3DF5), - C270("C270", 0xFF8F2AF4), - C280("C280", 0xFFA20CED), - C290("C290", 0xFFAF0BD0), - C300("C300", 0xFFB80AB8), - C310("C310", 0xFFC20AA3), - C320("C320", 0xFFC70A88), - C330("C330", 0xFFCB0B6B), - C340("C340", 0xFFD00B4D), - C350("C350", 0xFFD00B2C), - CRIMSON("crimson", ChatColorsPalette.Bubbles.CRIMSON.asSingleColor()), - VERMILLION("vermillion", ChatColorsPalette.Bubbles.VERMILION.asSingleColor()), - BURLAP("burlap", ChatColorsPalette.Bubbles.BURLAP.asSingleColor()), - FOREST("forest", ChatColorsPalette.Bubbles.FOREST.asSingleColor()), - WINTERGREEN("wintergreen", ChatColorsPalette.Bubbles.WINTERGREEN.asSingleColor()), - TEAL("teal", ChatColorsPalette.Bubbles.TEAL.asSingleColor()), - BLUE("blue", ChatColorsPalette.Bubbles.BLUE.asSingleColor()), - INDIGO("indigo", ChatColorsPalette.Bubbles.INDIGO.asSingleColor()), - VIOLET("violet", ChatColorsPalette.Bubbles.VIOLET.asSingleColor()), - PLUM("plum", ChatColorsPalette.Bubbles.PLUM.asSingleColor()), - TAUPE("taupe", ChatColorsPalette.Bubbles.TAUPE.asSingleColor()), - STEEL("steel", ChatColorsPalette.Bubbles.STEEL.asSingleColor()), - ULTRAMARINE("ultramarine", ChatColorsPalette.Bubbles.ULTRAMARINE.asSingleColor()), - UNKNOWN("unknown", ChatColorsPalette.Bubbles.STEEL.asSingleColor()); + A100("A100", 0xFFE3E3FE), + A110("A110", 0xFFDDE7FC), + A120("A120", 0xFFD8E8F0), + A130("A130", 0xFFCDE4CD), + A140("A140", 0xFFEAE0F8), + A150("A150", 0xFFF5E3FE), + A160("A160", 0xFFF6D8EC), + A170("A170", 0xFFF5D7D7), + A180("A180", 0xFFFEF5D0), + A190("A190", 0xFFEAE6D5), + A200("A200", 0xFFD2D2DC), + A210("A210", 0xFFD7D7D9); + + public static final AvatarColor UNKNOWN = A210; /** Fast map of name to enum, while also giving us a location to map old colors to new ones. */ private static final Map NAME_MAP = new HashMap<>(); @@ -69,61 +34,83 @@ public enum AvatarColor { NAME_MAP.put(color.serialize(), color); } - NAME_MAP.put("red", CRIMSON); - NAME_MAP.put("orange", VERMILLION); - NAME_MAP.put("deep_orange", VERMILLION); - NAME_MAP.put("brown", BURLAP); - NAME_MAP.put("green", FOREST); - NAME_MAP.put("light_green", WINTERGREEN); - NAME_MAP.put("teal", TEAL); - NAME_MAP.put("blue", BLUE); - NAME_MAP.put("indigo", INDIGO); - NAME_MAP.put("purple", VIOLET); - NAME_MAP.put("deep_purple", VIOLET); - NAME_MAP.put("pink", PLUM); - NAME_MAP.put("blue_grey", TAUPE); - NAME_MAP.put("grey", STEEL); - NAME_MAP.put("ultramarine", ULTRAMARINE); + NAME_MAP.put("C020", A170); + NAME_MAP.put("C030", A170); + NAME_MAP.put("C040", A180); + NAME_MAP.put("C050", A180); + NAME_MAP.put("C000", A190); + NAME_MAP.put("C060", A190); + NAME_MAP.put("C070", A190); + NAME_MAP.put("C080", A130); + NAME_MAP.put("C090", A130); + NAME_MAP.put("C100", A130); + NAME_MAP.put("C110", A130); + NAME_MAP.put("C120", A130); + NAME_MAP.put("C130", A130); + NAME_MAP.put("C140", A130); + NAME_MAP.put("C150", A130); + NAME_MAP.put("C160", A130); + NAME_MAP.put("C170", A120); + NAME_MAP.put("C180", A120); + NAME_MAP.put("C190", A120); + NAME_MAP.put("C200", A110); + NAME_MAP.put("C210", A110); + NAME_MAP.put("C220", A110); + NAME_MAP.put("C230", A100); + NAME_MAP.put("C240", A100); + NAME_MAP.put("C250", A100); + NAME_MAP.put("C260", A100); + NAME_MAP.put("C270", A140); + NAME_MAP.put("C280", A140); + NAME_MAP.put("C290", A140); + NAME_MAP.put("C300", A150); + NAME_MAP.put("C010", A170); + NAME_MAP.put("C310", A150); + NAME_MAP.put("C320", A150); + NAME_MAP.put("C330", A160); + NAME_MAP.put("C340", A160); + NAME_MAP.put("C350", A160); + NAME_MAP.put("crimson", A170); + NAME_MAP.put("vermillion", A170); + NAME_MAP.put("burlap", A190); + NAME_MAP.put("forest", A130); + NAME_MAP.put("wintergreen", A130); + NAME_MAP.put("teal", A120); + NAME_MAP.put("blue", A110); + NAME_MAP.put("indigo", A100); + NAME_MAP.put("violet", A140); + NAME_MAP.put("plum", A150); + NAME_MAP.put("taupe", A190); + NAME_MAP.put("steel", A210); + NAME_MAP.put("ultramarine", A100); + NAME_MAP.put("unknown", A210); + NAME_MAP.put("red", A170); + NAME_MAP.put("orange", A170); + NAME_MAP.put("deep_orange", A170); + NAME_MAP.put("brown", A190); + NAME_MAP.put("green", A130); + NAME_MAP.put("light_green", A130); + NAME_MAP.put("purple", A140); + NAME_MAP.put("deep_purple", A140); + NAME_MAP.put("pink", A150); + NAME_MAP.put("blue_grey", A190); + NAME_MAP.put("grey", A210); } /** Colors that can be assigned via {@link #random()}. */ private static final AvatarColor[] RANDOM_OPTIONS = new AvatarColor[] { - C000, - C010, - C020, - C030, - C040, - C050, - C060, - C070, - C080, - C090, - C100, - C110, - C120, - C130, - C140, - C150, - C160, - C170, - C180, - C190, - C200, - C210, - C220, - C230, - C240, - C250, - C260, - C270, - C280, - C290, - C300, - C310, - C320, - C330, - C340, - C350, + A100, + A110, + A120, + A130, + A140, + A150, + A160, + A170, + A180, + A190, + A200, + A210 }; private final String name; @@ -148,6 +135,6 @@ public enum AvatarColor { } public static @NonNull AvatarColor deserialize(@NonNull String name) { - return Objects.requireNonNull(NAME_MAP.getOrDefault(name, C000)); + return Objects.requireNonNull(NAME_MAP.getOrDefault(name, A210)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java index 39b1c257c..54ab1ac8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java @@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.helpers.ClassicOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherMigrationHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.AvatarPickerDatabase; import org.thoughtcrime.securesms.migrations.LegacyMigrationJob; import org.thoughtcrime.securesms.util.SqlUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -70,6 +71,7 @@ public class DatabaseFactory { private final ChatColorsDatabase chatColorsDatabase; private final EmojiSearchDatabase emojiSearchDatabase; private final MessageSendLogDatabase messageSendLogDatabase; + private final AvatarPickerDatabase avatarPickerDatabase; public static DatabaseFactory getInstance(Context context) { if (instance == null) { @@ -200,6 +202,10 @@ public class DatabaseFactory { return getInstance(context).messageSendLogDatabase; } + public static AvatarPickerDatabase getAvatarPickerDatabase(Context context) { + return getInstance(context).avatarPickerDatabase; + } + public static SQLiteDatabase getBackupDatabase(Context context) { return getInstance(context).databaseHelper.getReadableDatabase().getSqlCipherDatabase(); } @@ -259,8 +265,9 @@ public class DatabaseFactory { this.mentionDatabase = new MentionDatabase(context, databaseHelper); this.paymentDatabase = new PaymentDatabase(context, databaseHelper); this.chatColorsDatabase = new ChatColorsDatabase(context, databaseHelper); - this.emojiSearchDatabase = new EmojiSearchDatabase(context, databaseHelper); - this.messageSendLogDatabase = new MessageSendLogDatabase(context, databaseHelper); + this.emojiSearchDatabase = new EmojiSearchDatabase(context, databaseHelper); + this.messageSendLogDatabase = new MessageSendLogDatabase(context, databaseHelper); + this.avatarPickerDatabase = new AvatarPickerDatabase(context, databaseHelper); } public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index decf51ced..4653c9730 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -57,6 +57,7 @@ import org.thoughtcrime.securesms.database.SqlCipherErrorHandler; import org.thoughtcrime.securesms.database.StickerDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.UnknownStorageIdDatabase; +import org.thoughtcrime.securesms.database.model.AvatarPickerDatabase; import org.thoughtcrime.securesms.database.model.databaseprotos.ReactionList; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; @@ -207,8 +208,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab private static final int THREAD_AUTOINCREMENT = 108; private static final int MMS_AUTOINCREMENT = 109; private static final int ABANDONED_ATTACHMENT_CLEANUP = 110; + private static final int AVATAR_PICKER = 111; - private static final int DATABASE_VERSION = 110; + private static final int DATABASE_VERSION = 111; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -245,6 +247,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab db.execSQL(PaymentDatabase.CREATE_TABLE); db.execSQL(ChatColorsDatabase.CREATE_TABLE); db.execSQL(EmojiSearchDatabase.CREATE_TABLE); + db.execSQL(AvatarPickerDatabase.CREATE_TABLE); executeStatements(db, SearchDatabase.CREATE_TABLE); executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE); executeStatements(db, MessageSendLogDatabase.CREATE_TABLE); @@ -1934,6 +1937,24 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab db.delete("part", "mid != -8675309 AND mid NOT IN (SELECT _id FROM mms)", null); } + if (oldVersion < AVATAR_PICKER) { + db.execSQL("CREATE TABLE avatar_picker (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "last_used INTEGER DEFAULT 0, " + + "group_id TEXT DEFAULT NULL, " + + "avatar BLOB NOT NULL)"); + + try (Cursor cursor = db.query("recipient", new String[] { "_id" }, "color IS NULL", null, null, null, null)) { + while (cursor.moveToNext()) { + long id = cursor.getInt(cursor.getColumnIndexOrThrow("_id")); + + ContentValues values = new ContentValues(1); + values.put("color", AvatarColor.random().serialize()); + + db.update("recipient", values, "_id = ?", new String[] { String.valueOf(id) }); + } + } + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/AvatarPickerDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/AvatarPickerDatabase.kt new file mode 100644 index 000000000..d284de23e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/AvatarPickerDatabase.kt @@ -0,0 +1,185 @@ +package org.thoughtcrime.securesms.database.model + +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import org.thoughtcrime.securesms.avatar.Avatar +import org.thoughtcrime.securesms.avatar.Avatars +import org.thoughtcrime.securesms.database.Database +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.database.model.databaseprotos.CustomAvatar +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.util.CursorUtil +import org.thoughtcrime.securesms.util.SqlUtil + +/** + * Database which manages the record keeping for custom created avatars. + */ +class AvatarPickerDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Database(context, databaseHelper) { + + companion object { + private const val TABLE_NAME = "avatar_picker" + private const val ID = "_id" + private const val LAST_USED = "last_used" + private const val GROUP_ID = "group_id" + private const val AVATAR = "avatar" + + //language=sql + @JvmField + val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY AUTOINCREMENT, + $LAST_USED INTEGER DEFAULT 0, + $GROUP_ID TEXT DEFAULT NULL, + $AVATAR BLOB NOT NULL + ) + """.trimIndent() + } + + fun saveAvatarForSelf(avatar: Avatar): Avatar { + return saveAvatar(avatar, null) + } + + fun saveAvatarForGroup(avatar: Avatar, groupId: GroupId): Avatar { + return saveAvatar(avatar, groupId) + } + + fun markUsage(avatar: Avatar) { + val databaseId = avatar.databaseId + if (databaseId !is Avatar.DatabaseId.Saved) { + throw IllegalArgumentException("Must save this avatar before trying to mark usage.") + } + + val db = databaseHelper.writableDatabase + val where = ID_WHERE + val args = SqlUtil.buildArgs(databaseId.id) + val values = ContentValues(1) + + values.put(LAST_USED, System.currentTimeMillis()) + db.update(TABLE_NAME, values, where, args) + } + + fun update(avatar: Avatar) { + val databaseId = avatar.databaseId + if (databaseId !is Avatar.DatabaseId.Saved) { + throw IllegalArgumentException("Cannot update an unsaved avatar") + } + + val db = databaseHelper.writableDatabase + val where = ID_WHERE + val values = ContentValues(1) + + values.put(AVATAR, avatar.toProto().toByteArray()) + db.update(TABLE_NAME, values, where, SqlUtil.buildArgs(databaseId.id)) + } + + fun deleteAvatar(avatar: Avatar) { + val databaseId = avatar.databaseId + if (databaseId !is Avatar.DatabaseId.Saved) { + throw IllegalArgumentException("Cannot delete an unsaved avatar.") + } + + val db = databaseHelper.writableDatabase + val where = ID_WHERE + val args = SqlUtil.buildArgs(databaseId.id) + + db.delete(TABLE_NAME, where, args) + } + + private fun saveAvatar(avatar: Avatar, groupId: GroupId?): Avatar { + val db = databaseHelper.writableDatabase + val databaseId = avatar.databaseId + + if (databaseId is Avatar.DatabaseId.DoNotPersist) { + throw IllegalArgumentException("Cannot persist this avatar") + } + + if (databaseId is Avatar.DatabaseId.Saved) { + val values = ContentValues(2) + values.put(AVATAR, avatar.toProto().toByteArray()) + + db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(databaseId.id)) + + return avatar + } else { + val values = ContentValues(4) + values.put(AVATAR, avatar.toProto().toByteArray()) + + if (groupId != null) { + values.put(GROUP_ID, groupId.toString()) + } + + val id = db.insert(TABLE_NAME, null, values) + if (id == -1L) { + throw AssertionError("Failed to save avatar") + } + + return avatar.withDatabaseId(Avatar.DatabaseId.Saved(id)) + } + } + + fun getAllAvatars(): List { + val db = databaseHelper.readableDatabase + val results = mutableListOf() + + db.query(TABLE_NAME, SqlUtil.buildArgs(ID, AVATAR), null, null, null, null, null)?.use { + while (it.moveToNext()) { + val id = CursorUtil.requireLong(it, ID) + val blob = CursorUtil.requireBlob(it, AVATAR) + val proto = CustomAvatar.parseFrom(blob) + results.add(proto.toAvatar(id)) + } + } + + return results + } + + fun getAvatarsForSelf(): List { + return getAvatars(null) + } + + fun getAvatarsForGroup(groupId: GroupId): List { + return getAvatars(groupId) + } + + private fun getAvatars(groupId: GroupId?): List { + val db = databaseHelper.readableDatabase + val orderBy = "$LAST_USED DESC" + val results = mutableListOf() + + val (where, args) = if (groupId == null) { + Pair("$GROUP_ID is NULL", null) + } else { + Pair("$GROUP_ID = ?", SqlUtil.buildArgs(groupId)) + } + + db.query(TABLE_NAME, SqlUtil.buildArgs(ID, AVATAR), where, args, null, null, orderBy)?.use { + while (it.moveToNext()) { + val id = CursorUtil.requireLong(it, ID) + val blob = CursorUtil.requireBlob(it, AVATAR) + val proto = CustomAvatar.parseFrom(blob) + results.add(proto.toAvatar(id)) + } + } + + return results + } + + private fun Avatar.toProto(): CustomAvatar { + return when (this) { + is Avatar.Photo -> CustomAvatar.newBuilder().setPhoto(CustomAvatar.Photo.newBuilder().setUri(this.uri.toString())).build() + is Avatar.Text -> CustomAvatar.newBuilder().setText(CustomAvatar.Text.newBuilder().setText(this.text).setColors(this.color.code)).build() + is Avatar.Vector -> CustomAvatar.newBuilder().setVector(CustomAvatar.Vector.newBuilder().setKey(this.key).setColors(this.color.code)).build() + else -> throw AssertionError() + } + } + + private fun CustomAvatar.toAvatar(id: Long): Avatar { + return when { + hasPhoto() -> Avatar.Photo(Uri.parse(photo.uri), photo.size, Avatar.DatabaseId.Saved(id)) + hasText() -> Avatar.Text(text.text, Avatars.colorMap[text.colors] ?: Avatars.colors[0], Avatar.DatabaseId.Saved(id)) + hasVector() -> Avatar.Vector(vector.key, Avatars.colorMap[vector.colors] ?: Avatars.colors[0], Avatar.DatabaseId.Saved(id)) + else -> throw AssertionError() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsActivity.java index 638323ce0..85ac7b4ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsActivity.java @@ -9,6 +9,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.navigation.NavGraph; import androidx.navigation.Navigation; +import androidx.navigation.fragment.NavHostFragment; import org.thoughtcrime.securesms.PassphraseRequiredActivity; import org.thoughtcrime.securesms.R; @@ -46,9 +47,11 @@ public class AddGroupDetailsActivity extends PassphraseRequiredActivity implemen if (bundle == null) { ArrayList recipientIds = getIntent().getParcelableArrayListExtra(EXTRA_RECIPIENTS); AddGroupDetailsFragmentArgs arguments = new AddGroupDetailsFragmentArgs.Builder(recipientIds.toArray(new RecipientId[0])).build(); - NavGraph graph = Navigation.findNavController(this, R.id.nav_host_fragment).getGraph(); + NavHostFragment fragment = NavHostFragment.create(R.navigation.create_group, arguments.toBundle()); - Navigation.findNavController(this, R.id.nav_host_fragment).setGraph(graph, arguments.toBundle()); + getSupportFragmentManager().beginTransaction() + .replace(R.id.nav_host_fragment, fragment) + .commit(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java index adc8ea6bd..5f4587620 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java @@ -18,25 +18,26 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; -import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.Toolbar; import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.Navigation; import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.request.target.CustomTarget; import com.bumptech.glide.request.transition.Transition; import com.dd.CircularProgressButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.signal.core.util.EditTextUtil; import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.avatar.picker.AvatarPickerFragment; import org.thoughtcrime.securesms.components.settings.app.privacy.expire.ExpireTimerSettingsFragment; import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; import org.thoughtcrime.securesms.groups.ui.creategroup.dialogs.NonGv2MemberDialog; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity; -import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFragment; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; import org.thoughtcrime.securesms.mms.GlideApp; @@ -58,7 +59,6 @@ import java.util.Objects; public class AddGroupDetailsFragment extends LoggingFragment { private static final int AVATAR_PLACEHOLDER_INSET_DP = 18; - private static final short REQUEST_CODE_AVATAR = 27621; private static final short REQUEST_DISAPPEARING_TIMER = 28621; private CircularProgressButton create; @@ -112,7 +112,7 @@ public class AddGroupDetailsFragment extends LoggingFragment { initializeViewModel(); - avatar.setOnClickListener(v -> showAvatarSelectionBottomSheet()); + avatar.setOnClickListener(v -> showAvatarPicker()); members.setRecipientClickListener(this::handleRecipientClick); EditTextUtil.addGraphemeClusterLimitFilter(name, FeatureFlags.getMaxGroupNameGraphemeLength()); name.addTextChangedListener(new AfterTextChanged(editable -> viewModel.setName(editable.toString()))); @@ -154,44 +154,46 @@ public class AddGroupDetailsFragment extends LoggingFragment { }); name.requestFocus(); + + getParentFragmentManager().setFragmentResultListener(AvatarPickerFragment.REQUEST_KEY_SELECT_AVATAR, + getViewLifecycleOwner(), + (key, bundle) -> handleMediaResult(bundle)); } @Override public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - if (requestCode == REQUEST_CODE_AVATAR && resultCode == Activity.RESULT_OK && data != null) { - - if (data.getBooleanExtra("delete", false)) { - viewModel.setAvatar(null); - return; - } - - final Media result = data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA); - final DecryptableStreamUriLoader.DecryptableUri decryptableUri = new DecryptableStreamUriLoader.DecryptableUri(result.getUri()); - - GlideApp.with(this) - .asBitmap() - .load(decryptableUri) - .skipMemoryCache(true) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .centerCrop() - .override(AvatarHelper.AVATAR_DIMENSIONS, AvatarHelper.AVATAR_DIMENSIONS) - .into(new CustomTarget() { - @Override - public void onResourceReady(@NonNull Bitmap resource, Transition transition) { - viewModel.setAvatar(Objects.requireNonNull(BitmapUtil.toByteArray(resource))); - } - - @Override - public void onLoadCleared(@Nullable Drawable placeholder) { - } - }); - } else if (requestCode == REQUEST_DISAPPEARING_TIMER && resultCode == Activity.RESULT_OK && data != null) { + if (requestCode == REQUEST_DISAPPEARING_TIMER && resultCode == Activity.RESULT_OK && data != null) { viewModel.setDisappearingMessageTimer(data.getIntExtra(ExpireTimerSettingsFragment.FOR_RESULT_VALUE, SignalStore.settings().getUniversalExpireTimer())); } else { super.onActivityResult(requestCode, resultCode, data); } } + private void handleMediaResult(Bundle data) { + final Media result = data.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA); + final DecryptableStreamUriLoader.DecryptableUri decryptableUri = new DecryptableStreamUriLoader.DecryptableUri(result.getUri()); + + viewModel.setAvatarMedia(result); + + GlideApp.with(this) + .asBitmap() + .load(decryptableUri) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .centerCrop() + .override(AvatarHelper.AVATAR_DIMENSIONS, AvatarHelper.AVATAR_DIMENSIONS) + .into(new CustomTarget() { + @Override + public void onResourceReady(@NonNull Bitmap resource, Transition transition) { + viewModel.setAvatar(Objects.requireNonNull(BitmapUtil.toByteArray(resource))); + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + } + }); + } + private void initializeViewModel() { AddGroupDetailsFragmentArgs args = AddGroupDetailsFragmentArgs.fromBundle(requireArguments()); AddGroupDetailsRepository repository = new AddGroupDetailsRepository(requireContext()); @@ -211,15 +213,15 @@ public class AddGroupDetailsFragment extends LoggingFragment { } private void handleRecipientClick(@NonNull Recipient recipient) { - new AlertDialog.Builder(requireContext()) - .setMessage(getString(R.string.AddGroupDetailsFragment__remove_s_from_this_group, recipient.getDisplayName(requireContext()))) - .setCancelable(true) - .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.cancel()) - .setPositiveButton(R.string.AddGroupDetailsFragment__remove, (dialog, which) -> { - viewModel.delete(recipient.getId()); - dialog.dismiss(); - }) - .show(); + new MaterialAlertDialogBuilder(requireContext()) + .setMessage(getString(R.string.AddGroupDetailsFragment__remove_s_from_this_group, recipient.getDisplayName(requireContext()))) + .setCancelable(true) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.cancel()) + .setPositiveButton(R.string.AddGroupDetailsFragment__remove, (dialog, which) -> { + viewModel.delete(recipient.getId()); + dialog.dismiss(); + }) + .show(); } private void handleGroupCreateResult(@NonNull GroupCreateResult groupCreateResult) { @@ -263,13 +265,15 @@ public class AddGroupDetailsFragment extends LoggingFragment { .alpha(isEnabled ? 1f : 0.5f); } - private void showAvatarSelectionBottomSheet() { - AvatarSelectionBottomSheetDialogFragment.create(viewModel.hasAvatar(), true, REQUEST_CODE_AVATAR, true) - .show(getChildFragmentManager(), "BOTTOM"); + private void showAvatarPicker() { + Media media = viewModel.getAvatarMedia(); + + Navigation.findNavController(requireView()).navigate(AddGroupDetailsFragmentDirections.actionAddGroupDetailsFragmentToAvatarPicker(null, media).setIsNewGroup(true)); } public interface Callback { void onGroupCreated(@NonNull RecipientId recipientId, long threadId, @NonNull List invitedMembers); + void onNavigationButtonPressed(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsViewModel.java index b88359f6f..b2e2fbb28 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsViewModel.java @@ -15,6 +15,7 @@ import com.annimon.stream.Stream; import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.DefaultValueLiveData; @@ -42,6 +43,8 @@ public final class AddGroupDetailsViewModel extends ViewModel { private final AddGroupDetailsRepository repository; private final LiveData> nonGv2CapableMembers; + private Media avatarMedia; + private AddGroupDetailsViewModel(@NonNull Collection recipientIds, @NonNull AddGroupDetailsRepository repository) { @@ -152,6 +155,14 @@ public final class AddGroupDetailsViewModel extends ViewModel { disappearingMessagesTimer.setValue(timer); } + public void setAvatarMedia(@Nullable Media media) { + this.avatarMedia = media; + } + + public @Nullable Media getAvatarMedia() { + return avatarMedia; + } + static final class Factory implements ViewModelProvider.Factory { private final Collection recipientIds; diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java index 0ed3e9480..c005000a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java @@ -67,7 +67,8 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { private enum EditingPurpose { IMAGE, - AVATAR_CIRCLE, + AVATAR_CAPTURE, + AVATAR_EDIT, WALLPAPER } @@ -95,8 +96,14 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { return new EditorModel(EditingPurpose.IMAGE, 0, EditorElementHierarchy.create()); } - public static EditorModel createForCircleEditing() { - EditorModel editorModel = new EditorModel(EditingPurpose.AVATAR_CIRCLE, 1, EditorElementHierarchy.createForCircleEditing()); + public static EditorModel createForAvatarCapture() { + EditorModel editorModel = new EditorModel(EditingPurpose.AVATAR_CAPTURE, 1, EditorElementHierarchy.createForCircleEditing()); + editorModel.setCropAspectLock(true); + return editorModel; + } + + public static EditorModel createForAvatarEdit() { + EditorModel editorModel = new EditorModel(EditingPurpose.AVATAR_EDIT, 1, EditorElementHierarchy.createForCircleEditing()); editorModel.setCropAspectLock(true); return editorModel; } @@ -642,7 +649,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { if (imageCropMatrix.isIdentity()) { imageCropMatrix.set(cropMatrix); - if (editingPurpose == EditingPurpose.AVATAR_CIRCLE || editingPurpose == EditingPurpose.WALLPAPER) { + if (editingPurpose == EditingPurpose.AVATAR_CAPTURE || editingPurpose == EditingPurpose.WALLPAPER || editingPurpose == EditingPurpose.AVATAR_EDIT) { Matrix userCropMatrix = editorElementHierarchy.getCropEditorElement().getLocalMatrix(); if (size.x > size.y) { userCropMatrix.setScale(fixedRatio * size.y / (float) size.x, 1f); @@ -658,7 +665,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { } switch (editingPurpose) { - case AVATAR_CIRCLE: { + case AVATAR_CAPTURE: { startCrop(); break; } @@ -667,6 +674,8 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { startCrop(); break; } + default: + break; } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsUserAvatar.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsUserAvatar.java index 5296bd13b..763e45761 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsUserAvatar.java +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsUserAvatar.java @@ -25,7 +25,7 @@ class InsightsUserAvatar { } private Drawable fallbackDrawable(@NonNull Context context) { - return fallbackContactPhoto.asDrawable(context, fallbackColor.colorInt()); + return fallbackContactPhoto.asDrawable(context, fallbackColor); } void load(ImageView into) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGridDividerDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGridDividerDecoration.kt index 949ac47b2..8efe6f007 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGridDividerDecoration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGridDividerDecoration.kt @@ -2,15 +2,14 @@ package org.thoughtcrime.securesms.mediaoverview import android.graphics.Rect import android.view.View -import androidx.annotation.Px import androidx.recyclerview.widget.RecyclerView -import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration internal class MediaGridDividerDecoration( - private val spanCount: Int, - @Px private val space: Int, + spanCount: Int, + space: Int, private val adapter: MediaGalleryAllAdapter -) : RecyclerView.ItemDecoration() { +) : GridDividerDecoration(spanCount, space) { override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { val holder = parent.getChildViewHolder(view) @@ -28,32 +27,6 @@ internal class MediaGridDividerDecoration( return } - val column = itemSectionOffset % spanCount - val isRtl = ViewUtil.isRtl(view) - - val distanceFromEnd = spanCount - 1 - column - - val spaceStart = (column / spanCount.toFloat()) * space - val spaceEnd = (distanceFromEnd / spanCount.toFloat()) * space - - outRect.setStart(spaceStart.toInt(), isRtl) - outRect.setEnd(spaceEnd.toInt(), isRtl) - outRect.bottom = space - } - - private fun Rect.setEnd(end: Int, isRtl: Boolean) { - if (isRtl) { - left = end - } else { - right = end - } - } - - private fun Rect.setStart(start: Int, isRtl: Boolean) { - if (isRtl) { - right = start - } else { - left = start - } + setItemOffsets(itemSectionOffset, view, outRect) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewActivity.java index 872916a9f..c70fbc6a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewActivity.java @@ -38,6 +38,7 @@ import com.google.android.material.tabs.TabLayout; import org.thoughtcrime.securesms.PassphraseRequiredActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.AnimatingToggle; +import org.thoughtcrime.securesms.components.BoldSelectionTabItem; import org.thoughtcrime.securesms.components.ControllableTabLayout; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MediaDatabase; @@ -98,8 +99,7 @@ public final class MediaOverviewActivity extends PassphraseRequiredActivity { boolean allThreads = threadId == MediaDatabase.ALL_THREADS; - tabLayout.setNewTabListener(new NewTabListener()); - tabLayout.addOnTabSelectedListener(new OnTabSelectedListener()); + BoldSelectionTabItem.registerListeners(tabLayout); fillTabLayoutIfFits(tabLayout); tabLayout.setupWithViewPager(viewPager); viewPager.setAdapter(new MediaOverviewPagerAdapter(getSupportFragmentManager())); @@ -286,34 +286,4 @@ public final class MediaOverviewActivity extends PassphraseRequiredActivity { return pages.get(position).second(); } } - - private static final class NewTabListener implements ControllableTabLayout.NewTabListener { - @Override - public void onNewTab(@NonNull TabLayout.Tab tab) { - View customView = tab.getCustomView(); - if (customView == null) { - tab.setCustomView(R.layout.media_overview_tab_item); - } - } - } - - private static final class OnTabSelectedListener implements TabLayout.OnTabSelectedListener { - - @Override - public void onTabSelected(@NonNull TabLayout.Tab tab) { - MediaOverviewTabItem view = (MediaOverviewTabItem) Objects.requireNonNull(tab.getCustomView()); - view.select(); - } - - @Override - public void onTabUnselected(@NonNull TabLayout.Tab tab) { - MediaOverviewTabItem view = (MediaOverviewTabItem) Objects.requireNonNull(tab.getCustomView()); - view.unselect(); - } - - @Override - public void onTabReselected(@NonNull TabLayout.Tab tab) { - // Intentionally Blank. - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewTabItem.kt b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewTabItem.kt deleted file mode 100644 index b885a89a5..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewTabItem.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.thoughtcrime.securesms.mediaoverview - -import android.content.Context -import android.util.AttributeSet -import android.widget.FrameLayout -import android.widget.TextView -import androidx.core.widget.doAfterTextChanged -import org.thoughtcrime.securesms.R - -class MediaOverviewTabItem @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : FrameLayout(context, attrs, defStyleAttr) { - - private lateinit var unselectedTextView: TextView - private lateinit var selectedTextView: TextView - - override fun onFinishInflate() { - super.onFinishInflate() - - unselectedTextView = findViewById(android.R.id.text1) - selectedTextView = findViewById(R.id.text1_bold) - - unselectedTextView.doAfterTextChanged { - selectedTextView.text = it - } - } - - fun select() { - unselectedTextView.alpha = 0f - selectedTextView.alpha = 1f - } - - fun unselect() { - unselectedTextView.alpha = 1f - selectedTextView.alpha = 0f - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java index e8b892afe..ffffd3a6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java @@ -157,7 +157,7 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera currentMedia = media; getSupportFragmentManager().beginTransaction() - .replace(R.id.fragment_container, ImageEditorFragment.newInstanceForAvatar(media.getUri()), IMAGE_EDITOR) + .replace(R.id.fragment_container, ImageEditorFragment.newInstanceForAvatarCapture(media.getUri()), IMAGE_EDITOR) .addToBackStack(IMAGE_EDITOR) .commit(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionBottomSheetDialogFragment.java deleted file mode 100644 index 24f40bb36..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionBottomSheetDialogFragment.java +++ /dev/null @@ -1,234 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.Manifest; -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Toast; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.appcompat.widget.AppCompatTextView; -import androidx.core.content.ContextCompat; -import androidx.core.util.Consumer; -import androidx.fragment.app.DialogFragment; -import androidx.recyclerview.widget.RecyclerView; - -import com.annimon.stream.Stream; -import com.google.android.material.bottomsheet.BottomSheetDialogFragment; - -import org.thoughtcrime.securesms.ClearAvatarPromptActivity; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.util.ThemeUtil; - -import java.util.ArrayList; -import java.util.List; - -public class AvatarSelectionBottomSheetDialogFragment extends BottomSheetDialogFragment { - - private static final String ARG_OPTIONS = "options"; - private static final String ARG_REQUEST_CODE = "request_code"; - private static final String ARG_IS_GROUP = "is_group"; - - public static DialogFragment create(boolean includeClear, boolean includeCamera, short requestCode, boolean isGroup) { - DialogFragment fragment = new AvatarSelectionBottomSheetDialogFragment(); - List selectionOptions = new ArrayList<>(3); - Bundle args = new Bundle(); - - if (includeCamera) { - selectionOptions.add(SelectionOption.CAPTURE); - } - - selectionOptions.add(SelectionOption.GALLERY); - - if (includeClear) { - selectionOptions.add(SelectionOption.DELETE); - } - - String[] options = Stream.of(selectionOptions) - .map(SelectionOption::getCode) - .toArray(String[]::new); - - args.putStringArray(ARG_OPTIONS, options); - args.putShort(ARG_REQUEST_CODE, requestCode); - args.putBoolean(ARG_IS_GROUP, isGroup); - fragment.setArguments(args); - - return fragment; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - setStyle(DialogFragment.STYLE_NORMAL, - ThemeUtil.isDarkTheme(requireContext()) ? R.style.Theme_Signal_BottomSheetDialog_Fixed - : R.style.Theme_Signal_Light_BottomSheetDialog_Fixed); - - super.onCreate(savedInstanceState); - - if (getOptionsCount() == 1) { - askForPermissionIfNeededAndLaunch(getOptionsFromArguments().get(0)); - } - } - - @Override - public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.avatar_selection_bottom_sheet_dialog_fragment, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - RecyclerView recyclerView = view.findViewById(R.id.avatar_selection_bottom_sheet_dialog_fragment_recycler); - recyclerView.setAdapter(new SelectionOptionAdapter(getOptionsFromArguments(), this::askForPermissionIfNeededAndLaunch)); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); - } - - @SuppressWarnings("ConstantConditions") - private int getOptionsCount() { - return requireArguments().getStringArray(ARG_OPTIONS).length; - } - - @SuppressWarnings("ConstantConditions") - private List getOptionsFromArguments() { - String[] optionCodes = requireArguments().getStringArray(ARG_OPTIONS); - - return Stream.of(optionCodes).map(SelectionOption::fromCode).toList(); - } - - private void askForPermissionIfNeededAndLaunch(@NonNull SelectionOption option) { - if (option == SelectionOption.CAPTURE) { - Permissions.with(this) - .request(Manifest.permission.CAMERA) - .ifNecessary() - .onAllGranted(() -> launchOptionAndDismiss(option)) - .onAnyDenied(() -> Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__taking_a_photo_requires_the_camera_permission, Toast.LENGTH_SHORT) - .show()) - .execute(); - } else if (option == SelectionOption.GALLERY) { - Permissions.with(this) - .request(Manifest.permission.READ_EXTERNAL_STORAGE) - .ifNecessary() - .onAllGranted(() -> launchOptionAndDismiss(option)) - .onAnyDenied(() -> Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__viewing_your_gallery_requires_the_storage_permission, Toast.LENGTH_SHORT) - .show()) - .execute(); - } else { - launchOptionAndDismiss(option); - } - } - - private void launchOptionAndDismiss(@NonNull SelectionOption option) { - Intent intent = createIntent(requireContext(), option, requireArguments().getBoolean(ARG_IS_GROUP)); - - int requestCode = requireArguments().getShort(ARG_REQUEST_CODE); - if (getParentFragment() != null) { - requireParentFragment().startActivityForResult(intent, requestCode); - } else { - requireActivity().startActivityForResult(intent, requestCode); - } - - dismiss(); - } - - private static Intent createIntent(@NonNull Context context, @NonNull SelectionOption selectionOption, boolean isGroup) { - switch (selectionOption) { - case CAPTURE: - return AvatarSelectionActivity.getIntentForCameraCapture(context); - case GALLERY: - return AvatarSelectionActivity.getIntentForGallery(context); - case DELETE: - return isGroup ? ClearAvatarPromptActivity.createForGroupProfilePhoto() - : ClearAvatarPromptActivity.createForUserProfilePhoto(); - default: - throw new IllegalStateException("Unknown option: " + selectionOption); - } - } - - private enum SelectionOption { - CAPTURE("capture", R.string.AvatarSelectionBottomSheetDialogFragment__take_photo, R.drawable.ic_camera_24), - GALLERY("gallery", R.string.AvatarSelectionBottomSheetDialogFragment__choose_from_gallery, R.drawable.ic_photo_24), - DELETE("delete", R.string.AvatarSelectionBottomSheetDialogFragment__remove_photo, R.drawable.ic_trash_24); - - private final String code; - private final @StringRes int label; - private final @DrawableRes int icon; - - SelectionOption(@NonNull String code, @StringRes int label, @DrawableRes int icon) { - this.code = code; - this.label = label; - this.icon = icon; - } - - public @NonNull String getCode() { - return code; - } - - static SelectionOption fromCode(@NonNull String code) { - for (SelectionOption option : values()) { - if (option.code.equals(code)) { - return option; - } - } - - throw new IllegalStateException("Unknown option: " + code); - } - } - - private static class SelectionOptionViewHolder extends RecyclerView.ViewHolder { - - private final AppCompatTextView optionView; - - SelectionOptionViewHolder(@NonNull View itemView, @NonNull Consumer onClick) { - super(itemView); - itemView.setOnClickListener(v -> { - if (getAdapterPosition() != RecyclerView.NO_POSITION) { - onClick.accept(getAdapterPosition()); - } - }); - - optionView = (AppCompatTextView) itemView; - } - - void bind(@NonNull SelectionOption selectionOption) { - optionView.setCompoundDrawablesWithIntrinsicBounds(ContextCompat.getDrawable(optionView.getContext(), selectionOption.icon), null, null, null); - optionView.setText(selectionOption.label); - } - } - - private static class SelectionOptionAdapter extends RecyclerView.Adapter { - - private final List options; - private final Consumer onOptionClicked; - - private SelectionOptionAdapter(@NonNull List options, @NonNull Consumer onOptionClicked) { - this.options = options; - this.onOptionClicked = onOptionClicked; - } - - @NonNull - @Override - public SelectionOptionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.avatar_selection_bottom_sheet_dialog_fragment_option, parent, false); - return new SelectionOptionViewHolder(view, (position) -> onOptionClicked.accept(options.get(position))); - } - - @Override - public void onBindViewHolder(@NonNull SelectionOptionViewHolder holder, int position) { - holder.bind(options.get(position)); - } - - @Override - public int getItemCount() { - return options.size(); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java index e4a9c8e58..09ecf7752 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.mediasend; import android.Manifest; +import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; import android.graphics.PorterDuff; @@ -395,6 +396,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med } } + @SuppressLint("MissingSuperCall") @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java b/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java index dc89d6431..9166c0e58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java @@ -8,10 +8,10 @@ import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.apache.http.auth.AUTH; import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.avatar.AvatarPickerStorage; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.emoji.EmojiFiles; import org.thoughtcrime.securesms.providers.BlobProvider; @@ -24,22 +24,25 @@ import java.io.InputStream; public class PartAuthority { - private static final String AUTHORITY = BuildConfig.APPLICATION_ID; - private static final String PART_URI_STRING = "content://" + AUTHORITY + "/part"; - private static final String STICKER_URI_STRING = "content://" + AUTHORITY + "/sticker"; - private static final String WALLPAPER_URI_STRING = "content://" + AUTHORITY + "/wallpaper"; - private static final String EMOJI_URI_STRING = "content://" + AUTHORITY + "/emoji"; - private static final Uri PART_CONTENT_URI = Uri.parse(PART_URI_STRING); - private static final Uri STICKER_CONTENT_URI = Uri.parse(STICKER_URI_STRING); - private static final Uri WALLPAPER_CONTENT_URI = Uri.parse(WALLPAPER_URI_STRING); - private static final Uri EMOJI_CONTENT_URI = Uri.parse(EMOJI_URI_STRING); + private static final String AUTHORITY = BuildConfig.APPLICATION_ID; + private static final String PART_URI_STRING = "content://" + AUTHORITY + "/part"; + private static final String STICKER_URI_STRING = "content://" + AUTHORITY + "/sticker"; + private static final String WALLPAPER_URI_STRING = "content://" + AUTHORITY + "/wallpaper"; + private static final String EMOJI_URI_STRING = "content://" + AUTHORITY + "/emoji"; + private static final String AVATAR_PICKER_URI_STRING = "content://" + AUTHORITY + "/avatar_picker"; + private static final Uri PART_CONTENT_URI = Uri.parse(PART_URI_STRING); + private static final Uri STICKER_CONTENT_URI = Uri.parse(STICKER_URI_STRING); + private static final Uri WALLPAPER_CONTENT_URI = Uri.parse(WALLPAPER_URI_STRING); + private static final Uri EMOJI_CONTENT_URI = Uri.parse(EMOJI_URI_STRING); + private static final Uri AVATAR_PICKER_CONTENT_URI = Uri.parse(AVATAR_PICKER_URI_STRING); - private static final int PART_ROW = 1; - private static final int PERSISTENT_ROW = 2; - private static final int BLOB_ROW = 3; - private static final int STICKER_ROW = 4; - private static final int WALLPAPER_ROW = 5; - private static final int EMOJI_ROW = 6; + private static final int PART_ROW = 1; + private static final int PERSISTENT_ROW = 2; + private static final int BLOB_ROW = 3; + private static final int STICKER_ROW = 4; + private static final int WALLPAPER_ROW = 5; + private static final int EMOJI_ROW = 6; + private static final int AVATAR_PICKER_ROW = 7; private static final UriMatcher uriMatcher; @@ -49,6 +52,7 @@ public class PartAuthority { uriMatcher.addURI(AUTHORITY, "sticker/#", STICKER_ROW); uriMatcher.addURI(AUTHORITY, "wallpaper/*", WALLPAPER_ROW); uriMatcher.addURI(AUTHORITY, "emoji/*", EMOJI_ROW); + uriMatcher.addURI(AUTHORITY, "avatar_picker/*", AVATAR_PICKER_ROW); uriMatcher.addURI(DeprecatedPersistentBlobProvider.AUTHORITY, DeprecatedPersistentBlobProvider.EXPECTED_PATH_OLD, PERSISTENT_ROW); uriMatcher.addURI(DeprecatedPersistentBlobProvider.AUTHORITY, DeprecatedPersistentBlobProvider.EXPECTED_PATH_NEW, PERSISTENT_ROW); uriMatcher.addURI(BlobProvider.AUTHORITY, BlobProvider.PATH, BLOB_ROW); @@ -66,13 +70,14 @@ public class PartAuthority { int match = uriMatcher.match(uri); try { switch (match) { - case PART_ROW: return DatabaseFactory.getAttachmentDatabase(context).getAttachmentStream(new PartUriParser(uri).getPartId(), 0); - case STICKER_ROW: return DatabaseFactory.getStickerDatabase(context).getStickerStream(ContentUris.parseId(uri)); - case PERSISTENT_ROW: return DeprecatedPersistentBlobProvider.getInstance(context).getStream(context, ContentUris.parseId(uri)); - case BLOB_ROW: return BlobProvider.getInstance().getStream(context, uri); - case WALLPAPER_ROW: return WallpaperStorage.read(context, getWallpaperFilename(uri)); - case EMOJI_ROW: return EmojiFiles.openForReading(context, getEmojiFilename(uri)); - default: return context.getContentResolver().openInputStream(uri); + case PART_ROW: return DatabaseFactory.getAttachmentDatabase(context).getAttachmentStream(new PartUriParser(uri).getPartId(), 0); + case STICKER_ROW: return DatabaseFactory.getStickerDatabase(context).getStickerStream(ContentUris.parseId(uri)); + case PERSISTENT_ROW: return DeprecatedPersistentBlobProvider.getInstance(context).getStream(context, ContentUris.parseId(uri)); + case BLOB_ROW: return BlobProvider.getInstance().getStream(context, uri); + case WALLPAPER_ROW: return WallpaperStorage.read(context, getWallpaperFilename(uri)); + case EMOJI_ROW: return EmojiFiles.openForReading(context, getEmojiFilename(uri)); + case AVATAR_PICKER_ROW: return AvatarPickerStorage.read(context, getAvatarPickerFilename(uri)); + default: return context.getContentResolver().openInputStream(uri); } } catch (SecurityException se) { throw new IOException(se); @@ -169,6 +174,10 @@ public class PartAuthority { return Uri.withAppendedPath(WALLPAPER_CONTENT_URI, filename); } + public static Uri getAvatarPickerUri(String filename) { + return Uri.withAppendedPath(AVATAR_PICKER_CONTENT_URI, filename); + } + public static Uri getEmojiUri(String sprite) { return Uri.withAppendedPath(EMOJI_CONTENT_URI, sprite); } @@ -181,6 +190,10 @@ public class PartAuthority { return uri.getPathSegments().get(1); } + public static String getAvatarPickerFilename(Uri uri) { + return uri.getPathSegments().get(1); + } + public static boolean isLocalUri(final @NonNull Uri uri) { int match = uriMatcher.match(uri); switch (match) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationConversation.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationConversation.kt index 8f08b9657..271844e56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationConversation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationConversation.kt @@ -52,7 +52,7 @@ data class NotificationConversation( return if (SignalStore.settings().messageNotificationsPrivacy.isDisplayContact) { recipient.getContactDrawable(context) } else { - GeneratedContactPhoto("Unknown", R.drawable.ic_profile_outline_40).asDrawable(context, AvatarColor.UNKNOWN.colorInt()) + GeneratedContactPhoto("Unknown", R.drawable.ic_profile_outline_40).asDrawable(context, AvatarColor.UNKNOWN) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationExtensions.kt index 2a6ecd132..99d652fcb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationExtensions.kt @@ -56,12 +56,12 @@ fun Recipient.getContactDrawable(context: Context): Drawable? { ) .get() } catch (e: InterruptedException) { - fallbackContactPhoto.asDrawable(context, avatarColor.colorInt()) + fallbackContactPhoto.asDrawable(context, avatarColor) } catch (e: ExecutionException) { - fallbackContactPhoto.asDrawable(context, avatarColor.colorInt()) + fallbackContactPhoto.asDrawable(context, avatarColor) } } else { - fallbackContactPhoto.asDrawable(context, avatarColor.colorInt()) + fallbackContactPhoto.asDrawable(context, avatarColor) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java index acaee81df..79f390411 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java @@ -14,6 +14,7 @@ import android.view.Display; import android.view.ViewGroup; import android.view.WindowManager; +import androidx.activity.result.ActivityResultCallback; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.core.app.ActivityCompat; diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java index 2da2d5036..9b0cdf223 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java @@ -9,6 +9,7 @@ import androidx.core.util.Consumer; import org.signal.core.util.StreamUtil; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.conversation.colors.AvatarColor; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.groups.GroupChangeException; import org.thoughtcrime.securesms.groups.GroupId; @@ -34,6 +35,11 @@ class EditGroupProfileRepository implements EditProfileRepository { this.groupId = groupId; } + @Override + public void getCurrentAvatarColor(@NonNull Consumer avatarColorConsumer) { + SimpleTask.run(() -> Recipient.resolved(getRecipientId()).getAvatarColor(), avatarColorConsumer::accept); + } + @Override public void getCurrentProfileName(@NonNull Consumer profileNameConsumer) { profileNameConsumer.accept(ProfileName.EMPTY); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileActivity.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileActivity.java index 2768df2e0..9fd688e02 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileActivity.java @@ -10,6 +10,7 @@ import androidx.annotation.NonNull; import androidx.navigation.NavDirections; import androidx.navigation.NavGraph; import androidx.navigation.Navigation; +import androidx.navigation.fragment.NavHostFragment; import org.thoughtcrime.securesms.BaseActivity; import org.thoughtcrime.securesms.R; @@ -61,10 +62,10 @@ public class EditProfileActivity extends BaseActivity implements EditProfileFrag setContentView(R.layout.profile_create_activity); if (bundle == null) { - Bundle extras = getIntent().getExtras(); - NavGraph graph = Navigation.findNavController(this, R.id.nav_host_fragment).getGraph(); - - Navigation.findNavController(this, R.id.nav_host_fragment).setGraph(graph, extras != null ? extras : new Bundle()); + NavHostFragment fragment = NavHostFragment.create(R.navigation.edit_profile, getIntent().getExtras()); + getSupportFragmentManager().beginTransaction() + .add(R.id.fragment_container, fragment) + .commit(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java index 8e43b9fd7..be3e1b7c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java @@ -5,6 +5,7 @@ import android.content.Context; import android.content.Intent; import android.os.Build; import android.os.Bundle; +import android.os.Parcelable; import android.text.InputType; import android.view.LayoutInflater; import android.view.View; @@ -20,7 +21,9 @@ import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.appcompat.widget.Toolbar; import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.Navigation; +import com.airbnb.lottie.SimpleColorFilter; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.dd.CircularProgressButton; @@ -29,11 +32,10 @@ import org.signal.core.util.StreamUtil; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; -import org.thoughtcrime.securesms.conversation.colors.AvatarColor; +import org.thoughtcrime.securesms.avatar.Avatars; +import org.thoughtcrime.securesms.avatar.picker.AvatarPickerFragment; import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity; -import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFragment; +import org.thoughtcrime.securesms.groups.ParcelableGroupId; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.profiles.manage.EditProfileNameFragment; @@ -47,7 +49,6 @@ import org.thoughtcrime.securesms.util.views.LearnMoreTextView; import java.io.IOException; import java.io.InputStream; -import static android.app.Activity.RESULT_OK; import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.EXCLUDE_SYSTEM; import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.GROUP_ID; import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.NEXT_BUTTON_TEXT; @@ -57,7 +58,6 @@ import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.SHOW_ public class EditProfileFragment extends LoggingFragment { private static final String TAG = Log.tag(EditProfileFragment.class); - private static final short REQUEST_CODE_SELECT_AVATAR = 31726; private static final int MAX_DESCRIPTION_GLYPHS = 480; private static final int MAX_DESCRIPTION_BYTES = 8192; @@ -69,6 +69,8 @@ public class EditProfileFragment extends LoggingFragment { private EditText familyName; private View reveal; private TextView preview; + private ImageView avatarPreviewBackground; + private ImageView avatarPreview; private Intent nextIntent; @@ -100,45 +102,38 @@ public class EditProfileFragment extends LoggingFragment { initializeResources(view, groupId); initializeProfileAvatar(); initializeProfileName(); + + getParentFragmentManager().setFragmentResultListener(AvatarPickerFragment.REQUEST_KEY_SELECT_AVATAR, getViewLifecycleOwner(), (key, bundle) -> { + Media media = bundle.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA); + handleMediaFromResult(media); + }); } - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); + private void handleMediaFromResult(@NonNull Media media) { + SimpleTask.run(() -> { + try { + InputStream stream = BlobProvider.getInstance().getStream(requireContext(), media.getUri()); - if (requestCode == REQUEST_CODE_SELECT_AVATAR && resultCode == RESULT_OK) { - - if (data != null && data.getBooleanExtra("delete", false)) { - viewModel.setAvatar(null); - avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_camera_solid_white_24).asDrawable(requireActivity(), AvatarColor.UNKNOWN.colorInt())); - return; + return StreamUtil.readFully(stream); + } catch (IOException ioException) { + Log.w(TAG, ioException); + return null; } - - SimpleTask.run(() -> { - try { - Media result = data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA); - InputStream stream = BlobProvider.getInstance().getStream(requireContext(), result.getUri()); - - return StreamUtil.readFully(stream); - } catch (IOException ioException) { - Log.w(TAG, ioException); - return null; - } - }, - (avatarBytes) -> { - if (avatarBytes != null) { - viewModel.setAvatar(avatarBytes); - GlideApp.with(EditProfileFragment.this) - .load(avatarBytes) - .skipMemoryCache(true) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .circleCrop() - .into(avatar); - } else { - Toast.makeText(requireActivity(), R.string.CreateProfileActivity_error_setting_profile_photo, Toast.LENGTH_LONG).show(); - } - }); - } + }, + (avatarBytes) -> { + if (avatarBytes != null) { + viewModel.setAvatarMedia(media); + viewModel.setAvatar(avatarBytes); + GlideApp.with(EditProfileFragment.this) + .load(avatarBytes) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .circleCrop() + .into(avatar); + } else { + Toast.makeText(requireActivity(), R.string.CreateProfileActivity_error_setting_profile_photo, Toast.LENGTH_LONG).show(); + } + }); } private void initializeViewModel(boolean excludeSystem, @Nullable GroupId groupId, boolean hasSavedInstanceState) { @@ -160,15 +155,17 @@ public class EditProfileFragment extends LoggingFragment { Bundle arguments = requireArguments(); boolean isEditingGroup = groupId != null; - this.toolbar = view.findViewById(R.id.toolbar); - this.title = view.findViewById(R.id.title); - this.avatar = view.findViewById(R.id.avatar); - this.givenName = view.findViewById(R.id.given_name); - this.familyName = view.findViewById(R.id.family_name); - this.finishButton = view.findViewById(R.id.finish_button); - this.reveal = view.findViewById(R.id.reveal); - this.preview = view.findViewById(R.id.name_preview); - this.nextIntent = arguments.getParcelable(NEXT_INTENT); + this.toolbar = view.findViewById(R.id.toolbar); + this.title = view.findViewById(R.id.title); + this.avatar = view.findViewById(R.id.avatar); + this.givenName = view.findViewById(R.id.given_name); + this.familyName = view.findViewById(R.id.family_name); + this.finishButton = view.findViewById(R.id.finish_button); + this.reveal = view.findViewById(R.id.reveal); + this.preview = view.findViewById(R.id.name_preview); + this.avatarPreviewBackground = view.findViewById(R.id.avatar_background); + this.avatarPreview = view.findViewById(R.id.avatar_placeholder); + this.nextIntent = arguments.getParcelable(NEXT_INTENT); this.avatar.setOnClickListener(v -> startAvatarSelection()); @@ -255,6 +252,13 @@ public class EditProfileFragment extends LoggingFragment { .circleCrop() .into(avatar); }); + + viewModel.avatarColor().observe(getViewLifecycleOwner(), avatarColor -> { + Avatars.ForegroundColor foregroundColor = Avatars.getForegroundColor(avatarColor); + + avatarPreview.getDrawable().setColorFilter(new SimpleColorFilter(foregroundColor.getColorInt())); + avatarPreviewBackground.getDrawable().setColorFilter(new SimpleColorFilter(avatarColor.colorInt())); + }); } private static void updateFieldIfNeeded(@NonNull EditText field, @NonNull String value) { @@ -273,11 +277,12 @@ public class EditProfileFragment extends LoggingFragment { } private void startAvatarSelection() { - AvatarSelectionBottomSheetDialogFragment.create(viewModel.canRemoveProfilePhoto(), - true, - REQUEST_CODE_SELECT_AVATAR, - viewModel.isGroup()) - .show(getChildFragmentManager(), null); + if (viewModel.isGroup()) { + Parcelable groupId = ParcelableGroupId.from(viewModel.getGroupId()); + Navigation.findNavController(requireView()).navigate(EditProfileFragmentDirections.actionCreateProfileFragmentToAvatarPicker((ParcelableGroupId) groupId, viewModel.getAvatarMedia())); + } else { + Navigation.findNavController(requireView()).navigate(EditProfileFragmentDirections.actionCreateProfileFragmentToAvatarPicker(null, null)); + } } private void handleUpload() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileRepository.java index 230ea5ae1..2a4a374cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileRepository.java @@ -4,11 +4,14 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.util.Consumer; +import org.thoughtcrime.securesms.conversation.colors.AvatarColor; import org.thoughtcrime.securesms.profiles.ProfileName; import org.whispersystems.libsignal.util.guava.Optional; interface EditProfileRepository { + void getCurrentAvatarColor(@NonNull Consumer avatarColorConsumer); + void getCurrentProfileName(@NonNull Consumer profileNameConsumer); void getCurrentAvatar(@NonNull Consumer avatarConsumer); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java index e12127224..5d3c71a6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java @@ -8,7 +8,9 @@ import androidx.lifecycle.Transformations; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; +import org.thoughtcrime.securesms.conversation.colors.AvatarColor; import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.profiles.edit.EditProfileRepository.UploadResult; import org.thoughtcrime.securesms.util.SingleLiveEvent; @@ -29,10 +31,12 @@ class EditProfileViewModel extends ViewModel { private final MutableLiveData originalAvatar = new MutableLiveData<>(); private final MutableLiveData originalDisplayName = new MutableLiveData<>(); private final SingleLiveEvent uploadResult = new SingleLiveEvent<>(); + private final MutableLiveData avatarColor = new MutableLiveData<>(); private final LiveData isFormValid; private final EditProfileRepository repository; private final GroupId groupId; private String originalDescription; + private Media avatarMedia; private EditProfileViewModel(@NonNull EditProfileRepository repository, boolean hasInstanceState, @Nullable GroupId groupId) { this.repository = repository; @@ -59,9 +63,15 @@ class EditProfileViewModel extends ViewModel { internalAvatar.setValue(value); originalAvatar.setValue(value); }); + + repository.getCurrentAvatarColor(avatarColor::setValue); } } + public LiveData avatarColor() { + return Transformations.distinctUntilChanged(avatarColor); + } + public LiveData givenName() { return Transformations.distinctUntilChanged(givenName); } @@ -90,6 +100,18 @@ class EditProfileViewModel extends ViewModel { return groupId != null; } + public @Nullable Media getAvatarMedia() { + return avatarMedia; + } + + public void setAvatarMedia(@Nullable Media avatarMedia) { + this.avatarMedia = avatarMedia; + } + + public @Nullable GroupId getGroupId() { + return groupId; + } + public boolean canRemoveProfilePhoto() { return hasAvatar(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java index f4eccdf5c..0e02f9640 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java @@ -9,6 +9,7 @@ import androidx.core.util.Consumer; import org.signal.core.util.StreamUtil; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.conversation.colors.AvatarColor; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob; @@ -42,6 +43,11 @@ public class EditSelfProfileRepository implements EditProfileRepository { this.excludeSystem = excludeSystem; } + @Override + public void getCurrentAvatarColor(@NonNull Consumer avatarColorConsumer) { + SimpleTask.run(() -> Recipient.self().getAvatarColor(), avatarColorConsumer::accept); + } + @Override public void getCurrentProfileName(@NonNull Consumer profileNameConsumer) { ProfileName storedProfileName = Recipient.self().getProfileName(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java index c3f1253f4..cb0fe6c87 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java @@ -23,9 +23,9 @@ import com.bumptech.glide.Glide; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.avatar.picker.AvatarPickerFragment; import org.thoughtcrime.securesms.components.emoji.EmojiUtil; import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity; -import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFragment; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.profiles.manage.ManageProfileViewModel.AvatarState; @@ -35,8 +35,7 @@ import static android.app.Activity.RESULT_OK; public class ManageProfileFragment extends LoggingFragment { - private static final String TAG = Log.tag(ManageProfileFragment.class); - private static final short REQUEST_CODE_SELECT_AVATAR = 31726; + private static final String TAG = Log.tag(ManageProfileFragment.class); private Toolbar toolbar; private ImageView avatarView; @@ -86,22 +85,11 @@ public class ManageProfileFragment extends LoggingFragment { this.aboutContainer.setOnClickListener(v -> { Navigation.findNavController(v).navigate(ManageProfileFragmentDirections.actionManageAbout()); }); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - - if (requestCode == REQUEST_CODE_SELECT_AVATAR && resultCode == RESULT_OK) { - if (data != null && data.getBooleanExtra("delete", false)) { - viewModel.onAvatarSelected(requireContext(), null); - return; - } - - Media result = data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA); + getParentFragmentManager().setFragmentResultListener(AvatarPickerFragment.REQUEST_KEY_SELECT_AVATAR, getViewLifecycleOwner(), (key, bundle) -> { + Media result = bundle.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA); viewModel.onAvatarSelected(requireContext(), result); - } + }); } private void initializeViewModel() { @@ -193,10 +181,6 @@ public class ManageProfileFragment extends LoggingFragment { } private void onAvatarClicked() { - AvatarSelectionBottomSheetDialogFragment.create(viewModel.canRemoveAvatar(), - true, - REQUEST_CODE_SELECT_AVATAR, - false) - .show(getChildFragmentManager(), null); + Navigation.findNavController(requireView()).navigate(ManageProfileFragmentDirections.actionManageProfileFragmentToAvatarPicker(null, null)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java index a4c45946d..7d346bcbc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java @@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto20dp; import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; +import org.thoughtcrime.securesms.conversation.colors.AvatarColor; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.ViewUtil; @@ -122,7 +123,7 @@ public class ReviewBannerView extends LinearLayout { } @Override - protected Drawable newFallbackDrawable(@NonNull Context context, int color, boolean inverted) { + protected Drawable newFallbackDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) { return new FallbackPhoto20dp(getFallbackResId()).asDrawable(context, color, inverted); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index 7d2a54e5f..72b85ffc8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -793,11 +793,11 @@ public class Recipient { } public @NonNull Drawable getFallbackContactPhotoDrawable(Context context, boolean inverted, @Nullable FallbackPhotoProvider fallbackPhotoProvider) { - return getFallbackContactPhoto(Util.firstNonNull(fallbackPhotoProvider, DEFAULT_FALLBACK_PHOTO_PROVIDER)).asDrawable(context, avatarColor.colorInt(), inverted); + return getFallbackContactPhoto(Util.firstNonNull(fallbackPhotoProvider, DEFAULT_FALLBACK_PHOTO_PROVIDER)).asDrawable(context, avatarColor, inverted); } public @NonNull Drawable getSmallFallbackContactPhotoDrawable(Context context, boolean inverted, @Nullable FallbackPhotoProvider fallbackPhotoProvider) { - return getFallbackContactPhoto(Util.firstNonNull(fallbackPhotoProvider, DEFAULT_FALLBACK_PHOTO_PROVIDER)).asSmallDrawable(context, avatarColor.colorInt(), inverted); + return getFallbackContactPhoto(Util.firstNonNull(fallbackPhotoProvider, DEFAULT_FALLBACK_PHOTO_PROVIDER)).asSmallDrawable(context, avatarColor, inverted); } public @NonNull FallbackContactPhoto getFallbackContactPhoto() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java index d40bb343a..f3a54c215 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java @@ -144,7 +144,7 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF avatar.setFallbackPhotoProvider(new Recipient.FallbackPhotoProvider() { @Override public @NonNull FallbackContactPhoto getPhotoForLocalNumber() { - return new FallbackPhoto80dp(R.drawable.ic_note_80, recipient.getAvatarColor().colorInt()); + return new FallbackPhoto80dp(R.drawable.ic_note_80, recipient.getAvatarColor()); } }); avatar.setAvatar(recipient); diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index 195909626..96385943f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -16,6 +16,7 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; @@ -51,6 +52,7 @@ import org.whispersystems.libsignal.util.Pair; import java.io.ByteArrayOutputStream; import java.util.Collections; import java.util.List; +import java.util.Objects; import static android.app.Activity.RESULT_OK; @@ -60,8 +62,8 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu private static final String TAG = Log.tag(ImageEditorFragment.class); - private static final String KEY_IMAGE_URI = "image_uri"; - private static final String KEY_IS_AVATAR_MODE = "avatar_mode"; + private static final String KEY_IMAGE_URI = "image_uri"; + private static final String KEY_MODE = "mode"; private static final int SELECT_STICKER_REQUEST_CODE = 124; @@ -104,15 +106,22 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu private ImageEditorHud imageEditorHud; private ImageEditorView imageEditorView; - public static ImageEditorFragment newInstanceForAvatar(@NonNull Uri imageUri) { + public static ImageEditorFragment newInstanceForAvatarCapture(@NonNull Uri imageUri) { ImageEditorFragment fragment = newInstance(imageUri); - fragment.requireArguments().putBoolean(KEY_IS_AVATAR_MODE, true); + fragment.requireArguments().putString(KEY_MODE, Mode.AVATAR_CAPTURE.code); + return fragment; + } + + public static ImageEditorFragment newInstanceForAvatarEdit(@NonNull Uri imageUri) { + ImageEditorFragment fragment = newInstance(imageUri); + fragment.requireArguments().putString(KEY_MODE, Mode.AVATAR_EDIT.code); return fragment; } public static ImageEditorFragment newInstance(@NonNull Uri imageUri) { Bundle args = new Bundle(); args.putParcelable(KEY_IMAGE_URI, imageUri); + args.putString(KEY_MODE, Mode.NORMAL.code); ImageEditorFragment fragment = new ImageEditorFragment(); fragment.setArguments(args); @@ -123,10 +132,16 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (!(getActivity() instanceof Controller)) { + + Fragment parent = getParentFragment(); + if (parent instanceof Controller) { + controller = (Controller) parent; + } else if (getActivity() instanceof Controller) { + controller = (Controller) getActivity(); + } else { throw new IllegalStateException("Parent activity must implement Controller interface."); } - controller = (Controller) getActivity(); + Bundle arguments = getArguments(); if (arguments != null) { imageUri = arguments.getParcelable(KEY_IMAGE_URI); @@ -152,7 +167,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - boolean isAvatarMode = requireArguments().getBoolean(KEY_IS_AVATAR_MODE, false); + Mode mode = Mode.getByCode(requireArguments().getString(KEY_MODE)); imageEditorHud = view.findViewById(R.id.scribble_hud); imageEditorView = view.findViewById(R.id.image_editor_view); @@ -171,14 +186,28 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu } if (editorModel == null) { - editorModel = isAvatarMode ? EditorModel.createForCircleEditing() : EditorModel.create(); + switch (mode) { + case AVATAR_EDIT: + editorModel = EditorModel.createForAvatarEdit(); + break; + case AVATAR_CAPTURE: + editorModel = EditorModel.createForAvatarCapture(); + break; + default: + editorModel = EditorModel.create(); + break; + } + EditorElement image = new EditorElement(new UriGlideRenderer(imageUri, true, imageMaxWidth, imageMaxHeight)); image.getFlags().setSelectable(false).persist(); editorModel.addElement(image); } - if (isAvatarMode) { + if (mode == Mode.AVATAR_CAPTURE || mode == Mode.AVATAR_EDIT) { imageEditorHud.setUpForAvatarEditing(); + } + + if (mode == Mode.AVATAR_CAPTURE) { imageEditorHud.enterMode(ImageEditorHud.Mode.CROP); } @@ -460,24 +489,27 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu } private void performSaveToDisk() { - SimpleTask.run(() -> { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - Bitmap image = imageEditorView.getModel().render(requireContext()); - - image.compress(Bitmap.CompressFormat.JPEG, 80, outputStream); - - return BlobProvider.getInstance() - .forData(outputStream.toByteArray()) - .withMimeType(MediaUtil.IMAGE_JPEG) - .createForSingleUseInMemory(); - - }, uri -> { + SimpleTask.run(this::renderToSingleUseBlob, uri -> { SaveAttachmentTask saveTask = new SaveAttachmentTask(requireContext()); SaveAttachmentTask.Attachment attachment = new SaveAttachmentTask.Attachment(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), null); saveTask.executeOnExecutor(SignalExecutors.BOUNDED, attachment); }); } + @WorkerThread + public @NonNull Uri renderToSingleUseBlob() { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + Bitmap image = imageEditorView.getModel().render(requireContext()); + + image.compress(Bitmap.CompressFormat.JPEG, 80, outputStream); + image.recycle(); + + return BlobProvider.getInstance() + .forData(outputStream.toByteArray()) + .withMimeType(MediaUtil.IMAGE_JPEG) + .createForSingleUseInMemory(); + } + private void refreshUniqueColors() { imageEditorHud.setColorPalette(imageEditorView.getModel().getUniqueColorsIgnoringAlpha()); } @@ -587,4 +619,35 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu this.position.preConcat(imageProjectionMatrix); } } + + private enum Mode { + + NORMAL("normal"), + AVATAR_CAPTURE("avatar_capture"), + AVATAR_EDIT("avatar_edit"); + + private final String code; + + Mode(@NonNull String code) { + this.code = code; + } + + String getCode() { + return code; + } + + static Mode getByCode(@Nullable String code) { + if (code == null) { + return NORMAL; + } + + for (Mode mode : values()) { + if (Objects.equals(code, mode.code)) { + return mode; + } + } + + return NORMAL; + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java index 105071ff8..ee96b3c72 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java @@ -154,6 +154,6 @@ public final class AvatarUtil { private static Drawable getFallback(@NonNull Context context, @NonNull Recipient recipient) { String name = Optional.fromNullable(recipient.getDisplayName(context)).or(""); - return new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40).asDrawable(context, recipient.getAvatarColor().colorInt()); + return new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40).asDrawable(context, recipient.getAvatarColor()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConversationShortcutPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationShortcutPhoto.java index 4d2ac100c..e9cb1e198 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConversationShortcutPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationShortcutPhoto.java @@ -13,7 +13,6 @@ import com.bumptech.glide.load.DataSource; import com.bumptech.glide.load.Key; import com.bumptech.glide.load.Options; import com.bumptech.glide.load.data.DataFetcher; -import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.model.ModelLoader; import com.bumptech.glide.load.model.ModelLoaderFactory; import com.bumptech.glide.load.model.MultiModelLoaderFactory; @@ -23,9 +22,8 @@ import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp; import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.SystemContactPhoto; +import org.thoughtcrime.securesms.conversation.colors.AvatarColor; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.mms.GlideApp; -import org.thoughtcrime.securesms.mms.GlideRequest; import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.recipients.Recipient; import org.whispersystems.libsignal.util.ByteUtil; @@ -186,8 +184,8 @@ public final class ConversationShortcutPhoto implements Key { photoSource = R.drawable.ic_profile_80; } - FallbackContactPhoto photo = recipient.isSelf() || recipient.isGroup() ? new FallbackPhoto80dp(photoSource, recipient.getAvatarColor().colorInt()) - : new ShortcutGeneratedContactPhoto(recipient.getDisplayName(context), photoSource, ViewUtil.dpToPx(80), ViewUtil.dpToPx(28), recipient.getAvatarColor().colorInt()); + FallbackContactPhoto photo = recipient.isSelf() || recipient.isGroup() ? new FallbackPhoto80dp(photoSource, recipient.getAvatarColor()) + : new ShortcutGeneratedContactPhoto(recipient.getDisplayName(context), photoSource, ViewUtil.dpToPx(80), recipient.getAvatarColor()); Bitmap toWrap = DrawableUtil.toBitmap(photo.asCallCard(context), ViewUtil.dpToPx(80), ViewUtil.dpToPx(80)); Bitmap wrapped = DrawableUtil.wrapBitmapForShortcutInfo(toWrap); @@ -199,20 +197,20 @@ public final class ConversationShortcutPhoto implements Key { private static final class ShortcutGeneratedContactPhoto extends GeneratedContactPhoto { - private final int color; + private final AvatarColor color; - public ShortcutGeneratedContactPhoto(@NonNull String name, int fallbackResId, int targetSize, int fontSize, int color) { - super(name, fallbackResId, targetSize, fontSize); + public ShortcutGeneratedContactPhoto(@NonNull String name, int fallbackResId, int targetSize, @NonNull AvatarColor color) { + super(name, fallbackResId, targetSize); this.color = color; } @Override - protected Drawable newFallbackDrawable(@NonNull Context context, int color, boolean inverted) { - return new FallbackPhoto80dp(getFallbackResId(), color).asDrawable(context, -1); + protected Drawable newFallbackDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) { + return new FallbackPhoto80dp(getFallbackResId(), color).asDrawable(context, AvatarColor.UNKNOWN); } - @Override public Drawable asCallCard(Context context) { + @Override public Drawable asCallCard(@NonNull Context context) { return new FallbackPhoto80dp(getFallbackResId(), color).asCallCard(context); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java index 2eccc687e..8d0bc2b9b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java @@ -7,6 +7,7 @@ import android.view.ViewGroup; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; @@ -33,16 +34,16 @@ import kotlin.jvm.functions.Function1; * override compiler typing recommendations when binding and diffing. *

* General pattern for implementation: - *
    - *
  1. Create {@link MappingModel}s for the items in the list. These encapsulate data massaging methods for views to use and the diff logic.
  2. - *
  3. Create {@link MappingViewHolder}s for each item type in the list and their corresponding {@link Factory}.
  4. - *
  5. Create an instance or subclass of {@link MappingAdapter} and register the mapping of model type to view holder factory for that model type.
  6. - *
- * Event listeners, click or otherwise, are handled at the view holder level and should be passed into the appropriate view holder factories. This - * pattern mimics how we pass data into view models via factories. - *

- * NOTE: There can only be on factory registered per model type. Registering two for the same type will result in the last one being used. However, the - * same factory can be registered multiple times for multiple model types (if the model type class hierarchy supports it). + *
    + *
  1. Create {@link MappingModel}s for the items in the list. These encapsulate data massaging methods for views to use and the diff logic.
  2. + *
  3. Create {@link MappingViewHolder}s for each item type in the list and their corresponding {@link Factory}.
  4. + *
  5. Create an instance or subclass of {@link MappingAdapter} and register the mapping of model type to view holder factory for that model type.
  6. + *
+ * Event listeners, click or otherwise, are handled at the view holder level and should be passed into the appropriate view holder factories. This + * pattern mimics how we pass data into view models via factories. + *

+ * NOTE: There can only be on factory registered per model type. Registering two for the same type will result in the last one being used. However, the + * same factory can be registered multiple times for multiple model types (if the model type class hierarchy supports it). */ public class MappingAdapter extends ListAdapter, MappingViewHolder> { @@ -102,6 +103,12 @@ public class MappingAdapter extends ListAdapter, MappingViewHold return Objects.requireNonNull(factories.get(viewType)).createViewHolder(parent); } + @Override + public void onBindViewHolder(@NonNull MappingViewHolder holder, int position, @NonNull List payloads) { + holder.setPayload(payloads); + onBindViewHolder(holder, position); + } + @Override public void onBindViewHolder(@NonNull MappingViewHolder holder, int position) { //noinspection unchecked @@ -142,6 +149,16 @@ public class MappingAdapter extends ListAdapter, MappingViewHold } return false; } + + @Override + public @Nullable Object getChangePayload(@NonNull MappingModel oldItem, @NonNull MappingModel newItem) { + if (oldItem.getClass() == newItem.getClass()) { + //noinspection unchecked + return oldItem.getChangePayload(newItem); + } + + return null; + } } public interface Factory> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MappingModel.java b/app/src/main/java/org/thoughtcrime/securesms/util/MappingModel.java index 0e5233d17..21fc15124 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MappingModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MappingModel.java @@ -1,8 +1,13 @@ package org.thoughtcrime.securesms.util; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; public interface MappingModel { boolean areItemsTheSame(@NonNull T newItem); boolean areContentsTheSame(@NonNull T newItem); + + default @Nullable Object getChangePayload(@NonNull T newItem) { + return null; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java index 4fa8c76eb..09386a242 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java @@ -7,13 +7,18 @@ import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.lifecycle.LifecycleOwner; +import java.util.LinkedList; +import java.util.List; + public abstract class MappingViewHolder> extends LifecycleViewHolder implements LifecycleOwner { - protected final Context context; + protected final Context context; + protected final List payload; public MappingViewHolder(@NonNull View itemView) { super(itemView); context = itemView.getContext(); + payload = new LinkedList<>(); } public T findViewById(@IdRes int id) { @@ -26,6 +31,11 @@ public abstract class MappingViewHolder> exten public abstract void bind(@NonNull Model model); + public void setPayload(@NonNull List payload) { + this.payload.clear(); + this.payload.addAll(payload); + } + public static final class SimpleViewHolder> extends MappingViewHolder { public SimpleViewHolder(@NonNull View itemView) { super(itemView); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/NameUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/NameUtil.kt new file mode 100644 index 000000000..18d8cf67a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/NameUtil.kt @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.util + +import android.text.TextUtils +import java.util.regex.Pattern + +object NameUtil { + private val PATTERN = Pattern.compile("[^\\p{L}\\p{Nd}\\p{S}]+") + + /** + * Returns an abbreviation of the input, up to two characters long. + */ + @JvmStatic + fun getAbbreviation(name: String): String? { + val parts = name.split(" ").toTypedArray() + val builder = StringBuilder() + var count = 0 + var i = 0 + + while (i < parts.size && count < 2) { + val cleaned = PATTERN.matcher(parts[i]).replaceFirst("") + if (!TextUtils.isEmpty(cleaned)) { + builder.appendCodePoint(cleaned.codePointAt(0)) + count++ + } + i++ + } + + return if (builder.isEmpty()) { + null + } else { + builder.toString() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/storage/FileStorage.java b/app/src/main/java/org/thoughtcrime/securesms/util/storage/FileStorage.java new file mode 100644 index 000000000..476ee61c1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/storage/FileStorage.java @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.util.storage; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.StreamUtil; +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; +import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Manages the storage of custom files. + */ +public final class FileStorage { + + /** + * Saves the provided input stream as a new file. + */ + @WorkerThread + public static @NonNull String save(@NonNull Context context, + @NonNull InputStream inputStream, + @NonNull String directoryName, + @NonNull String fileNameBase, + @NonNull String extension + ) throws IOException + { + File directory = context.getDir(directoryName, Context.MODE_PRIVATE); + File file = File.createTempFile(fileNameBase, "." + extension, directory); + + StreamUtil.copy(inputStream, getOutputStream(context, file)); + + return file.getName(); + } + + @WorkerThread + public static @NonNull InputStream read(@NonNull Context context, + @NonNull String directoryName, + @NonNull String filename) throws IOException + { + File directory = context.getDir(directoryName, Context.MODE_PRIVATE); + File file = new File(directory, filename); + + return getInputStream(context, file); + } + + @WorkerThread + public static @NonNull List getAll(@NonNull Context context, + @NonNull String directoryName, + @NonNull String fileNameBase) + { + return getAllFiles(context, directoryName, fileNameBase).stream() + .map(File::getName) + .collect(Collectors.toList()); + } + + @WorkerThread + public static @NonNull List getAllFiles(@NonNull Context context, + @NonNull String directoryName, + @NonNull String fileNameBase) + { + File directory = context.getDir(directoryName, Context.MODE_PRIVATE); + File[] allFiles = directory.listFiles(pathname -> pathname.getName().contains(fileNameBase)); + + if (allFiles != null) { + return Arrays.asList(allFiles); + } else { + return Collections.emptyList(); + } + } + + private static @NonNull OutputStream getOutputStream(@NonNull Context context, File outputFile) throws IOException { + AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + return ModernEncryptingPartOutputStream.createFor(attachmentSecret, outputFile, true).second; + } + + private static @NonNull InputStream getInputStream(@NonNull Context context, File inputFile) throws IOException { + AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + return ModernDecryptingPartInputStream.createFor(attachmentSecret, inputFile, 0); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewModel.java index e6d1fb7f7..318fbbc26 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewModel.java @@ -58,7 +58,7 @@ public class ChatWallpaperViewModel extends ViewModel { }); } else { liveRecipient = null; - wallpaperPreviewPortrait = new DefaultValueLiveData<>(new WallpaperPreviewPortrait.SolidColor(AvatarColor.ULTRAMARINE)); + wallpaperPreviewPortrait = new DefaultValueLiveData<>(new WallpaperPreviewPortrait.SolidColor(AvatarColor.A100)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/WallpaperStorage.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/WallpaperStorage.java index 70ab2ce4b..8c2804f7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/WallpaperStorage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/WallpaperStorage.java @@ -6,25 +6,18 @@ import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; -import com.annimon.stream.Stream; - -import org.signal.core.util.StreamUtil; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.crypto.AttachmentSecret; -import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; -import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; -import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.storage.FileStorage; import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; -import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; /** * Manages the storage of custom wallpaper files. @@ -41,36 +34,23 @@ public final class WallpaperStorage { */ @WorkerThread public static @NonNull ChatWallpaper save(@NonNull Context context, @NonNull InputStream wallpaperStream, @NonNull String extension) throws IOException { - File directory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); - File file = File.createTempFile(FILENAME_BASE, "." + extension, directory); + String name = FileStorage.save(context, wallpaperStream, DIRECTORY, FILENAME_BASE, extension); - StreamUtil.copy(wallpaperStream, getOutputStream(context, file)); - - return ChatWallpaperFactory.create(PartAuthority.getWallpaperUri(file.getName())); + return ChatWallpaperFactory.create(PartAuthority.getWallpaperUri(name)); } @WorkerThread public static @NonNull InputStream read(@NonNull Context context, String filename) throws IOException { - File directory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); - File wallpaperFile = new File(directory, filename); - - return getInputStream(context, wallpaperFile); + return FileStorage.read(context, DIRECTORY, filename); } @WorkerThread public static @NonNull List getAll(@NonNull Context context) { - File directory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); - File[] allFiles = directory.listFiles(pathname -> pathname.getName().contains(FILENAME_BASE)); - - if (allFiles != null) { - return Stream.of(allFiles) - .map(File::getName) - .map(PartAuthority::getWallpaperUri) - .map(ChatWallpaperFactory::create) - .toList(); - } else { - return Collections.emptyList(); - } + return FileStorage.getAll(context, DIRECTORY, FILENAME_BASE) + .stream() + .map(PartAuthority::getWallpaperUri) + .map(ChatWallpaperFactory::create) + .collect(Collectors.toList()); } /** @@ -97,14 +77,4 @@ public final class WallpaperStorage { Log.w(TAG, "Failed to delete " + filename + "!"); } } - - private static @NonNull OutputStream getOutputStream(@NonNull Context context, File outputFile) throws IOException { - AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); - return ModernEncryptingPartOutputStream.createFor(attachmentSecret, outputFile, true).second; - } - - private static @NonNull InputStream getInputStream(@NonNull Context context, File inputFile) throws IOException { - AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); - return ModernDecryptingPartInputStream.createFor(attachmentSecret, inputFile, 0); - } } diff --git a/app/src/main/proto/Database.proto b/app/src/main/proto/Database.proto index 872205234..9935e5fea 100644 --- a/app/src/main/proto/Database.proto +++ b/app/src/main/proto/Database.proto @@ -149,4 +149,28 @@ message ChatColor { message RecipientExtras { bool manuallyShownAvatar = 1; +} + +message CustomAvatar { + + message Text { + string text = 1; + string colors = 2; + } + + message Vector { + string key = 1; + string colors = 2; + } + + message Photo { + string uri = 1; + int64 size = 2; + } + + oneof avatar { + Text text = 1; + Vector vector = 2; + Photo photo = 3; + } } \ No newline at end of file diff --git a/app/src/main/res/anim/fragment_close_enter.xml b/app/src/main/res/anim/fragment_close_enter.xml new file mode 100644 index 000000000..506fd30bb --- /dev/null +++ b/app/src/main/res/anim/fragment_close_enter.xml @@ -0,0 +1,40 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fragment_close_exit.xml b/app/src/main/res/anim/fragment_close_exit.xml new file mode 100644 index 000000000..be1dd7e69 --- /dev/null +++ b/app/src/main/res/anim/fragment_close_exit.xml @@ -0,0 +1,41 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fragment_open_enter.xml b/app/src/main/res/anim/fragment_open_enter.xml new file mode 100644 index 000000000..41dfbead9 --- /dev/null +++ b/app/src/main/res/anim/fragment_open_enter.xml @@ -0,0 +1,42 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fragment_open_exit.xml b/app/src/main/res/anim/fragment_open_exit.xml new file mode 100644 index 000000000..9b9783287 --- /dev/null +++ b/app/src/main/res/anim/fragment_open_exit.xml @@ -0,0 +1,41 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/avatar_color_item_ring.xml b/app/src/main/res/drawable/avatar_color_item_ring.xml new file mode 100644 index 000000000..247fdee1b --- /dev/null +++ b/app/src/main/res/drawable/avatar_color_item_ring.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/avatar_picker_item_ring.xml b/app/src/main/res/drawable/avatar_picker_item_ring.xml new file mode 100644 index 000000000..5a96888a8 --- /dev/null +++ b/app/src/main/res/drawable/avatar_picker_item_ring.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_transparent_black_20.xml b/app/src/main/res/drawable/circle_transparent_black_20.xml new file mode 100644 index 000000000..349c89fa7 --- /dev/null +++ b/app/src/main/res/drawable/circle_transparent_black_20.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/ic_avatar_abstract_01.xml b/app/src/main/res/drawable/ic_avatar_abstract_01.xml new file mode 100644 index 000000000..fd93b0eb0 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_abstract_01.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_abstract_02.xml b/app/src/main/res/drawable/ic_avatar_abstract_02.xml new file mode 100644 index 000000000..76f694dc9 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_abstract_02.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_abstract_03.xml b/app/src/main/res/drawable/ic_avatar_abstract_03.xml new file mode 100644 index 000000000..242d06827 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_abstract_03.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_balloon.xml b/app/src/main/res/drawable/ic_avatar_balloon.xml new file mode 100644 index 000000000..411e06f96 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_balloon.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_avatar_book.xml b/app/src/main/res/drawable/ic_avatar_book.xml new file mode 100644 index 000000000..b7ed2f631 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_book.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_briefcase.xml b/app/src/main/res/drawable/ic_avatar_briefcase.xml new file mode 100644 index 000000000..f200ab7e4 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_briefcase.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_cat.xml b/app/src/main/res/drawable/ic_avatar_cat.xml new file mode 100644 index 000000000..0ea1cdc4a --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_cat.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_celebration.xml b/app/src/main/res/drawable/ic_avatar_celebration.xml new file mode 100644 index 000000000..83a5d25b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_celebration.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_dinosour.xml b/app/src/main/res/drawable/ic_avatar_dinosour.xml new file mode 100644 index 000000000..d4fd5f61a --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_dinosour.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_dog.xml b/app/src/main/res/drawable/ic_avatar_dog.xml new file mode 100644 index 000000000..b19545827 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_dog.xml @@ -0,0 +1,31 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_drink.xml b/app/src/main/res/drawable/ic_avatar_drink.xml new file mode 100644 index 000000000..8c14af115 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_drink.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_football.xml b/app/src/main/res/drawable/ic_avatar_football.xml new file mode 100644 index 000000000..dc9b5c1d3 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_football.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_fox.xml b/app/src/main/res/drawable/ic_avatar_fox.xml new file mode 100644 index 000000000..f7ebbdd0d --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_fox.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_ghost.xml b/app/src/main/res/drawable/ic_avatar_ghost.xml new file mode 100644 index 000000000..9b7eb4cdf --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_ghost.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_heart.xml b/app/src/main/res/drawable/ic_avatar_heart.xml new file mode 100644 index 000000000..25d53f9bf --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_heart.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_house.xml b/app/src/main/res/drawable/ic_avatar_house.xml new file mode 100644 index 000000000..2258df6e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_house.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_incognito.xml b/app/src/main/res/drawable/ic_avatar_incognito.xml new file mode 100644 index 000000000..89b38f376 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_incognito.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_melon.xml b/app/src/main/res/drawable/ic_avatar_melon.xml new file mode 100644 index 000000000..1a6bd8277 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_melon.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_pig.xml b/app/src/main/res/drawable/ic_avatar_pig.xml new file mode 100644 index 000000000..1c92f101b --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_pig.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_sloth.xml b/app/src/main/res/drawable/ic_avatar_sloth.xml new file mode 100644 index 000000000..10758e086 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_sloth.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_soccerball.xml b/app/src/main/res/drawable/ic_avatar_soccerball.xml new file mode 100644 index 000000000..d949519d9 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_soccerball.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_sunset.xml b/app/src/main/res/drawable/ic_avatar_sunset.xml new file mode 100644 index 000000000..5d4632a58 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_sunset.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_surfboard.xml b/app/src/main/res/drawable/ic_avatar_surfboard.xml new file mode 100644 index 000000000..e1519dc5c --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_surfboard.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_avatar_tucan.xml b/app/src/main/res/drawable/ic_avatar_tucan.xml new file mode 100644 index 000000000..fcd05bcf4 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar_tucan.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_text_24.xml b/app/src/main/res/drawable/ic_text_24.xml new file mode 100644 index 000000000..04c2fe265 --- /dev/null +++ b/app/src/main/res/drawable/ic_text_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/add_group_details_activity.xml b/app/src/main/res/layout/add_group_details_activity.xml index b193a4772..c2fe2cfdd 100644 --- a/app/src/main/res/layout/add_group_details_activity.xml +++ b/app/src/main/res/layout/add_group_details_activity.xml @@ -1,9 +1,5 @@ - \ No newline at end of file + android:layout_height="match_parent" /> \ No newline at end of file diff --git a/app/src/main/res/layout/avatar_color_item.xml b/app/src/main/res/layout/avatar_color_item.xml new file mode 100644 index 000000000..50498ae2f --- /dev/null +++ b/app/src/main/res/layout/avatar_color_item.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/avatar_photo_editor_fragment.xml b/app/src/main/res/layout/avatar_photo_editor_fragment.xml new file mode 100644 index 000000000..90c9e6842 --- /dev/null +++ b/app/src/main/res/layout/avatar_photo_editor_fragment.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/avatar_picker_fragment.xml b/app/src/main/res/layout/avatar_picker_fragment.xml new file mode 100644 index 000000000..eed83a10c --- /dev/null +++ b/app/src/main/res/layout/avatar_picker_fragment.xml @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/avatar_picker_item.xml b/app/src/main/res/layout/avatar_picker_item.xml new file mode 100644 index 000000000..2c220a635 --- /dev/null +++ b/app/src/main/res/layout/avatar_picker_item.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/media_overview_tab_item.xml b/app/src/main/res/layout/bold_selection_tab_item.xml similarity index 84% rename from app/src/main/res/layout/media_overview_tab_item.xml rename to app/src/main/res/layout/bold_selection_tab_item.xml index 82725dedb..79ae9fe5f 100644 --- a/app/src/main/res/layout/media_overview_tab_item.xml +++ b/app/src/main/res/layout/bold_selection_tab_item.xml @@ -1,5 +1,5 @@ - @@ -27,4 +27,4 @@ android:textColor="@color/signal_text_primary" tools:text="Media" /> - + diff --git a/app/src/main/res/layout/button_strip_item_view.xml b/app/src/main/res/layout/button_strip_item_view.xml new file mode 100644 index 000000000..cf866bb21 --- /dev/null +++ b/app/src/main/res/layout/button_strip_item_view.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/profile_create_activity.xml b/app/src/main/res/layout/profile_create_activity.xml index ac5b7dede..d56c74e97 100644 --- a/app/src/main/res/layout/profile_create_activity.xml +++ b/app/src/main/res/layout/profile_create_activity.xml @@ -1,21 +1,7 @@ - - - - - + tools:context=".profiles.edit.EditProfileActivity" /> \ No newline at end of file diff --git a/app/src/main/res/layout/profile_create_fragment.xml b/app/src/main/res/layout/profile_create_fragment.xml index 3777d5c85..e10967cbc 100644 --- a/app/src/main/res/layout/profile_create_fragment.xml +++ b/app/src/main/res/layout/profile_create_fragment.xml @@ -57,7 +57,6 @@ android:layout_height="96dp" android:layout_marginTop="16dp" android:src="@drawable/circle_tintable" - android:tint="@color/core_grey_05" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/title" @@ -69,7 +68,6 @@ android:layout_height="0dp" android:layout_marginStart="16dp" android:layout_marginEnd="16dp" - android:tint="@color/core_grey_75" android:transitionName="avatar" app:layout_constraintBottom_toBottomOf="@+id/avatar_background" app:layout_constraintEnd_toEndOf="@+id/avatar_background" diff --git a/app/src/main/res/layout/text_avatar_creation_fragment.xml b/app/src/main/res/layout/text_avatar_creation_fragment.xml new file mode 100644 index 000000000..abe62f903 --- /dev/null +++ b/app/src/main/res/layout/text_avatar_creation_fragment.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/text_avatar_creation_fragment_hidden_recycler.xml b/app/src/main/res/layout/text_avatar_creation_fragment_hidden_recycler.xml new file mode 100644 index 000000000..1ce67a29d --- /dev/null +++ b/app/src/main/res/layout/text_avatar_creation_fragment_hidden_recycler.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/vector_avatar_creation_fragment.xml b/app/src/main/res/layout/vector_avatar_creation_fragment.xml new file mode 100644 index 000000000..cdf5d9374 --- /dev/null +++ b/app/src/main/res/layout/vector_avatar_creation_fragment.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/avatar_picker_context.xml b/app/src/main/res/menu/avatar_picker_context.xml new file mode 100644 index 000000000..ab89f8d99 --- /dev/null +++ b/app/src/main/res/menu/avatar_picker_context.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/avatar_picker.xml b/app/src/main/res/navigation/avatar_picker.xml new file mode 100644 index 000000000..e73095ec9 --- /dev/null +++ b/app/src/main/res/navigation/avatar_picker.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/create_group.xml b/app/src/main/res/navigation/create_group.xml index 38ad375be..f964866ee 100644 --- a/app/src/main/res/navigation/create_group.xml +++ b/app/src/main/res/navigation/create_group.xml @@ -16,6 +16,32 @@ app:argType="org.thoughtcrime.securesms.recipients.RecipientId[]" app:nullable="false" /> + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/edit_profile.xml b/app/src/main/res/navigation/edit_profile.xml index a37ec5fb9..29ae9d4fe 100644 --- a/app/src/main/res/navigation/edit_profile.xml +++ b/app/src/main/res/navigation/edit_profile.xml @@ -19,6 +19,24 @@ app:popEnterAnim="@anim/nav_default_pop_enter_anim" app:popExitAnim="@anim/nav_default_pop_exit_anim" /> + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/manage_profile.xml b/app/src/main/res/navigation/manage_profile.xml index 98178db33..f3e0088bf 100644 --- a/app/src/main/res/navigation/manage_profile.xml +++ b/app/src/main/res/navigation/manage_profile.xml @@ -35,6 +35,26 @@ app:popEnterAnim="@anim/slide_from_start" app:popExitAnim="@anim/slide_to_end" /> + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-sw360dp/dimens.xml b/app/src/main/res/values-sw360dp/dimens.xml index 739ce8533..c3dc4c6a2 100644 --- a/app/src/main/res/values-sw360dp/dimens.xml +++ b/app/src/main/res/values-sw360dp/dimens.xml @@ -27,4 +27,6 @@ 32dp 56dp 16dp + + 160dp \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 2f8fa6f82..94a8eee3d 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -310,4 +310,10 @@ + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 64d668661..081ef0daf 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -191,4 +191,6 @@ 16dp 48dp 12dp + + 100dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c3b0e0c0..c38ed5118 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3620,10 +3620,25 @@ %1$s · %2$s + Avatar preview + Camera + Take a picture + Choose a photo + Photo + Text + Save + Select an avatar + Preview + Done + Text + Color SMS · %1$s + Clear avatar + Edit + Failed to save avatar diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 7c39f7b5f..4dbd43a76 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -399,6 +399,11 @@ 12dp + +