Compare commits

..

14 Commits

Author SHA1 Message Date
Greyson Parrelli
b2c3a34d68 Bump version to 5.18.0 2021-07-21 16:57:04 -04:00
Greyson Parrelli
90925f4d8c Updated language translations. 2021-07-21 16:57:04 -04:00
Lucio Maciel
833f90ce53 Fix margins on message clusters and 1:1 messages. 2021-07-21 16:57:04 -04:00
Lucio Maciel
26c9b5166e Update timer icons and received text bubble. 2021-07-21 16:57:04 -04:00
Alex Hart
a27d60f830 Adjust new avatar picker logic.
* Better emoji rendering support
* Deleting an avatar will deselect it
* Added padding to the bottom of recyclers
* Disabled save if no edit / selection has been made.
* Clearing and saving will remove a user's avatar.
2021-07-21 16:57:04 -04:00
Alex Hart
a75f634c0a Add megaphone for new avatar picker. 2021-07-21 16:57:04 -04:00
lucio-signal
963c018e0c Add SingleLineEmojiTextView to fix flickering on conversations list. 2021-07-21 16:57:04 -04:00
Alex Hart
6cc0eed5fe Fail linked preview thumbnail request instead of crashing app. 2021-07-21 16:57:04 -04:00
Alex Hart
cdcc7b6fa5 Fix voice note player crash in Android 4.4 2021-07-21 16:57:04 -04:00
Alex Hart
24482b5a65 Disable conversation overscroll for Android 12. 2021-07-21 16:57:03 -04:00
Alex Hart
b100262c6a Fix crash when sending video (due to IllegalStateException). 2021-07-21 16:57:03 -04:00
Alex Hart
ed23c3fe7c Add avatar picker and defaults. 2021-07-21 16:57:03 -04:00
Greyson Parrelli
0093e1d3eb Add the ability to increase log lifespan. 2021-07-21 16:57:03 -04:00
Greyson Parrelli
7419da7247 Move logging into a database. 2021-07-21 16:57:03 -04:00
266 changed files with 7219 additions and 1637 deletions

View File

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

View File

@@ -57,8 +57,8 @@ protobuf {
}
}
def canonicalVersionCode = 881
def canonicalVersionName = "5.17.3"
def canonicalVersionCode = 882
def canonicalVersionName = "5.18.0"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -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'

Binary file not shown.

View File

@@ -27,17 +27,15 @@ import androidx.multidex.MultiDexApplication;
import com.google.android.gms.security.ProviderInstaller;
import net.sqlcipher.database.SQLiteDatabase;
import org.conscrypt.Conscrypt;
import org.signal.aesgcmprovider.AesGcmProvider;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.AndroidLogger;
import org.signal.core.util.logging.Log;
import org.signal.core.util.logging.PersistentLogger;
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;
@@ -56,7 +54,7 @@ import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
import org.thoughtcrime.securesms.logging.LogSecretProvider;
import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
@@ -74,7 +72,6 @@ import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.ByteUnit;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
@@ -156,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)
@@ -248,7 +246,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
private void initializeLogging() {
persistentLogger = new PersistentLogger(this, LogSecretProvider.getOrCreateAttachmentSecret(this), BuildConfig.VERSION_NAME, FeatureFlags.internalUser() ? 15 : 7, ByteUnit.KILOBYTES.toBytes(300));
persistentLogger = new PersistentLogger(this, TimeUnit.DAYS.toMillis(2));
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
@@ -379,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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,132 @@
package org.thoughtcrime.securesms.avatar
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
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 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 {
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,
): Drawable {
return TextAvatarDrawable(context, avatar, inverted, size)
}
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<Unit>) {
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())
}
}

View File

@@ -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<String, ColorPair> = ForegroundColor.values().map {
ColorPair(AvatarColor.deserialize(it.serialize()), it)
}.associateBy {
it.code
}
val colors: List<ColorPair> = 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()
}
}

View File

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

View File

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

View File

@@ -0,0 +1,235 @@
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"
const val SELECT_AVATAR_CLEAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_CLEAR"
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()
},
{
setFragmentResult(
REQUEST_KEY_SELECT_AVATAR,
Bundle().apply {
putBoolean(SELECT_AVATAR_CLEAR, true)
}
)
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()
}
}

View File

@@ -0,0 +1,144 @@
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 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<Model> {
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<Model>(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 { _, _, _, _, _, _, _, _, _ ->
updateAndApplyText(textView.text.toString())
}
}
private fun updateAndApplyText(text: String) {
val textSize = Avatars.getTextSizeForLength(context, text, textView.measuredWidth * 0.8f, textView.measuredHeight * 0.45f)
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
textView.text = text
}
override fun bind(model: Model) {
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
updateAndApplyText(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)
}
}
}
}
}

View File

@@ -0,0 +1,189 @@
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.conversation.colors.AvatarColor
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<Avatar> = 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<Avatar> = 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(recipient.avatarColor)
}
} else {
getDefaultAvatarForGroup(recipient.avatarColor)
}
}
fun getPersistedAvatarsForSelf(): Single<List<Avatar>> = Single.fromCallable {
DatabaseFactory.getAvatarPickerDatabase(applicationContext).getAvatarsForSelf()
}
fun getPersistedAvatarsForGroup(groupId: GroupId): Single<List<Avatar>> = Single.fromCallable {
DatabaseFactory.getAvatarPickerDatabase(applicationContext).getAvatarsForGroup(groupId)
}
fun getDefaultAvatarsForSelf(): Single<List<Avatar>> = 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<List<Avatar>> = 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, requireNotNull(Avatars.colorMap[Recipient.self().avatarColor.serialize()]), Avatar.DatabaseId.DoNotPersist)
}
}
fun getDefaultAvatarForGroup(groupId: GroupId): Avatar {
val recipient = Recipient.externalGroupExact(applicationContext, groupId)
return getDefaultAvatarForGroup(recipient.avatarColor)
}
fun getDefaultAvatarForGroup(color: AvatarColor?): Avatar {
val colorPair = Avatars.colorMap[color?.serialize()]
val defaultColor = Avatar.getDefaultForGroup()
return if (colorPair != null) {
defaultColor.copy(color = colorPair)
} else {
defaultColor
}
}
fun delete(avatar: Avatar, onDelete: () -> Unit) {
SignalExecutors.BOUNDED.execute {
if (avatar.databaseId is Avatar.DatabaseId.Saved) {
val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
avatarDatabase.deleteAvatar(avatar)
}
onDelete()
}
}
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.avatar.picker
import org.thoughtcrime.securesms.avatar.Avatar
data class AvatarPickerState(
val currentAvatar: Avatar? = null,
val selectableAvatars: List<Avatar> = listOf(),
val canSave: Boolean = false,
val canClear: Boolean = false,
val isCleared: Boolean = false
)

View File

@@ -0,0 +1,198 @@
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<AvatarPickerState> = store.stateLiveData
protected abstract fun getAvatar(): Single<Avatar>
protected abstract fun getDefaultAvatarFromRepository(): Avatar
protected abstract fun getPersistedAvatars(): Single<List<Avatar>>
protected abstract fun getDefaultAvatars(): Single<List<Avatar>>
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) {
refreshAvatar()
refreshSelectableAvatars()
}
}
fun clear() {
store.update {
val avatar = getDefaultAvatarFromRepository()
it.copy(currentAvatar = avatar, canSave = true, canClear = false, isCleared = true)
}
}
fun save(onSaved: (Media) -> Unit, onCleared: () -> Unit) {
if (store.state.isCleared) {
onCleared()
} else {
val avatar = store.state.currentAvatar ?: throw AssertionError()
persistAndCreateMedia(avatar, onSaved)
}
}
fun onAvatarSelectedFromGrid(avatar: Avatar) {
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
}
fun onAvatarEditCompleted(avatar: Avatar) {
persistAvatar(avatar) { saved ->
store.update { it.copy(currentAvatar = saved, canSave = isSaveable(saved), canClear = true, isCleared = false) }
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, isCleared = false) }
refreshSelectableAvatars()
}
}
}
protected fun refreshAvatar() {
disposables.add(
getAvatar().subscribeOn(Schedulers.io()).subscribe { avatar ->
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = avatar is Avatar.Photo && !isSaveable(avatar), isCleared = false) }
}
)
}
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.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<Avatar> = repository.getAvatarForSelf()
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForSelf()
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForSelf()
override fun getDefaultAvatars(): Single<List<Avatar>> = 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<Avatar> {
return if (initialAvatar != null) {
Single.just(initialAvatar)
} else {
repository.getAvatarForGroup(groupId)
}
}
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(groupId)
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForGroup(groupId)
override fun getDefaultAvatars(): Single<List<Avatar>> = 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<Avatar> {
return if (initialAvatar != null) {
Single.just(initialAvatar)
} else {
Single.fromCallable { getDefaultAvatarFromRepository() }
}
}
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(null)
override fun getPersistedAvatars(): Single<List<Avatar>> = Single.just(listOf())
override fun getDefaultAvatars(): Single<List<Avatar>> = 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 <T : ViewModel?> create(modelClass: Class<T>): 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))
}
}
}

View File

@@ -0,0 +1,158 @@
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 var hasBoundFromViewModel: Boolean = false
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
val selectionStart = textInput.selectionStart
val selectionEnd = textInput.selectionEnd
viewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
textInput.post {
if (!hadText) {
textInput.setSelection(textInput.length())
} else {
textInput.setSelection(selectionStart, selectionEnd)
}
}
adapter.submitList(state.colors().map { AvatarColorItem.Model(it) })
hasBoundFromViewModel = true
}
EditTextUtil.addGraphemeClusterLimitFilter(textInput, 3)
textInput.doAfterTextChanged {
if (it != null && hasBoundFromViewModel) {
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"
}
}

View File

@@ -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<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.avatar.text
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.avatar.Avatar
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<TextAvatarCreationState> = Transformations.distinctUntilChanged(store.stateLiveData)
fun setColor(colorPair: Avatars.ColorPair) {
store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) }
}
fun setText(text: String) {
store.update {
if (it.currentAvatar.text == text) {
it
} else {
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 <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(TextAvatarCreationViewModel(initialText)))
}
}
}

View File

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

View File

@@ -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<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.components.emoji
import android.content.Context
import android.text.TextUtils
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.libsignal.util.guava.Optional
open class SingleLineEmojiTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
private var bufferType: BufferType? = null
init {
maxLines = 1
}
override fun setText(text: CharSequence?, type: BufferType?) {
bufferType = type
val candidates = if (isInEditMode) null else EmojiProvider.getCandidates(text)
if (SignalStore.settings().isPreferSystemEmoji || candidates == null || candidates.size() == 0) {
super.setText(Optional.fromNullable(text).or(""), BufferType.NORMAL)
} else {
val newContent = if (width == 0) {
text
} else {
TextUtils.ellipsize(text, paint, width.toFloat(), TextUtils.TruncateAt.END, false, null)
}
val newCandidates = if (isInEditMode) null else EmojiProvider.getCandidates(newContent)
val newText = if (newCandidates == null || newCandidates.size() == 0) {
newContent
} else {
EmojiProvider.emojify(newCandidates, newContent, this)
}
super.setText(newText, type)
}
}
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
super.onSizeChanged(width, height, oldWidth, oldHeight)
if (width > 0 && oldWidth != width) {
setText(text, bufferType ?: BufferType.NORMAL)
}
}
override fun setMaxLines(maxLines: Int) {
check(maxLines == 1) { "setMaxLines: $maxLines != 1" }
super.setMaxLines(maxLines)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Bitmap>() {
@Override
public void onLoadFailed(@Nullable Drawable errorDrawable) {

View File

@@ -263,6 +263,10 @@ public class ConversationFragment extends LoggingFragment {
list.setLayoutManager(layoutManager);
list.setItemAnimator(null);
if (Build.VERSION.SDK_INT >= 31) {
list.setOverScrollMode(View.OVER_SCROLL_NEVER);
}
snapToTopDataObserver = new ConversationSnapToTopDataObserver(list, new ConversationScrollRequestValidator());
conversationBanner = (ConversationBannerView) inflater.inflate(R.layout.conversation_item_banner, container, false);
topLoadMoreView = (ViewSwitcher) inflater.inflate(R.layout.load_more_header, container, false);

View File

@@ -727,6 +727,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
bodyText.setText(StringUtil.trim(styledText));
bodyText.setVisibility(View.VISIBLE);
}
if (!messageRecord.isOutgoing()) {
if (!messageRecord.isMms()) {
ViewUtil.setTopMargin(bodyText, readDimen(R.dimen.message_bubble_content_top_padding));
} else {
ViewUtil.setTopMargin(bodyText, readDimen(R.dimen.message_bubble_text_top_padding));
}
}
}
private void setMediaAttributes(@NonNull MessageRecord messageRecord,
@@ -1123,6 +1131,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread, @NonNull ChatColors chatColors) {
boolean startOfCluster = isStartOfMessageCluster(current, previous, isGroupThread);
if (current.isMms() && !current.isMmsNotification() && ((MediaMmsMessageRecord)current).getQuote() != null) {
if (quoteView == null) {
throw new AssertionError();
@@ -1144,7 +1153,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
quoteView.setOnLongClickListener(passthroughClickListener);
if (isStartOfMessageCluster(current, previous, isGroupThread)) {
if (startOfCluster) {
if (current.isOutgoing()) {
quoteView.setTopCornerSizes(true, true);
} else if (isGroupThread) {
@@ -1172,12 +1181,13 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
quoteView.dismiss();
}
int topMargin = (current.isOutgoing() || !startOfCluster || !groupThread) ? 0 : readDimen(R.dimen.message_bubble_content_top_padding);
if (mediaThumbnailStub.resolved()) {
ViewUtil.setTopMargin(mediaThumbnailStub.get(), 0);
ViewUtil.setTopMargin(mediaThumbnailStub.get(), topMargin);
}
if (linkPreviewStub.resolved()) {
ViewUtil.setTopMargin(linkPreviewStub.get(), 0);
ViewUtil.setTopMargin(linkPreviewStub.get(), topMargin);
}
}
}

View File

@@ -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<String, AvatarColor> 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));
}
}

View File

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

View File

@@ -61,7 +61,7 @@ public class KeyValueDatabase extends SQLiteOpenHelper implements SignalDatabase
return instance;
}
public KeyValueDatabase(@NonNull Application application, @NonNull DatabaseSecret databaseSecret) {
private KeyValueDatabase(@NonNull Application application, @NonNull DatabaseSecret databaseSecret) {
super(application, DATABASE_NAME, null, DATABASE_VERSION, new SqlCipherDatabaseHook(), new SqlCipherErrorHandler(DATABASE_NAME));
this.application = application;

View File

@@ -0,0 +1,166 @@
package org.thoughtcrime.securesms.database
import android.annotation.SuppressLint
import android.app.Application
import android.content.ContentValues
import android.database.Cursor
import net.sqlcipher.database.SQLiteDatabase
import net.sqlcipher.database.SQLiteOpenHelper
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.crypto.DatabaseSecret
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
import org.thoughtcrime.securesms.database.model.LogEntry
import org.thoughtcrime.securesms.util.CursorUtil
import org.thoughtcrime.securesms.util.SqlUtil
import java.io.Closeable
/**
* Stores logs.
*
* Logs are very performance-critical, particularly inserts and deleting old entries.
*
* This is it's own separate physical database, so it cannot do joins or queries with any other
* tables.
*/
class LogDatabase private constructor(
application: Application,
private val databaseSecret: DatabaseSecret
) : SQLiteOpenHelper(
application,
DATABASE_NAME,
null,
DATABASE_VERSION,
SqlCipherDatabaseHook(),
SqlCipherErrorHandler(DATABASE_NAME)
),
SignalDatabase {
companion object {
private val TAG = Log.tag(LogDatabase::class.java)
private const val DATABASE_VERSION = 1
private const val DATABASE_NAME = "signal-logs.db"
private const val TABLE_NAME = "log"
private const val ID = "_id"
private const val CREATED_AT = "created_at"
private const val EXPIRES_AT = "expires_at"
private const val BODY = "body"
private const val SIZE = "size"
private val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY,
$CREATED_AT INTEGER,
$EXPIRES_AT INTEGER,
$BODY TEXT,
$SIZE INTEGER
)
""".trimIndent()
private val CREATE_INDEXES = arrayOf(
"CREATE INDEX log_expires_at_index ON $TABLE_NAME ($EXPIRES_AT)"
)
@SuppressLint("StaticFieldLeak") // We hold an Application context, not a view context
@Volatile
private var instance: LogDatabase? = null
@JvmStatic
fun getInstance(context: Application): LogDatabase {
if (instance == null) {
synchronized(LogDatabase::class.java) {
if (instance == null) {
instance = LogDatabase(context, DatabaseSecretProvider.getOrCreateDatabaseSecret(context))
}
}
}
return instance!!
}
}
override fun onCreate(db: SQLiteDatabase) {
Log.i(TAG, "onCreate()")
db.execSQL(CREATE_TABLE)
CREATE_INDEXES.forEach { db.execSQL(it) }
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
Log.i(TAG, "onUpgrade($oldVersion, $newVersion)")
}
override fun getSqlCipherDatabase(): SQLiteDatabase {
return writableDatabase
}
fun insert(logs: List<LogEntry>, currentTime: Long) {
val db = writableDatabase
db.beginTransaction()
try {
logs.forEach { log ->
db.insert(TABLE_NAME, null, buildValues(log))
}
db.delete(TABLE_NAME, "$EXPIRES_AT < ?", SqlUtil.buildArgs(currentTime))
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
fun getAllBeforeTime(time: Long): Reader {
return Reader(readableDatabase.query(TABLE_NAME, arrayOf(BODY), "$CREATED_AT < ?", SqlUtil.buildArgs(time), null, null, null))
}
fun getRangeBeforeTime(start: Int, length: Int, time: Long): List<String> {
val lines = mutableListOf<String>()
readableDatabase.query(TABLE_NAME, arrayOf(BODY), "$CREATED_AT < ?", SqlUtil.buildArgs(time), null, null, null, "$start,$length").use { cursor ->
while (cursor.moveToNext()) {
lines.add(CursorUtil.requireString(cursor, BODY))
}
}
return lines
}
fun getLogCountBeforeTime(time: Long): Int {
readableDatabase.query(TABLE_NAME, arrayOf("COUNT(*)"), "$CREATED_AT < ?", SqlUtil.buildArgs(time), null, null, null).use { cursor ->
return if (cursor.moveToFirst()) {
cursor.getInt(0)
} else {
0
}
}
}
private fun buildValues(log: LogEntry): ContentValues {
return ContentValues().apply {
put(CREATED_AT, log.createdAt)
put(EXPIRES_AT, log.createdAt + log.lifespan)
put(BODY, log.body)
put(SIZE, log.body.length)
}
}
private val readableDatabase: SQLiteDatabase
get() = getReadableDatabase(databaseSecret.asString())
private val writableDatabase: SQLiteDatabase
get() = getWritableDatabase(databaseSecret.asString())
class Reader(private val cursor: Cursor) : Iterator<String>, Closeable {
override fun hasNext(): Boolean {
return !cursor.isLast && cursor.count > 0
}
override fun next(): String {
cursor.moveToNext()
return CursorUtil.requireString(cursor, BODY)
}
override fun close() {
cursor.close()
}
}
}

View File

@@ -156,6 +156,8 @@ public class RecipientDatabase extends Database {
private static final String IDENTITY_STATUS = "identity_status";
private static final String IDENTITY_KEY = "identity_key";
static final long IMPORTANT_LOG_DURATION = TimeUnit.DAYS.toMillis(7);
/**
* Values that represent the index in the capabilities bitmask. Each index can store a 2-bit
* value, which in this case is the value of {@link Recipient.Capability}.
@@ -438,7 +440,7 @@ public class RecipientDatabase extends Database {
RecipientId finalId;
if (!byE164.isPresent() && !byUuid.isPresent()) {
Log.i(TAG, "Discovered a completely new user. Inserting.");
Log.i(TAG, "Discovered a completely new user. Inserting.", IMPORTANT_LOG_DURATION);
if (highTrust) {
long id = db.insert(TABLE_NAME, null, buildContentValuesForNewUser(e164, uuid));
finalId = RecipientId.from(id);
@@ -451,7 +453,7 @@ public class RecipientDatabase extends Database {
RecipientSettings e164Settings = getRecipientSettings(byE164.get());
if (e164Settings.uuid != null) {
if (highTrust) {
Log.w(TAG, "Found out about a UUID for a known E164 user, but that user already has a UUID. Likely a case of re-registration. High-trust, so stripping the E164 from the existing account and assigning it to a new entry.");
Log.w(TAG, String.format(Locale.US, "Found out about a UUID (%s) for a known E164 user (%s), but that user already has a UUID (%s). Likely a case of re-registration. High-trust, so stripping the E164 from the existing account and assigning it to a new entry.", uuid, byE164.get(), e164Settings.uuid), IMPORTANT_LOG_DURATION);
removePhoneNumber(byE164.get(), db);
recipientNeedingRefresh = byE164.get();
@@ -462,18 +464,18 @@ public class RecipientDatabase extends Database {
long id = db.insert(TABLE_NAME, null, insertValues);
finalId = RecipientId.from(id);
} else {
Log.w(TAG, "Found out about a UUID for a known E164 user, but that user already has a UUID. Likely a case of re-registration. Low-trust, so making a new user for the UUID.");
Log.w(TAG, String.format(Locale.US, "Found out about a UUID (%s) for a known E164 user (%s), but that user already has a UUID (%s). Likely a case of re-registration. Low-trust, so making a new user for the UUID.", uuid, byE164.get(), e164Settings.uuid), IMPORTANT_LOG_DURATION);
long id = db.insert(TABLE_NAME, null, buildContentValuesForNewUser(null, uuid));
finalId = RecipientId.from(id);
}
} else {
if (highTrust) {
Log.i(TAG, "Found out about a UUID for a known E164 user. High-trust, so updating.");
Log.i(TAG, String.format(Locale.US, "Found out about a UUID (%s) for a known E164 user (%s). High-trust, so updating.", uuid, byE164.get()), IMPORTANT_LOG_DURATION);
markRegisteredOrThrow(byE164.get(), uuid);
finalId = byE164.get();
} else {
Log.i(TAG, "Found out about a UUID for a known E164 user. Low-trust, so making a new user for the UUID.");
Log.i(TAG, String.format(Locale.US, "Found out about a UUID (%s) for a known E164 user (%s). Low-trust, so making a new user for the UUID.", uuid, byE164.get()), IMPORTANT_LOG_DURATION);
long id = db.insert(TABLE_NAME, null, buildContentValuesForNewUser(null, uuid));
finalId = RecipientId.from(id);
}
@@ -484,11 +486,11 @@ public class RecipientDatabase extends Database {
} else if (!byE164.isPresent() && byUuid.isPresent()) {
if (e164 != null) {
if (highTrust) {
Log.i(TAG, "Found out about an E164 for a known UUID user. High-trust, so updating.");
Log.i(TAG, String.format(Locale.US, "Found out about an E164 (%s) for a known UUID user (%s). High-trust, so updating.", e164, byUuid.get()), IMPORTANT_LOG_DURATION);
setPhoneNumberOrThrow(byUuid.get(), e164);
finalId = byUuid.get();
} else {
Log.i(TAG, "Found out about an E164 for a known UUID user. Low-trust, so doing nothing.");
Log.i(TAG, String.format(Locale.US, "Found out about an E164 (%s) for a known UUID user (%s). Low-trust, so doing nothing.", e164, byUuid.get()), IMPORTANT_LOG_DURATION);
finalId = byUuid.get();
}
} else {
@@ -498,13 +500,13 @@ public class RecipientDatabase extends Database {
if (byE164.equals(byUuid)) {
finalId = byUuid.get();
} else {
Log.w(TAG, "Hit a conflict between " + byE164.get() + " (E164) and " + byUuid.get() + " (UUID). They map to different recipients.", new Throwable());
Log.w(TAG, String.format(Locale.US, "Hit a conflict between %s (E164 of %s) and %s (UUID %s). They map to different recipients.", byE164.get(), e164, byUuid.get(), uuid), new Throwable(), IMPORTANT_LOG_DURATION);
RecipientSettings e164Settings = getRecipientSettings(byE164.get());
if (e164Settings.getUuid() != null) {
if (highTrust) {
Log.w(TAG, "The E164 contact has a different UUID. Likely a case of re-registration. High-trust, so stripping the E164 from the existing account and assigning it to the UUID entry.");
Log.w(TAG, "The E164 contact has a different UUID. Likely a case of re-registration. High-trust, so stripping the E164 from the existing account and assigning it to the UUID entry.", IMPORTANT_LOG_DURATION);
removePhoneNumber(byE164.get(), db);
recipientNeedingRefresh = byE164.get();
@@ -513,17 +515,17 @@ public class RecipientDatabase extends Database {
finalId = byUuid.get();
} else {
Log.w(TAG, "The E164 contact has a different UUID. Likely a case of re-registration. Low-trust, so doing nothing.");
Log.w(TAG, "The E164 contact has a different UUID. Likely a case of re-registration. Low-trust, so doing nothing.", IMPORTANT_LOG_DURATION);
finalId = byUuid.get();
}
} else {
if (highTrust) {
Log.w(TAG, "We have one contact with just an E164, and another with UUID. High-trust, so merging the two rows together.");
Log.w(TAG, "We have one contact with just an E164, and another with UUID. High-trust, so merging the two rows together.", IMPORTANT_LOG_DURATION);
finalId = merge(byUuid.get(), byE164.get());
recipientNeedingRefresh = byUuid.get();
remapped = new Pair<>(byE164.get(), byUuid.get());
} else {
Log.w(TAG, "We have one contact with just an E164, and another with UUID. Low-trust, so doing nothing.");
Log.w(TAG, "We have one contact with just an E164, and another with UUID. Low-trust, so doing nothing.", IMPORTANT_LOG_DURATION);
finalId = byUuid.get();
}
}
@@ -2879,7 +2881,7 @@ public class RecipientDatabase extends Database {
RecipientSettings e164Settings = getRecipientSettings(byE164);
// Recipient
Log.w(TAG, "Deleting recipient " + byE164);
Log.w(TAG, "Deleting recipient " + byE164, IMPORTANT_LOG_DURATION);
db.delete(TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(byE164));
RemappedRecords.getInstance().addRecipient(context, byE164, byUuid);
@@ -2964,17 +2966,17 @@ public class RecipientDatabase extends Database {
boolean hasUuidSession = DatabaseFactory.getSessionDatabase(context).getAllFor(byUuid).size() > 0;
if (hasE164Session && hasUuidSession) {
Log.w(TAG, "Had a session for both users. Deleting the E164.");
Log.w(TAG, "Had a session for both users. Deleting the E164.", IMPORTANT_LOG_DURATION);
db.delete(SessionDatabase.TABLE_NAME, SessionDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164));
} else if (hasE164Session && !hasUuidSession) {
Log.w(TAG, "Had a session for E164, but not UUID. Re-assigning to the UUID.");
Log.w(TAG, "Had a session for E164, but not UUID. Re-assigning to the UUID.", IMPORTANT_LOG_DURATION);
ContentValues values = new ContentValues();
values.put(SessionDatabase.RECIPIENT_ID, byUuid.serialize());
db.update(SessionDatabase.TABLE_NAME, values, SessionDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164));
} else if (!hasE164Session && hasUuidSession) {
Log.w(TAG, "Had a session for UUID, but not E164. No action necessary.");
Log.w(TAG, "Had a session for UUID, but not E164. No action necessary.", IMPORTANT_LOG_DURATION);
} else {
Log.w(TAG, "Had no sessions. No action necessary.");
Log.w(TAG, "Had no sessions. No action necessary.", IMPORTANT_LOG_DURATION);
}
// Mentions

View File

@@ -1276,16 +1276,16 @@ public class ThreadDatabase extends Database {
throw new IllegalStateException("Must be in a transaction!");
}
Log.w(TAG, "Merging threads. Primary: " + primaryRecipientId + ", Secondary: " + secondaryRecipientId);
Log.w(TAG, "Merging threads. Primary: " + primaryRecipientId + ", Secondary: " + secondaryRecipientId, RecipientDatabase.IMPORTANT_LOG_DURATION);
ThreadRecord primary = getThreadRecord(getThreadIdFor(primaryRecipientId));
ThreadRecord secondary = getThreadRecord(getThreadIdFor(secondaryRecipientId));
if (primary != null && secondary == null) {
Log.w(TAG, "[merge] Only had a thread for primary. Returning that.");
Log.w(TAG, "[merge] Only had a thread for primary. Returning that.", RecipientDatabase.IMPORTANT_LOG_DURATION);
return new MergeResult(primary.getThreadId(), -1, false);
} else if (primary == null && secondary != null) {
Log.w(TAG, "[merge] Only had a thread for secondary. Updating it to have the recipientId of the primary.");
Log.w(TAG, "[merge] Only had a thread for secondary. Updating it to have the recipientId of the primary.", RecipientDatabase.IMPORTANT_LOG_DURATION);
ContentValues values = new ContentValues();
values.put(RECIPIENT_ID, primaryRecipientId.serialize());
@@ -1296,7 +1296,7 @@ public class ThreadDatabase extends Database {
Log.w(TAG, "[merge] No thread for either.");
return new MergeResult(-1, -1, false);
} else {
Log.w(TAG, "[merge] Had a thread for both. Deleting the secondary and merging the attributes together.");
Log.w(TAG, "[merge] Had a thread for both. Deleting the secondary and merging the attributes together.", RecipientDatabase.IMPORTANT_LOG_DURATION);
SQLiteDatabase db = databaseHelper.getWritableDatabase();

View File

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

View File

@@ -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<Avatar> {
val db = databaseHelper.readableDatabase
val results = mutableListOf<Avatar>()
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<Avatar> {
return getAvatars(null)
}
fun getAvatarsForGroup(groupId: GroupId): List<Avatar> {
return getAvatars(groupId)
}
private fun getAvatars(groupId: GroupId?): List<Avatar> {
val db = databaseHelper.readableDatabase
val orderBy = "$LAST_USED DESC"
val results = mutableListOf<Avatar>()
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()
}
}
}

View File

@@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.database.model
data class LogEntry(
val createdAt: Long,
val lifespan: Long,
val body: String
)

View File

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

View File

@@ -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,52 @@ 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<Bitmap>() {
@Override
public void onResourceReady(@NonNull Bitmap resource, Transition<? super Bitmap> 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) {
if (data.getBoolean(AvatarPickerFragment.SELECT_AVATAR_CLEAR)) {
viewModel.setAvatarMedia(null);
viewModel.setAvatar(null);
return;
}
final Media result = data.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA);
final DecryptableStreamUriLoader.DecryptableUri decryptableUri = new DecryptableStreamUriLoader.DecryptableUri(result.getUri());
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<Bitmap>() {
@Override
public void onResourceReady(@NonNull Bitmap resource, Transition<? super Bitmap> 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 +219,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 +271,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<Recipient> invitedMembers);
void onNavigationButtonPressed();
}
}

View File

@@ -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<List<Recipient>> nonGv2CapableMembers;
private Media avatarMedia;
private AddGroupDetailsViewModel(@NonNull Collection<RecipientId> 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<RecipientId> recipientIds;

View File

@@ -19,29 +19,19 @@ public class HelpViewModel extends ViewModel {
private static final int MINIMUM_PROBLEM_CHARS = 10;
private MutableLiveData<Boolean> problemMeetsLengthRequirements = new MutableLiveData<>();
private MutableLiveData<Boolean> hasLines = new MutableLiveData<>(false);
private MutableLiveData<Integer> categoryIndex = new MutableLiveData<>(0);
private LiveData<Boolean> isFormValid = Transformations.map(new LiveDataPair<>(problemMeetsLengthRequirements, hasLines), this::transformValidationData);
private final MutableLiveData<Boolean> problemMeetsLengthRequirements;
private final MutableLiveData<Integer> categoryIndex;
private final LiveData<Boolean> isFormValid;
private final SubmitDebugLogRepository submitDebugLogRepository;
private List<LogLine> logLines;
public HelpViewModel() {
submitDebugLogRepository = new SubmitDebugLogRepository();
submitDebugLogRepository = new SubmitDebugLogRepository();
problemMeetsLengthRequirements = new MutableLiveData<>();
categoryIndex = new MutableLiveData<>(0);
submitDebugLogRepository.getLogLines(lines -> {
logLines = lines;
hasLines.postValue(true);
});
LiveData<Boolean> firstValid = LiveDataUtil.combineLatest(problemMeetsLengthRequirements, hasLines, (validLength, validLines) -> {
return validLength == Boolean.TRUE && validLines == Boolean.TRUE;
});
isFormValid = LiveDataUtil.combineLatest(firstValid, categoryIndex, (valid, index) -> {
return valid == Boolean.TRUE && index > 0;
isFormValid = LiveDataUtil.combineLatest(problemMeetsLengthRequirements, categoryIndex, (meetsLengthRequirements, index) -> {
return meetsLengthRequirements == Boolean.TRUE && index > 0;
});
}
@@ -65,7 +55,7 @@ public class HelpViewModel extends ViewModel {
MutableLiveData<SubmitResult> resultLiveData = new MutableLiveData<>();
if (includeDebugLogs) {
submitDebugLogRepository.submitLog(logLines, result -> resultLiveData.postValue(new SubmitResult(result, result.isPresent())));
submitDebugLogRepository.buildAndSubmitLog(result -> resultLiveData.postValue(new SubmitResult(result, result.isPresent())));
} else {
resultLiveData.postValue(new SubmitResult(Optional.absent(), false));
}
@@ -73,10 +63,6 @@ public class HelpViewModel extends ViewModel {
return resultLiveData;
}
private boolean transformValidationData(Pair<Boolean, Boolean> validationData) {
return validationData.first() == Boolean.TRUE && validationData.second() == Boolean.TRUE;
}
static class SubmitResult {
private final Optional<String> debugLogUrl;
private final boolean isError;

View File

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

View File

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

View File

@@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.migrations.BackupNotificationMigrationJob;
import org.thoughtcrime.securesms.migrations.BlobStorageLocationMigrationJob;
import org.thoughtcrime.securesms.migrations.CachedAttachmentsMigrationJob;
import org.thoughtcrime.securesms.migrations.DatabaseMigrationJob;
import org.thoughtcrime.securesms.migrations.DeleteDeprecatedLogsMigrationJob;
import org.thoughtcrime.securesms.migrations.DirectoryRefreshMigrationJob;
import org.thoughtcrime.securesms.migrations.KbsEnclaveMigrationJob;
import org.thoughtcrime.securesms.migrations.LegacyMigrationJob;
@@ -175,6 +176,7 @@ public final class JobManagerFactories {
put(BlobStorageLocationMigrationJob.KEY, new BlobStorageLocationMigrationJob.Factory());
put(CachedAttachmentsMigrationJob.KEY, new CachedAttachmentsMigrationJob.Factory());
put(DatabaseMigrationJob.KEY, new DatabaseMigrationJob.Factory());
put(DeleteDeprecatedLogsMigrationJob.KEY, new DeleteDeprecatedLogsMigrationJob.Factory());
put(DirectoryRefreshMigrationJob.KEY, new DirectoryRefreshMigrationJob.Factory());
put(KbsEnclaveMigrationJob.KEY, new KbsEnclaveMigrationJob.Factory());
put(LegacyMigrationJob.KEY, new LegacyMigrationJob.Factory());

View File

@@ -55,6 +55,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifes
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.IDN;
import java.util.concurrent.ExecutionException;
import okhttp3.CacheControl;
@@ -193,7 +194,7 @@ public class LinkPreviewRepository {
if (bitmap != null) bitmap.recycle();
callback.accept(thumbnail);
} catch (IOException e) {
} catch (IOException | IllegalArgumentException e) {
Log.w(TAG, "Exception during link preview image retrieval.", e);
controller.cancel();
callback.accept(Optional.absent());

View File

@@ -20,10 +20,8 @@ public class CustomSignalProtocolLogger implements SignalProtocolLogger {
Log.w(tag, message);
break;
case ERROR:
Log.e(tag, message);
break;
case ASSERT:
Log.wtf(tag, message);
Log.e(tag, message);
break;
}
}

View File

@@ -0,0 +1,202 @@
package org.thoughtcrime.securesms.logging
import android.app.Application
import android.os.Looper
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.database.LogDatabase
import org.thoughtcrime.securesms.database.model.LogEntry
import java.io.ByteArrayOutputStream
import java.io.PrintStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* A logger that will persist log entries in [LogDatabase].
*
* We log everywhere, and we never want it to slow down the app, so performance is critical here.
* This class takes special care to do as little as possible on the main thread, instead letting the background thread do the work.
*
* The process looks something like:
* - Main thread creates a [LogRequest] object and puts it in a queue
* - The [WriteThread] constantly pulls from that queue, formats the logs, and writes them to the database.
*/
class PersistentLogger(
application: Application,
defaultLifespan: Long
) : Log.Logger(defaultLifespan) {
companion object {
private const val LOG_V = "V"
private const val LOG_D = "D"
private const val LOG_I = "I"
private const val LOG_W = "W"
private const val LOG_E = "E"
private const val LOG_WTF = "A"
}
private val logEntries = LogRequests()
private val logDatabase = LogDatabase.getInstance(application)
private val cachedThreadString: ThreadLocal<String> = ThreadLocal()
init {
WriteThread(logEntries, logDatabase).apply {
priority = Thread.MIN_PRIORITY
}.start()
}
override fun v(tag: String?, message: String?, t: Throwable?, duration: Long) {
write(LOG_V, tag, message, t, duration)
}
override fun d(tag: String?, message: String?, t: Throwable?, duration: Long) {
write(LOG_D, tag, message, t, duration)
}
override fun i(tag: String?, message: String?, t: Throwable?, duration: Long) {
write(LOG_I, tag, message, t, duration)
}
override fun w(tag: String?, message: String?, t: Throwable?, duration: Long) {
write(LOG_W, tag, message, t, duration)
}
override fun e(tag: String?, message: String?, t: Throwable?, duration: Long) {
write(LOG_E, tag, message, t, duration)
}
override fun flush() {
logEntries.blockForFlushed()
}
private fun write(level: String, tag: String?, message: String?, t: Throwable?, lifespan: Long) {
logEntries.add(LogRequest(level, tag ?: "null", message, Date(), getThreadString(), t, lifespan))
}
private fun getThreadString(): String {
var threadString = cachedThreadString.get()
if (cachedThreadString.get() == null) {
threadString = if (Looper.myLooper() == Looper.getMainLooper()) {
"main "
} else {
String.format("%-5s", Thread.currentThread().id)
}
cachedThreadString.set(threadString)
}
return threadString!!
}
private data class LogRequest(
val level: String,
val tag: String,
val message: String?,
val date: Date,
val threadString: String,
val throwable: Throwable?,
val lifespan: Long
)
private class WriteThread(
private val requests: LogRequests,
private val db: LogDatabase
) : Thread("signal-logger") {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS zzz", Locale.US)
private val buffer = mutableListOf<LogRequest>()
override fun run() {
while (true) {
requests.blockForRequests(buffer)
db.insert(buffer.flatMap { requestToEntries(it) }, System.currentTimeMillis())
buffer.clear()
requests.notifyFlushed()
}
}
fun requestToEntries(request: LogRequest): List<LogEntry> {
val out = mutableListOf<LogEntry>()
out.add(
LogEntry(
createdAt = request.date.time,
lifespan = request.lifespan,
body = formatBody(request.threadString, request.date, request.level, request.tag, request.message)
)
)
if (request.throwable != null) {
val outputStream = ByteArrayOutputStream()
request.throwable.printStackTrace(PrintStream(outputStream))
val trace = String(outputStream.toByteArray())
val lines = trace.split("\\n".toRegex()).toTypedArray()
val entries = lines.map { line ->
LogEntry(
createdAt = request.date.time,
lifespan = request.lifespan,
body = formatBody(request.threadString, request.date, request.level, request.tag, line)
)
}
out.addAll(entries)
}
return out
}
fun formatBody(threadString: String, date: Date, level: String, tag: String, message: String?): String {
return "[${BuildConfig.VERSION_NAME}] [$threadString] ${dateFormat.format(date)} $level $tag: $message"
}
}
private class LogRequests {
val logs = mutableListOf<LogRequest>()
val logLock = Object()
var flushed = false
val flushedLock = Object()
fun add(entry: LogRequest) {
synchronized(logLock) {
logs.add(entry)
logLock.notify()
}
}
/**
* Blocks until requests are available. When they are, the [buffer] will be populated with all pending requests.
* Note: This method gets hit a *lot*, which is why we're using a buffer instead of spamming out new lists every time.
*/
fun blockForRequests(buffer: MutableList<LogRequest>) {
synchronized(logLock) {
while (logs.isEmpty()) {
logLock.wait()
}
buffer.addAll(logs)
logs.clear()
flushed = false
}
}
fun blockForFlushed() {
synchronized(flushedLock) {
while (!flushed) {
flushedLock.wait()
}
}
}
fun notifyFlushed() {
synchronized(flushedLock) {
flushed = true
flushedLock.notify()
}
}
}
}

View File

@@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.logsubmit
import android.app.Application
import org.signal.paging.PagedDataSource
import org.thoughtcrime.securesms.database.LogDatabase
import org.thoughtcrime.securesms.logsubmit.util.Scrubber
/**
* Retrieves logs to show in the [SubmitDebugLogActivity].
*
* @param prefixLines A static list of lines to show before all of the lines retrieved from [LogDatabase]
* @param untilTime Only show logs before this time. This is our way of making sure the set of logs we show on this screen doesn't grow.
*/
class LogDataSource(
application: Application,
private val prefixLines: List<LogLine>,
private val untilTime: Long
) :
PagedDataSource<LogLine> {
val logDatabase = LogDatabase.getInstance(application)
override fun size(): Int {
return prefixLines.size + logDatabase.getLogCountBeforeTime(untilTime)
}
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): List<LogLine> {
if (start + length < prefixLines.size) {
return prefixLines.subList(start, start + length)
} else if (start < prefixLines.size) {
return prefixLines.subList(start, prefixLines.size) +
logDatabase.getRangeBeforeTime(0, length - (prefixLines.size - start), untilTime).map { convertToLogLine(it) }
} else {
return logDatabase.getRangeBeforeTime(start - prefixLines.size, length, untilTime).map { convertToLogLine(it) }
}
}
private fun convertToLogLine(raw: String): LogLine {
val scrubbed: String = Scrubber.scrub(raw).toString()
return SimpleLogLine(scrubbed, LogStyleParser.parseStyle(scrubbed), LogLine.Placeholder.NONE)
}
}

View File

@@ -4,9 +4,10 @@ import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.ApplicationContext;
public class LogSectionLogger implements LogSection {
/**
* Because the actual contents of this section are paged from the database, this class just has a header and no content.
*/
public class LogSectionLoggerHeader implements LogSection {
@Override
public @NonNull String getTitle() {
@@ -15,7 +16,6 @@ public class LogSectionLogger implements LogSection {
@Override
public @NonNull CharSequence getContent(@NonNull Context context) {
CharSequence logs = ApplicationContext.getInstance(context).getPersistentLogger().getLogs();
return logs != null ? logs : "Unable to retrieve logs.";
return "";
}
}

View File

@@ -60,6 +60,8 @@ public class SubmitDebugLogActivity extends BaseActivity implements SubmitDebugL
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setTitle(R.string.HelpSettingsFragment__debug_log);
this.viewModel = ViewModelProviders.of(this, new SubmitDebugLogViewModel.Factory()).get(SubmitDebugLogViewModel.class);
initView();
initViewModel();
}
@@ -115,16 +117,13 @@ public class SubmitDebugLogActivity extends BaseActivity implements SubmitDebugL
public boolean onOptionsItemSelected(MenuItem item) {
super.onOptionsItemSelected(item);
switch (item.getItemId()) {
case android.R.id.home:
finish();
return true;
case R.id.menu_edit_log:
viewModel.onEditButtonPressed();
break;
case R.id.menu_done_editing_log:
viewModel.onDoneEditingButtonPressed();
break;
if (item.getItemId() == android.R.id.home) {
finish();
return true;
} else if (item.getItemId() == R.id.menu_edit_log) {
viewModel.onEditButtonPressed();
} else if (item.getItemId() == R.id.menu_done_editing_log) {
viewModel.onDoneEditingButtonPressed();
}
return false;
@@ -150,10 +149,11 @@ public class SubmitDebugLogActivity extends BaseActivity implements SubmitDebugL
this.scrollToBottomButton = findViewById(R.id.debug_log_scroll_to_bottom);
this.scrollToTopButton = findViewById(R.id.debug_log_scroll_to_top);
this.adapter = new SubmitDebugLogAdapter(this);
this.adapter = new SubmitDebugLogAdapter(this, viewModel.getPagingController());
this.lineList.setLayoutManager(new LinearLayoutManager(this));
this.lineList.setAdapter(adapter);
this.lineList.setItemAnimator(null);
submitButton.setOnClickListener(v -> onSubmitClicked());
@@ -181,8 +181,6 @@ public class SubmitDebugLogActivity extends BaseActivity implements SubmitDebugL
}
private void initViewModel() {
this.viewModel = ViewModelProviders.of(this, new SubmitDebugLogViewModel.Factory()).get(SubmitDebugLogViewModel.class);
viewModel.getLines().observe(this, this::presentLines);
viewModel.getMode().observe(this, this::presentMode);
}
@@ -196,7 +194,7 @@ public class SubmitDebugLogActivity extends BaseActivity implements SubmitDebugL
submitButton.setVisibility(View.VISIBLE);
}
adapter.setLines(lines);
adapter.submitList(lines);
}
private void presentMode(@NonNull SubmitDebugLogViewModel.Mode mode) {
@@ -204,9 +202,10 @@ public class SubmitDebugLogActivity extends BaseActivity implements SubmitDebugL
case NORMAL:
editBanner.setVisibility(View.GONE);
adapter.setEditing(false);
editMenuItem.setVisible(true);
doneMenuItem.setVisible(false);
searchMenuItem.setVisible(true);
// TODO [greyson][log] Not yet implemented
// editMenuItem.setVisible(true);
// doneMenuItem.setVisible(false);
// searchMenuItem.setVisible(true);
break;
case SUBMITTING:
editBanner.setVisibility(View.GONE);

View File

@@ -10,8 +10,7 @@ import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Stream;
import org.signal.paging.PagingController;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ListenableHorizontalScrollView;
@@ -21,26 +20,52 @@ import java.util.concurrent.CopyOnWriteArrayList;
public class SubmitDebugLogAdapter extends RecyclerView.Adapter<SubmitDebugLogAdapter.LineViewHolder> {
private static final int MAX_LINE_LENGTH = 1000;
private static final int LINE_LENGTH = 500;
private final List<LogLine> lines;
private final ScrollManager scrollManager;
private final Listener listener;
private static final int TYPE_LOG = 1;
private static final int TYPE_PLACEHOLDER = 2;
private final ScrollManager scrollManager;
private final Listener listener;
private final PagingController pagingController;
private final List<LogLine> lines;
private boolean editing;
private int longestLine;
public SubmitDebugLogAdapter(@NonNull Listener listener) {
this.listener = listener;
this.lines = new ArrayList<>();
this.scrollManager = new ScrollManager();
public SubmitDebugLogAdapter(@NonNull Listener listener, @NonNull PagingController pagingController) {
this.listener = listener;
this.pagingController = pagingController;
this.scrollManager = new ScrollManager();
this.lines = new ArrayList<>();
setHasStableIds(true);
}
@Override
public long getItemId(int position) {
return lines.get(position).getId();
LogLine item = getItem(position);
return item != null ? getItem(position).getId() : -1;
}
@Override
public int getItemViewType(int position) {
return getItem(position) == null ? TYPE_PLACEHOLDER : TYPE_LOG;
}
protected LogLine getItem(int position) {
pagingController.onDataNeededAroundIndex(position);
return lines.get(position);
}
public void submitList(@NonNull List<LogLine> list) {
this.lines.clear();
this.lines.addAll(list);
notifyDataSetChanged();
}
@Override
public int getItemCount() {
return lines.size();
}
@Override
@@ -50,7 +75,13 @@ public class SubmitDebugLogAdapter extends RecyclerView.Adapter<SubmitDebugLogAd
@Override
public void onBindViewHolder(@NonNull LineViewHolder holder, int position) {
holder.bind(lines.get(position), longestLine, editing, scrollManager, listener);
LogLine item = getItem(position);
if (item == null) {
item = SimpleLogLine.EMPTY;
}
holder.bind(item, LINE_LENGTH, editing, scrollManager, listener);
}
@Override
@@ -58,21 +89,6 @@ public class SubmitDebugLogAdapter extends RecyclerView.Adapter<SubmitDebugLogAd
holder.unbind(scrollManager);
}
@Override
public int getItemCount() {
return lines.size();
}
public void setLines(@NonNull List<LogLine> lines) {
this.lines.clear();
this.lines.addAll(lines);
this.longestLine = Stream.of(lines).reduce(0, (currentMax, line) -> Math.max(currentMax, line.getText().length()));
this.longestLine = Math.min(longestLine, MAX_LINE_LENGTH);
notifyDataSetChanged();
}
public void setEditing(boolean editing) {
this.editing = editing;
notifyDataSetChanged();

View File

@@ -1,7 +1,10 @@
package org.thoughtcrime.securesms.logsubmit;
import android.app.Application;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -11,20 +14,26 @@ import com.annimon.stream.Stream;
import org.json.JSONException;
import org.json.JSONObject;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.signal.core.util.tracing.Tracer;
import org.thoughtcrime.securesms.database.LogDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.logsubmit.util.Scrubber;
import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.regex.Pattern;
import java.util.zip.GZIPOutputStream;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
@@ -33,6 +42,9 @@ import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.BufferedSink;
import okio.Okio;
import okio.Source;
/**
* Handles retrieving, scrubbing, and uploading of all debug logs.
@@ -68,10 +80,10 @@ public class SubmitDebugLogRepository {
add(new LogSectionThreads());
add(new LogSectionBlockedThreads());
add(new LogSectionLogcat());
add(new LogSectionLogger());
add(new LogSectionLoggerHeader());
}};
private final Context context;
private final Application context;
private final ExecutorService executor;
public SubmitDebugLogRepository() {
@@ -79,44 +91,88 @@ public class SubmitDebugLogRepository {
this.executor = SignalExecutors.SERIAL;
}
public void getLogLines(@NonNull Callback<List<LogLine>> callback) {
executor.execute(() -> callback.onResult(getLogLinesInternal()));
public void getPrefixLogLines(@NonNull Callback<List<LogLine>> callback) {
executor.execute(() -> callback.onResult(getPrefixLogLinesInternal()));
}
public void submitLog(@NonNull List<LogLine> lines, Callback<Optional<String>> callback) {
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogInternal(lines, null)));
public void buildAndSubmitLog(@NonNull Callback<Optional<String>> callback) {
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogInternal(System.currentTimeMillis(), getPrefixLogLinesInternal(), Tracer.getInstance().serialize())));
}
public void submitLog(@NonNull List<LogLine> lines, @Nullable byte[] trace, Callback<Optional<String>> callback) {
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogInternal(lines, trace)));
/**
* Submits a log with the provided prefix lines.
*
* @param untilTime Only submit logs from {@link LogDatabase} if they were created before this time. This is our way of making sure that the logs we submit
* only include the logs that we've already shown the user. It's possible some old logs may have been trimmed off in the meantime, but no
* new ones could pop up.
*/
public void submitLogWithPrefixLines(long untilTime, @NonNull List<LogLine> prefixLines, @Nullable byte[] trace, Callback<Optional<String>> callback) {
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogInternal(untilTime, prefixLines, trace)));
}
@WorkerThread
private @NonNull Optional<String> submitLogInternal(@NonNull List<LogLine> lines, @Nullable byte[] trace) {
private @NonNull Optional<String> submitLogInternal(long untilTime, @NonNull List<LogLine> prefixLines, @Nullable byte[] trace) {
String traceUrl = null;
if (trace != null) {
try {
traceUrl = uploadContent("application/octet-stream", trace);
traceUrl = uploadContent("application/octet-stream", RequestBody.create(MediaType.get("application/octet-stream"), trace));
} catch (IOException e) {
Log.w(TAG, "Error during trace upload.", e);
return Optional.absent();
}
}
StringBuilder bodyBuilder = new StringBuilder();
for (LogLine line : lines) {
StringBuilder prefixStringBuilder = new StringBuilder();
for (LogLine line : prefixLines) {
switch (line.getPlaceholderType()) {
case NONE:
bodyBuilder.append(line.getText()).append('\n');
prefixStringBuilder.append(line.getText()).append('\n');
break;
case TRACE:
bodyBuilder.append(traceUrl).append('\n');
prefixStringBuilder.append(traceUrl).append('\n');
break;
}
}
try {
String logUrl = uploadContent("text/plain", bodyBuilder.toString().getBytes());
ParcelFileDescriptor[] fds = ParcelFileDescriptor.createPipe();
Uri gzipUri = BlobProvider.getInstance()
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType("application/gzip")
.createForSingleSessionOnDiskAsync(context, null, null);
OutputStream gzipOutput = new GZIPOutputStream(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1]));
gzipOutput.write(prefixStringBuilder.toString().getBytes());
try (LogDatabase.Reader reader = LogDatabase.getInstance(context).getAllBeforeTime(untilTime)) {
while (reader.hasNext()) {
gzipOutput.write(Scrubber.scrub(reader.next()).toString().getBytes());
gzipOutput.write("\n".getBytes());
}
}
StreamUtil.close(gzipOutput);
String logUrl = uploadContent("application/gzip", new RequestBody() {
@Override
public @NonNull MediaType contentType() {
return MediaType.get("application/gzip");
}
@Override public long contentLength() {
return BlobProvider.getInstance().calculateFileSize(context, gzipUri);
}
@Override
public void writeTo(@NonNull BufferedSink sink) throws IOException {
Source source = Okio.source(BlobProvider.getInstance().getStream(context, gzipUri));
sink.writeAll(source);
}
});
BlobProvider.getInstance().delete(context, gzipUri);
return Optional.of(logUrl);
} catch (IOException e) {
Log.w(TAG, "Error during log upload.", e);
@@ -125,7 +181,7 @@ public class SubmitDebugLogRepository {
}
@WorkerThread
private @NonNull String uploadContent(@NonNull String contentType, @NonNull byte[] content) throws IOException {
private @NonNull String uploadContent(@NonNull String contentType, @NonNull RequestBody requestBody) throws IOException {
try {
OkHttpClient client = new OkHttpClient.Builder().addInterceptor(new StandardUserAgentInterceptor()).dns(SignalServiceNetworkAccess.DNS).build();
Response response = client.newCall(new Request.Builder().url(API_ENDPOINT).get().build()).execute();
@@ -149,7 +205,7 @@ public class SubmitDebugLogRepository {
post.addFormDataPart(key, fields.getString(key));
}
post.addFormDataPart("file", "file", RequestBody.create(MediaType.parse(contentType), content));
post.addFormDataPart("file", "file", requestBody);
Response postResponse = client.newCall(new Request.Builder().url(url).post(post.build()).build()).execute();
@@ -165,7 +221,7 @@ public class SubmitDebugLogRepository {
}
@WorkerThread
private @NonNull List<LogLine> getLogLinesInternal() {
private @NonNull List<LogLine> getPrefixLogLinesInternal() {
long startTime = System.currentTimeMillis();
int maxTitleLength = Stream.of(SECTIONS).reduce(0, (max, section) -> Math.max(max, section.getTitle().length()));

View File

@@ -1,42 +1,60 @@
package org.thoughtcrime.securesms.logsubmit;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.tracing.Tracer;
import org.signal.paging.PagedData;
import org.signal.paging.PagingConfig;
import org.signal.paging.PagingController;
import org.signal.paging.ProxyPagingController;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Collections;
import java.util.ArrayList;
import java.util.List;
public class SubmitDebugLogViewModel extends ViewModel {
private final SubmitDebugLogRepository repo;
private final DefaultValueLiveData<List<LogLine>> lines;
private final MutableLiveData<Mode> mode;
private final SubmitDebugLogRepository repo;
private final MutableLiveData<Mode> mode;
private final ProxyPagingController pagingController;
private final List<LogLine> staticLines;
private final MediatorLiveData<List<LogLine>> lines;
private final long firstViewTime;
private final byte[] trace;
private List<LogLine> sourceLines;
private byte[] trace;
private SubmitDebugLogViewModel() {
this.repo = new SubmitDebugLogRepository();
this.lines = new DefaultValueLiveData<>(Collections.emptyList());
this.mode = new MutableLiveData<>();
this.trace = Tracer.getInstance().serialize();
this.repo = new SubmitDebugLogRepository();
this.mode = new MutableLiveData<>();
this.trace = Tracer.getInstance().serialize();
this.pagingController = new ProxyPagingController();
this.firstViewTime = System.currentTimeMillis();
this.staticLines = new ArrayList<>();
this.lines = new MediatorLiveData<>();
repo.getLogLines(result -> {
sourceLines = result;
mode.postValue(Mode.NORMAL);
lines.postValue(sourceLines);
repo.getPrefixLogLines(staticLines -> {
this.staticLines.addAll(staticLines);
LogDataSource dataSource = new LogDataSource(ApplicationDependencies.getApplication(), staticLines, firstViewTime);
PagingConfig config = new PagingConfig.Builder().setPageSize(100)
.setBufferPages(3)
.setStartIndex(0)
.build();
PagedData<LogLine> pagedData = PagedData.create(dataSource, config);
ThreadUtil.runOnMain(() -> {
pagingController.set(pagedData.getController());
lines.addSource(pagedData.getData(), lines::setValue);
mode.setValue(Mode.NORMAL);
});
});
}
@@ -44,6 +62,10 @@ public class SubmitDebugLogViewModel extends ViewModel {
return lines;
}
@NonNull PagingController getPagingController() {
return pagingController;
}
@NonNull LiveData<Mode> getMode() {
return mode;
}
@@ -53,7 +75,7 @@ public class SubmitDebugLogViewModel extends ViewModel {
MutableLiveData<Optional<String>> result = new MutableLiveData<>();
repo.submitLog(lines.getValue(), trace, value -> {
repo.submitLogWithPrefixLines(firstViewTime, staticLines, trace, value -> {
mode.postValue(Mode.NORMAL);
result.postValue(value);
});
@@ -62,35 +84,23 @@ public class SubmitDebugLogViewModel extends ViewModel {
}
void onQueryUpdated(@NonNull String query) {
if (TextUtils.isEmpty(query)) {
lines.postValue(sourceLines);
} else {
List<LogLine> filtered = Stream.of(sourceLines)
.filter(l -> l.getText().toLowerCase().contains(query.toLowerCase()))
.toList();
lines.postValue(filtered);
}
throw new UnsupportedOperationException("Not yet implemented.");
}
void onSearchClosed() {
lines.postValue(sourceLines);
throw new UnsupportedOperationException("Not yet implemented.");
}
void onEditButtonPressed() {
mode.setValue(Mode.EDIT);
throw new UnsupportedOperationException("Not yet implemented.");
}
void onDoneEditingButtonPressed() {
mode.setValue(Mode.NORMAL);
throw new UnsupportedOperationException("Not yet implemented.");
}
void onLogDeleted(@NonNull LogLine line) {
sourceLines.remove(line);
List<LogLine> logs = lines.getValue();
logs.remove(line);
lines.postValue(logs);
throw new UnsupportedOperationException("Not yet implemented.");
}
boolean onBackPressed() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<SelectionOption> 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<SelectionOption> 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<Integer> 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<SelectionOptionViewHolder> {
private final List<SelectionOption> options;
private final Consumer<SelectionOption> onOptionClicked;
private SelectionOptionAdapter(@NonNull List<SelectionOption> options, @NonNull Consumer<SelectionOption> 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();
}
}
}

View File

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

View File

@@ -57,6 +57,7 @@ public class MegaphoneRepository {
database.markFinished(Event.RESEARCH);
database.markFinished(Event.GROUP_CALLING);
database.markFinished(Event.CHAT_COLORS);
database.markFinished(Event.ADD_A_PROFILE_PHOTO);
resetDatabaseCache();
});
}

View File

@@ -25,7 +25,9 @@ import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
import org.thoughtcrime.securesms.lock.v2.KbsMigrationActivity;
import org.thoughtcrime.securesms.messagerequests.MessageRequestMegaphoneActivity;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.profiles.manage.ManageProfileActivity;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.FeatureFlags;
@@ -105,6 +107,7 @@ public final class Megaphones {
put(Event.ONBOARDING, shouldShowOnboardingMegaphone(context) ? ALWAYS : NEVER);
put(Event.NOTIFICATIONS, shouldShowNotificationsMegaphone(context) ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(30)) : NEVER);
put(Event.CHAT_COLORS, ALWAYS);
put(Event.ADD_A_PROFILE_PHOTO, shouldShowAddAProfileMegaphone(context) ? ALWAYS : NEVER);
}};
}
@@ -134,6 +137,8 @@ public final class Megaphones {
return buildNotificationsMegaphone(context);
case CHAT_COLORS:
return buildChatColorsMegaphone(context);
case ADD_A_PROFILE_PHOTO:
return buildAddAProfilePhotoMegaphone(context);
default:
throw new IllegalArgumentException("Event not handled!");
}
@@ -320,6 +325,21 @@ public final class Megaphones {
.build();
}
private static @NonNull Megaphone buildAddAProfilePhotoMegaphone(@NonNull Context context) {
return new Megaphone.Builder(Event.ADD_A_PROFILE_PHOTO, Megaphone.Style.BASIC)
.setTitle(R.string.AddAProfilePhotoMegaphone__add_a_profile_photo)
.setImage(R.drawable.ic_add_a_profile_megaphone_image)
.setBody(R.string.AddAProfilePhotoMegaphone__choose_a_look_and_color)
.setActionButton(R.string.AddAProfilePhotoMegaphone__add_photo, (megaphone, listener) -> {
listener.onMegaphoneNavigationRequested(ManageProfileActivity.getIntentForAvatarEdit(context));
listener.onMegaphoneCompleted(Event.ADD_A_PROFILE_PHOTO);
})
.setSecondaryButton(R.string.AddAProfilePhotoMegaphone__not_now, (megaphone, listener) -> {
listener.onMegaphoneCompleted(Event.ADD_A_PROFILE_PHOTO);
})
.build();
}
private static boolean shouldShowMessageRequestsMegaphone() {
return Recipient.self().getProfileName() == ProfileName.EMPTY;
}
@@ -363,6 +383,10 @@ public final class Megaphones {
return shouldShow;
}
private static boolean shouldShowAddAProfileMegaphone(@NonNull Context context) {
return !AvatarHelper.hasAvatar(context, Recipient.self().getId());
}
public enum Event {
REACTIONS("reactions"),
PINS_FOR_ALL("pins_for_all"),
@@ -375,7 +399,8 @@ public final class Megaphones {
GROUP_CALLING("group_calling"),
ONBOARDING("onboarding"),
NOTIFICATIONS("notifications"),
CHAT_COLORS("chat_colors");
CHAT_COLORS("chat_colors"),
ADD_A_PROFILE_PHOTO("add_a_profile_photo");
private final String key;

View File

@@ -78,9 +78,10 @@ public class ApplicationMigrations {
static final int SENDER_KEY_2 = 36;
static final int DB_AUTOINCREMENT = 37;
static final int ATTACHMENT_CLEANUP = 38;
static final int LOG_CLEANUP = 39;
}
public static final int CURRENT_VERSION = 38;
public static final int CURRENT_VERSION = 39;
/**
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
@@ -342,6 +343,10 @@ public class ApplicationMigrations {
jobs.put(Version.ATTACHMENT_CLEANUP, new AttachmentCleanupMigrationJob());
}
if (lastSeenVersion < Version.LOG_CLEANUP) {
jobs.put(Version.LOG_CLEANUP, new DeleteDeprecatedLogsMigrationJob());
}
return jobs;
}

View File

@@ -0,0 +1,72 @@
package org.thoughtcrime.securesms.migrations;
import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import java.io.File;
/**
* We moved from storing logs in encrypted files to just storing them in an encrypted database. So we need to delete the leftover files.
*/
public class DeleteDeprecatedLogsMigrationJob extends MigrationJob {
private static final String TAG = Log.tag(DeleteDeprecatedLogsMigrationJob.class);
public static final String KEY = "DeleteDeprecatedLogsMigrationJob";
public DeleteDeprecatedLogsMigrationJob() {
this(new Parameters.Builder().build());
}
private DeleteDeprecatedLogsMigrationJob(@NonNull Parameters parameters) {
super(parameters);
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
boolean isUiBlocking() {
return false;
}
@Override
void performMigration() {
File logDir = new File(context.getCacheDir(), "log");
if (logDir.exists()) {
File[] files = logDir.listFiles();
int count = 0;
if (files != null) {
for (File f : files) {
count += f.delete() ? 1 : 0;
}
}
if (!logDir.delete()) {
Log.w(TAG, "Failed to delete log directory.");
}
Log.i(TAG, "Deleted " + count + " log files.");
} else {
Log.w(TAG, "Log directory does not exist.");
}
}
@Override
boolean shouldRetry(@NonNull Exception e) {
return false;
}
public static final class Factory implements Job.Factory<DeleteDeprecatedLogsMigrationJob> {
@Override
public @NonNull DeleteDeprecatedLogsMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) {
return new DeleteDeprecatedLogsMigrationJob(parameters);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -39,10 +39,8 @@ final class MobileCoinLogAdapter implements LogAdapter {
Log.w(tag, message, throwable);
break;
case ERROR:
Log.e(tag, message, throwable);
break;
case WTF:
Log.wtf(tag, message, throwable);
Log.e(tag, message, throwable);
break;
}
}

View File

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

View File

@@ -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<AvatarColor> avatarColorConsumer) {
SimpleTask.run(() -> Recipient.resolved(getRecipientId()).getAvatarColor(), avatarColorConsumer::accept);
}
@Override
public void getCurrentProfileName(@NonNull Consumer<ProfileName> profileNameConsumer) {
profileNameConsumer.accept(ProfileName.EMPTY);

View File

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

View File

@@ -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,12 @@ 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.avatar.Avatars;
import org.thoughtcrime.securesms.avatar.picker.AvatarPickerFragment;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.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 +51,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 +60,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 +71,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 +104,44 @@ public class EditProfileFragment extends LoggingFragment {
initializeResources(view, groupId);
initializeProfileAvatar();
initializeProfileName();
getParentFragmentManager().setFragmentResultListener(AvatarPickerFragment.REQUEST_KEY_SELECT_AVATAR, getViewLifecycleOwner(), (key, bundle) -> {
if (bundle.getBoolean(AvatarPickerFragment.SELECT_AVATAR_CLEAR)) {
viewModel.setAvatarMedia(null);
viewModel.setAvatar(null);
avatar.setImageDrawable(null);
} else {
Media media = bundle.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA);
handleMediaFromResult(media);
}
});
}
@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 +163,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 +260,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 +285,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() {

View File

@@ -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<AvatarColor> avatarColorConsumer);
void getCurrentProfileName(@NonNull Consumer<ProfileName> profileNameConsumer);
void getCurrentAvatar(@NonNull Consumer<byte[]> avatarConsumer);

View File

@@ -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<byte[]> originalAvatar = new MutableLiveData<>();
private final MutableLiveData<String> originalDisplayName = new MutableLiveData<>();
private final SingleLiveEvent<UploadResult> uploadResult = new SingleLiveEvent<>();
private final MutableLiveData<AvatarColor> avatarColor = new MutableLiveData<>();
private final LiveData<Boolean> 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> avatarColor() {
return Transformations.distinctUntilChanged(avatarColor);
}
public LiveData<String> 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();
}

View File

@@ -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<AvatarColor> avatarColorConsumer) {
SimpleTask.run(() -> Recipient.self().getAvatarColor(), avatarColorConsumer::accept);
}
@Override
public void getCurrentProfileName(@NonNull Consumer<ProfileName> profileNameConsumer) {
ProfileName storedProfileName = Recipient.self().getProfileName();

View File

@@ -25,6 +25,7 @@ public class ManageProfileActivity extends PassphraseRequiredActivity implements
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
public static final String START_AT_USERNAME = "start_at_username";
public static final String START_AT_AVATAR = "start_at_avatar";
public static @NonNull Intent getIntent(@NonNull Context context) {
return new Intent(context, ManageProfileActivity.class);
@@ -36,6 +37,12 @@ public class ManageProfileActivity extends PassphraseRequiredActivity implements
return intent;
}
public static @NonNull Intent getIntentForAvatarEdit(@NonNull Context context) {
Intent intent = new Intent(context, ManageProfileActivity.class);
intent.putExtra(START_AT_AVATAR, true);
return intent;
}
@Override
public void onCreate(Bundle bundle, boolean ready) {
dynamicTheme.onCreate(this);
@@ -52,6 +59,11 @@ public class ManageProfileActivity extends PassphraseRequiredActivity implements
NavDirections action = ManageProfileFragmentDirections.actionManageUsername();
Navigation.findNavController(this, R.id.nav_host_fragment).navigate(action);
}
if (extras != null && extras.getBoolean(START_AT_AVATAR, false)) {
NavDirections action = ManageProfileFragmentDirections.actionManageProfileFragmentToAvatarPicker(null, null);
Navigation.findNavController(this, R.id.nav_host_fragment).navigate(action);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -89,22 +89,18 @@ public final class ShakeToReport implements ShakeDetector.Listener {
Log.i(TAG, "Submitting log...");
repo.getLogLines(lines -> {
Log.i(TAG, "Retrieved log lines...");
repo.buildAndSubmitLog(url -> {
Log.i(TAG, "Logs uploaded!");
repo.submitLog(lines, Tracer.getInstance().serialize(), url -> {
Log.i(TAG, "Logs uploaded!");
ThreadUtil.runOnMain(() -> {
spinner.dismiss();
ThreadUtil.runOnMain(() -> {
spinner.dismiss();
if (url.isPresent()) {
showPostSubmitDialog(activity, url.get());
} else {
Toast.makeText(activity, R.string.ShakeToReport_failed_to_submit, Toast.LENGTH_SHORT).show();
enableIfVisible();
}
});
if (url.isPresent()) {
showPostSubmitDialog(activity, url.get());
} else {
Toast.makeText(activity, R.string.ShakeToReport_failed_to_submit, Toast.LENGTH_SHORT).show();
enableIfVisible();
}
});
});
}

View File

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

View File

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

View File

@@ -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.
* <p></p>
* General pattern for implementation:
* <ol>
* <li>Create {@link MappingModel}s for the items in the list. These encapsulate data massaging methods for views to use and the diff logic.</li>
* <li>Create {@link MappingViewHolder}s for each item type in the list and their corresponding {@link Factory}.</li>
* <li>Create an instance or subclass of {@link MappingAdapter} and register the mapping of model type to view holder factory for that model type.</li>
* </ol>
* 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.
* <p></p>
* 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).
* <ol>
* <li>Create {@link MappingModel}s for the items in the list. These encapsulate data massaging methods for views to use and the diff logic.</li>
* <li>Create {@link MappingViewHolder}s for each item type in the list and their corresponding {@link Factory}.</li>
* <li>Create an instance or subclass of {@link MappingAdapter} and register the mapping of model type to view holder factory for that model type.</li>
* </ol>
* 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.
* <p></p>
* 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<MappingModel<?>, MappingViewHolder<?>> {
@@ -102,6 +103,12 @@ public class MappingAdapter extends ListAdapter<MappingModel<?>, MappingViewHold
return Objects.requireNonNull(factories.get(viewType)).createViewHolder(parent);
}
@Override
public void onBindViewHolder(@NonNull MappingViewHolder<?> holder, int position, @NonNull List<Object> 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<MappingModel<?>, 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<T extends MappingModel<T>> {

View File

@@ -1,8 +1,13 @@
package org.thoughtcrime.securesms.util;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public interface MappingModel<T> {
boolean areItemsTheSame(@NonNull T newItem);
boolean areContentsTheSame(@NonNull T newItem);
default @Nullable Object getChangePayload(@NonNull T newItem) {
return null;
}
}

View File

@@ -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<Model extends MappingModel<Model>> extends LifecycleViewHolder implements LifecycleOwner {
protected final Context context;
protected final Context context;
protected final List<Object> payload;
public MappingViewHolder(@NonNull View itemView) {
super(itemView);
context = itemView.getContext();
payload = new LinkedList<>();
}
public <T extends View> T findViewById(@IdRes int id) {
@@ -26,6 +31,11 @@ public abstract class MappingViewHolder<Model extends MappingModel<Model>> exten
public abstract void bind(@NonNull Model model);
public void setPayload(@NonNull List<Object> payload) {
this.payload.clear();
this.payload.addAll(payload);
}
public static final class SimpleViewHolder<Model extends MappingModel<Model>> extends MappingViewHolder<Model> {
public SimpleViewHolder(@NonNull View itemView) {
super(itemView);

Some files were not shown because too many files have changed in this diff Show More