Compare commits

...

55 Commits
v ... v5.18.4

Author SHA1 Message Date
Greyson Parrelli
16f1128990 Bump version to 5.18.4 2021-07-26 13:31:39 -04:00
Greyson Parrelli
4fd1d05503 Updated language translations. 2021-07-26 13:28:04 -04:00
Greyson Parrelli
dfac05a118 Do not use constants in LogDatabase#onUpgrade. 2021-07-26 11:29:25 -04:00
Greyson Parrelli
cd869bcb89 Fix name of nightly build. 2021-07-26 11:16:24 -04:00
Greyson Parrelli
427119cef2 Do not backup the avatar picker database. 2021-07-26 10:40:55 -04:00
Lucio Maciel
dada7a4f06 Revert "Update timer icons and received text bubble."
This reverts commits 26c9b5166e,
833f90ce53,
0ba7ff911b and 38adb0373d.
2021-07-26 11:13:26 -03:00
Greyson Parrelli
44a84210d8 Fix backup setting summary text consistency. 2021-07-26 10:08:20 -04:00
Greyson Parrelli
5ac8d3b0bd Do not show profile photo when tapping note to self. 2021-07-26 10:00:09 -04:00
Greyson Parrelli
7ccba5b1c8 Handle missing file browser during backup selection. 2021-07-26 09:59:49 -04:00
Greyson Parrelli
c2ffd8adbb Fix crash when submitting a debuglog during registration. 2021-07-26 09:39:03 -04:00
Greyson Parrelli
7e4396ae3f Use custom emoji for avatars. 2021-07-26 08:56:20 -04:00
Greyson Parrelli
d0827eb48e Fix emoji rendering artifact.
There's sometimes this one pixel line that can appear next to them.
Easiest solution for now is to trim it off.
2021-07-26 08:23:09 -04:00
Greyson Parrelli
90397165c3 Bump version to 5.18.3 2021-07-23 17:58:40 -04:00
Greyson Parrelli
e3e47504a6 Updated language translations. 2021-07-23 17:58:18 -04:00
Greyson Parrelli
42269efa57 Fix reaction sizing issue. 2021-07-23 17:53:52 -04:00
Lucio Maciel
38adb0373d Fix mentions and thumbnail size. 2021-07-23 17:53:52 -04:00
Alex Hart
8bde389398 Scroll to selected on state change. 2021-07-23 17:52:51 -04:00
Alex Hart
d29b0609a3 Create nicer animation for moving between pages. 2021-07-23 14:02:47 -03:00
Alex Hart
740977164b Apply several fixes for beta feedback.
* Remove overscroll from avatar picker recyclers.
* Center crop wallpaper previews.
* If no media thumb exists, return bubble projection instead.
2021-07-23 13:47:43 -03:00
Greyson Parrelli
2dd8f24e14 Bump version to 5.18.2 2021-07-23 08:27:56 -04:00
Greyson Parrelli
4e409fc9ed Updated language translations. 2021-07-23 08:27:15 -04:00
Greyson Parrelli
136826be69 Update order of onboarding cards. 2021-07-23 08:07:49 -04:00
Lucio Maciel
0ba7ff911b Fix margins on message bubbles. 2021-07-23 08:05:50 -04:00
Alex Hart
bfbdbdcbc0 Add Photo onboarding card. 2021-07-23 08:05:28 -04:00
Greyson Parrelli
f2533ac4b7 Fallback to legacy sends upon getting a 401 during sender key. 2021-07-23 08:05:28 -04:00
Greyson Parrelli
15a5f5966d Update logging to be size-limited and more performant. 2021-07-23 08:05:28 -04:00
Alex Hart
3c748b2df6 Fix NullPointerException if there is no cursor drawable set. 2021-07-23 08:05:28 -04:00
Alex Hart
c1b54b3532 Fix several issues with new avatar picker.
* Fix silliness with text behaviour
* Fix long click behaviour
* Make views play nicer with landscape mode
* Do not show megaphone if user has an avatar (or had one and removed it)
* Fix bad heading on vector color picker
2021-07-23 08:05:28 -04:00
Alex Hart
ab56856f41 Adjust sizing of default group icon in chat settings. 2021-07-23 08:05:28 -04:00
Alex Hart
ce31e642dd Fix missing background on video player bar. 2021-07-22 11:10:57 -03:00
Greyson Parrelli
aa67c82634 Bump version to 5.18.1 2021-07-22 03:04:15 -04:00
Greyson Parrelli
c9c4187d2e Updated language translations. 2021-07-22 03:04:15 -04:00
Greyson Parrelli
60b4862b1b Ensure SQLCipher is loaded before logging begins. 2021-07-22 03:04:15 -04:00
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
Greyson Parrelli
0b85852621 Bump version to 5.17.3 2021-07-19 13:05:11 -04:00
Greyson Parrelli
556518973d Fix crash during cache warming for fresh installs. 2021-07-19 13:01:53 -04:00
Greyson Parrelli
b9514d0b94 Bump version to 5.17.2 2021-07-19 12:40:21 -04:00
Greyson Parrelli
a9dab90a1e Updated language translations. 2021-07-19 12:40:21 -04:00
Greyson Parrelli
39709c8d64 Fix some timing issues around recipient events. 2021-07-19 12:40:21 -04:00
Greyson Parrelli
c2a6963a6d Warm up some recipients from the contact selection screen. 2021-07-19 11:57:26 -04:00
Greyson Parrelli
bfdebbfa5d Sort contacts that start with a number at the end. 2021-07-19 11:57:26 -04:00
Alex Hart
167a691018 Update SMS tag visibility in onRecipientChanged. 2021-07-19 11:57:26 -04:00
292 changed files with 15203 additions and 2540 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 = 879
def canonicalVersionName = "5.17.1"
def canonicalVersionCode = 886
def canonicalVersionName = "5.18.4"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -268,7 +268,7 @@ android {
ext.websiteUpdateUrl = "null"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"internal\""
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\""
}
study {
@@ -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

@@ -18,6 +18,7 @@ package org.thoughtcrime.securesms;
import android.content.Context;
import android.os.Build;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -27,18 +28,17 @@ 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.LogDatabase;
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
@@ -56,7 +56,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 +74,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;
@@ -124,12 +123,12 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
super.onCreate();
AppStartup.getInstance().addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("sqlcipher-init", () -> SqlCipherLibraryLoader.load(this))
.addBlocking("logging", () -> {
initializeLogging();
Log.i(TAG, "onCreate()");
})
.addBlocking("crash-handling", this::initializeCrashHandling)
.addBlocking("sqlcipher-init", () -> SqlCipherLibraryLoader.load(this))
.addBlocking("rx-init", () -> {
RxJavaPlugins.setInitIoSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED_IO, true, false));
RxJavaPlugins.setInitComputationSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED, true, false));
@@ -156,6 +155,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,10 +248,12 @@ 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);
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
SignalExecutors.UNBOUNDED.execute(() -> LogDatabase.getInstance(this).trimToSize());
}
private void initializeCrashHandling() {
@@ -379,6 +381,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,59 @@
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)
textView.setForceCustomEmoji(true)
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,243 @@
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 lateinit var recycler: RecyclerView
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 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 = view.findViewById(R.id.avatar_picker_recycler)
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)
}
val items = state.selectableAvatars.map { AvatarPickerItem.Model(it, it == state.currentAvatar) }
val selectedPosition = items.indexOfFirst { it.isSelected }
adapter.submitList(items) {
if (selectedPosition > -1)
recycler.smoothScrollToPosition(selectedPosition)
}
}
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 true
is Avatar.Resource -> return true
}
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,147 @@
package org.thoughtcrime.securesms.avatar.picker
import android.util.TypedValue
import android.view.View
import android.widget.EditText
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 { _, _, _, _, _, _, _, _, _ ->
updateFontSize(textView.text.toString())
}
}
private fun updateFontSize(text: String) {
val textSize = Avatars.getTextSizeForLength(context, text, textView.measuredWidth * 0.8f, textView.measuredHeight * 0.45f)
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
if (textView !is EditText) {
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()
itemView.setOnLongClickListener {
onAvatarLongClickListener?.invoke(itemView, model.avatar) ?: false
}
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
updateFontSize(model.avatar.text)
if (textView.text.toString() != model.avatar.text) {
textView.text = model.avatar.text
}
imageView.setImageDrawable(null)
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
textView.setTextColor(model.avatar.color.foregroundColor)
}
is Avatar.Vector -> {
textView.visible = false
val drawableId = Avatars.getDrawableResource(model.avatar.key)
if (drawableId == null) {
imageView.setImageDrawable(null)
} else {
imageView.setImageDrawable(AppCompatResources.getDrawable(context, drawableId))
}
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
}
is Avatar.Photo -> {
textView.visible = false
GlideApp.with(imageView).load(DecryptableStreamUriLoader.DecryptableUri(model.avatar.uri)).into(imageView)
}
is Avatar.Resource -> {
imageView.setPadding((imageView.width * 0.2).toInt())
textView.visible = false
GlideApp.with(imageView).clear(imageView)
imageView.setImageResource(model.avatar.resourceId)
imageView.colorFilter = SimpleColorFilter(model.avatar.color.foregroundColor)
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
}
}
}
}
}

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,152 @@
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.AutoTransition
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.KeyboardAwareLinearLayout
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) {
private val viewModel: TextAvatarCreationViewModel by viewModels(factoryProducer = this::createFactory)
private lateinit var textInput: EditText
private lateinit var recycler: RecyclerView
private lateinit var content: ConstraintLayout
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)
val keyboardAwareLayout: KeyboardAwareLinearLayout = view.findViewById(R.id.keyboard_aware_layout)
withRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment_content)
withoutRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment_content_hidden_recycler)
content = view.findViewById(R.id.content)
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)
viewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
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_NEXT) {
tabLayout.getTabAt(1)?.select()
true
} else {
false
}
}
keyboardAwareLayout.addOnKeyboardHiddenListener {
if (tabLayout.selectedTabPosition == 1) {
val transition = AutoTransition().setStartDelay(250L)
TransitionManager.endTransitions(content)
withRecyclerSet.applyTo(content)
TransitionManager.beginDelayedTransition(content, transition)
}
}
}
private inner class OnTabSelectedListener : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
when (tab.position) {
0 -> {
textInput.isEnabled = true
ViewUtil.focusAndShowKeyboard(textInput)
withoutRecyclerSet.applyTo(content)
textInput.setSelection(textInput.length())
}
1 -> {
textInput.isEnabled = false
ViewUtil.hideKeyboard(requireContext(), textInput)
}
}
}
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

@@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.database.SessionDatabase;
import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.StickerDatabase;
import org.thoughtcrime.securesms.database.model.AvatarPickerDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -84,7 +85,8 @@ public class FullBackupExporter extends FullBackupBase {
EmojiSearchDatabase.TABLE_NAME,
SenderKeyDatabase.TABLE_NAME,
SenderKeySharedDatabase.TABLE_NAME,
PendingRetryReceiptDatabase.TABLE_NAME
PendingRetryReceiptDatabase.TABLE_NAME,
AvatarPickerDatabase.TABLE_NAME
);
public static void export(@NonNull Context context,

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

@@ -122,10 +122,10 @@ class EmojiProvider {
final int xStart = (index % emojiPerRow) * glyphWidth;
final int yStart = (index / emojiPerRow) * glyphHeight;
this.emojiBounds = new Rect(xStart,
yStart,
xStart + glyphWidth,
yStart + glyphHeight);
this.emojiBounds = new Rect(xStart + 1,
yStart + 1,
xStart + glyphWidth - 1,
yStart + glyphHeight - 1);
}
@Override

View File

@@ -14,29 +14,33 @@ public class EmojiSpan extends AnimatingImageSpan {
private final float SHIFT_FACTOR = 1.5f;
private final int size;
private final FontMetricsInt fm;
private int size;
private FontMetricsInt fontMetrics;
public EmojiSpan(@NonNull Drawable drawable, @NonNull TextView tv) {
super(drawable, tv);
fm = tv.getPaint().getFontMetricsInt();
size = fm != null ? Math.abs(fm.descent) + Math.abs(fm.ascent)
: tv.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size);
fontMetrics = tv.getPaint().getFontMetricsInt();
size = fontMetrics != null ? Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent)
: tv.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size);
getDrawable().setBounds(0, 0, size, size);
}
@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, FontMetricsInt fm) {
if (fm != null && this.fm != null) {
fm.ascent = this.fm.ascent;
fm.descent = this.fm.descent;
fm.top = this.fm.top;
fm.bottom = this.fm.bottom;
fm.leading = this.fm.leading;
return size;
if (fm != null && this.fontMetrics != null) {
fm.ascent = this.fontMetrics.ascent;
fm.descent = this.fontMetrics.descent;
fm.top = this.fontMetrics.top;
fm.bottom = this.fontMetrics.bottom;
fm.leading = this.fontMetrics.leading;
} else {
return super.getSize(paint, text, start, end, fm);
this.fontMetrics = paint.getFontMetricsInt();
this.size = Math.abs(this.fontMetrics.descent) + Math.abs(this.fontMetrics.ascent);
getDrawable().setBounds(0, 0, size, size);
}
return size;
}
@Override

View File

@@ -33,10 +33,10 @@ import java.util.List;
public class EmojiTextView extends AppCompatTextView {
private final boolean scaleEmojis;
private final boolean forceCustom;
private static final char ELLIPSIS = '…';
private boolean forceCustom;
private CharSequence previousText;
private BufferType previousBufferType;
private float originalFontSize;
@@ -144,6 +144,13 @@ public class EmojiTextView extends AppCompatTextView {
setText(previousText, BufferType.SPANNABLE);
}
public void setForceCustomEmoji(boolean forceCustom) {
if (this.forceCustom != forceCustom) {
this.forceCustom = forceCustom;
setText(previousText, BufferType.SPANNABLE);
}
}
private void ellipsizeAnyTextForMaxLength() {
if (maxLength > 0 && getText().length() > maxLength + 1) {
SpannableStringBuilder newContent = new SpannableStringBuilder();

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

@@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.ThrottledDebouncer
import org.thoughtcrime.securesms.util.livedata.Store
@@ -21,7 +22,7 @@ class ChatsSettingsViewModel(private val repository: ChatsSettingsRepository) :
useAddressBook = SignalStore.settings().isPreferSystemContactPhotos,
useSystemEmoji = SignalStore.settings().isPreferSystemEmoji,
enterKeySends = SignalStore.settings().isEnterKeySends,
chatBackupsEnabled = SignalStore.settings().isBackupEnabled
chatBackupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication())
)
)

View File

@@ -237,11 +237,13 @@ class ConversationSettingsFragment : DSLSettingsFragment(
AvatarPreference.Model(
recipient = state.recipient,
onAvatarClick = { avatar ->
requireActivity().apply {
startActivity(
AvatarPreviewActivity.intentFromRecipientId(this, state.recipient.id),
AvatarPreviewActivity.createTransitionBundle(this, avatar)
)
if (!state.recipient.isSelf) {
requireActivity().apply {
startActivity(
AvatarPreviewActivity.intentFromRecipientId(this, state.recipient.id),
AvatarPreviewActivity.createTransitionBundle(this, avatar)
)
}
}
}
)

View File

@@ -5,9 +5,12 @@ import androidx.core.view.ViewCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto
import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Renders a large avatar (80dp) for a given Recipient.
@@ -34,6 +37,7 @@ object AvatarPreference {
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val avatar: AvatarImageView = itemView.findViewById<AvatarImageView>(R.id.bio_preference_avatar).apply {
ViewCompat.setTransitionName(this, "avatar")
setFallbackPhotoProvider(AvatarPreferenceFallbackPhotoProvider())
}
override fun bind(model: Model) {
@@ -42,4 +46,10 @@ object AvatarPreference {
avatar.setOnClickListener { model.onAvatarClick(avatar) }
}
}
private class AvatarPreferenceFallbackPhotoProvider : Recipient.FallbackPhotoProvider() {
override fun getPhotoForGroup(): FallbackContactPhoto {
return FallbackPhoto(R.drawable.ic_group_outline_40, ViewUtil.dpToPx(8))
}
}
}

View File

@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.FromTextView;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
@@ -90,17 +91,23 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
} else if (recipientId != null) {
this.recipient = Recipient.live(recipientId);
this.recipient.observeForever(this);
name = this.recipient.get().getDisplayName(getContext());
}
if (recipient == null || recipient.get().isRegistered()) {
Recipient recipientSnapshot = recipient != null ? recipient.get() : null;
if (recipientSnapshot != null && !recipientSnapshot.isResolving()) {
contactName = recipientSnapshot.getDisplayName(getContext());
name = contactName;
} else if (recipient != null) {
name = "";
}
if (recipientSnapshot == null || recipientSnapshot.isResolving() || recipientSnapshot.isRegistered()) {
smsTag.setVisibility(GONE);
} else {
smsTag.setVisibility(VISIBLE);
}
Recipient recipientSnapshot = recipient != null ? recipient.get() : null;
if (recipientSnapshot == null || recipientSnapshot.isResolving()) {
this.contactPhotoImage.setAvatar(glideRequests, null, false);
setText(null, type, name, number, label, about);
@@ -207,8 +214,13 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
@Override
public void onRecipientChanged(@NonNull Recipient recipient) {
if (this.recipient != null && this.recipient.getId().equals(recipient.getId())) {
contactName = recipient.getDisplayName(getContext());
contactAbout = recipient.getCombinedAboutAndEmoji();
contactNumber = PhoneNumberFormatter.prettyPrint(recipient.getE164().or(""));
contactPhotoImage.setAvatar(glideRequests, recipient, false);
setText(recipient, contactType, contactName, contactNumber, contactLabel, contactAbout);
smsTag.setVisibility(recipient.isRegistered() ? GONE : VISIBLE);
} else {
Log.w(TAG, "Bad change! Local recipient doesn't match. Ignoring. Local: " + (this.recipient == null ? "null" : this.recipient.getId()) + ", Changed: " + recipient.getId());
}

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

@@ -0,0 +1,65 @@
package org.thoughtcrime.securesms.contacts.avatars;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Px;
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;
/**
* Fallback resource based contact photo with a 20dp icon
*/
public final class FallbackPhoto implements FallbackContactPhoto {
@DrawableRes private final int drawableResource;
@Px private final int foregroundInset;
public FallbackPhoto(@DrawableRes int drawableResource, @Px int foregroundInset) {
this.drawableResource = drawableResource;
this.foregroundInset = foregroundInset;
}
@Override
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color) {
return buildDrawable(context, color);
}
@Override
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return buildDrawable(context, color);
}
@Override
public Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return buildDrawable(context, color);
}
@Override
public Drawable asCallCard(@NonNull Context context) {
throw new UnsupportedOperationException();
}
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 = Objects.requireNonNull(AppCompatResources.getDrawable(context, drawableResource));
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground});
DrawableCompat.setTint(background, color.colorInt());
DrawableCompat.setTint(foreground, Avatars.getForegroundColor(color).getColorInt());
drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset);
return drawable;
}
}

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

@@ -57,6 +57,7 @@ import android.view.View.OnKeyListener;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.TextView;
@@ -381,7 +382,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
private MenuItem searchViewItem;
private MessageRequestsBottomView messageRequestBottomView;
private ConversationReactionDelegate reactionDelegate;
private Stub<VoiceNotePlayerView> voiceNotePlayerViewStub;
private Stub<FrameLayout> voiceNotePlayerViewStub;
private AttachmentManager attachmentManager;
private AudioRecorder audioRecorder;
@@ -413,6 +414,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
private VoiceRecorderWakeLock voiceRecorderWakeLock;
private DraftViewModel draftViewModel;
private VoiceNoteMediaController voiceNoteMediaController;
private VoiceNotePlayerView voiceNotePlayerView;
private LiveRecipient recipient;
@@ -1129,6 +1131,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 +1305,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) {
@@ -2075,19 +2078,25 @@ public class ConversationActivity extends PassphraseRequiredActivity
voiceNoteMediaController.getVoiceNotePlayerViewState().observe(this, state -> {
if (state.isPresent()) {
if (!voiceNotePlayerViewStub.resolved()) {
voiceNotePlayerViewStub.get().setListener(new VoiceNotePlayerViewListener());
}
voiceNotePlayerViewStub.get().show();
voiceNotePlayerViewStub.get().setState(state.get());
requireVoiceNotePlayerView().show();
requireVoiceNotePlayerView().setState(state.get());
} else if (voiceNotePlayerViewStub.resolved()) {
voiceNotePlayerViewStub.get().hide();
requireVoiceNotePlayerView().hide();
}
});
voiceNoteMediaController.getVoiceNotePlaybackState().observe(ConversationActivity.this, inputPanel.getPlaybackStateObserver());
}
private @NonNull VoiceNotePlayerView requireVoiceNotePlayerView() {
if (voiceNotePlayerView == null) {
voiceNotePlayerView = voiceNotePlayerViewStub.get().findViewById(R.id.voice_note_player_view);
voiceNotePlayerView.setListener(new VoiceNotePlayerViewListener());
}
return voiceNotePlayerView;
}
private void updateWallpaper(@Nullable ChatWallpaper chatWallpaper) {
Log.d(TAG, "Setting wallpaper.");
if (chatWallpaper != null) {

View File

@@ -700,8 +700,8 @@ public class ConversationAdapter
}
@NonNull
public @Override Projection getProjection(@NonNull ViewGroup recyclerView) {
return getBindable().getProjection(recyclerView);
public @Override Projection getGiphyMp4PlayableProjection(@NonNull ViewGroup recyclerView) {
return getBindable().getGiphyMp4PlayableProjection(recyclerView);
}
@Override

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

@@ -129,6 +129,7 @@ import org.thoughtcrime.securesms.util.UrlClickHandler;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.VibrateUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.NullableStub;
import org.thoughtcrime.securesms.util.views.Stub;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import org.whispersystems.libsignal.util.guava.Optional;
@@ -185,19 +186,19 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private AlertView alertView;
protected ReactionsConversationView reactionsView;
private @NonNull Set<ConversationMessage> batchSelected = new HashSet<>();
private @NonNull Outliner outliner = new Outliner();
private @NonNull Outliner pulseOutliner = new Outliner();
private @NonNull List<Outliner> outliners = new ArrayList<>(2);
private LiveRecipient conversationRecipient;
private Stub<ConversationItemThumbnail> mediaThumbnailStub;
private Stub<AudioView> audioViewStub;
private Stub<DocumentView> documentViewStub;
private Stub<SharedContactView> sharedContactStub;
private Stub<LinkPreviewView> linkPreviewStub;
private Stub<BorderlessImageView> stickerStub;
private Stub<ViewOnceMessageView> revealableStub;
private @Nullable EventListener eventListener;
private @NonNull Set<ConversationMessage> batchSelected = new HashSet<>();
private @NonNull Outliner outliner = new Outliner();
private @NonNull Outliner pulseOutliner = new Outliner();
private @NonNull List<Outliner> outliners = new ArrayList<>(2);
private LiveRecipient conversationRecipient;
private NullableStub<ConversationItemThumbnail> mediaThumbnailStub;
private Stub<AudioView> audioViewStub;
private Stub<DocumentView> documentViewStub;
private Stub<SharedContactView> sharedContactStub;
private Stub<LinkPreviewView> linkPreviewStub;
private Stub<BorderlessImageView> stickerStub;
private Stub<ViewOnceMessageView> revealableStub;
private @Nullable EventListener eventListener;
private int defaultBubbleColor;
private int defaultBubbleColorForWallpaper;
@@ -244,26 +245,26 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.backgroundDrawable = new ClipProjectionDrawable(Objects.requireNonNull(ContextCompat.getDrawable(getContext(),
R.drawable.conversation_item_background)));
this.bodyText = findViewById(R.id.conversation_item_body);
this.footer = findViewById(R.id.conversation_item_footer);
this.stickerFooter = findViewById(R.id.conversation_item_sticker_footer);
this.groupSender = findViewById(R.id.group_message_sender);
this.alertView = findViewById(R.id.indicators_parent);
this.contactPhoto = findViewById(R.id.contact_photo);
this.contactPhotoHolder = findViewById(R.id.contact_photo_container);
this.bodyBubble = findViewById(R.id.body_bubble);
this.mediaThumbnailStub = new Stub<>(findViewById(R.id.image_view_stub));
this.audioViewStub = new Stub<>(findViewById(R.id.audio_view_stub));
this.documentViewStub = new Stub<>(findViewById(R.id.document_view_stub));
this.sharedContactStub = new Stub<>(findViewById(R.id.shared_contact_view_stub));
this.linkPreviewStub = new Stub<>(findViewById(R.id.link_preview_stub));
this.stickerStub = new Stub<>(findViewById(R.id.sticker_view_stub));
this.revealableStub = new Stub<>(findViewById(R.id.revealable_view_stub));
this.groupSenderHolder = findViewById(R.id.group_sender_holder);
this.quoteView = findViewById(R.id.quote_view);
this.reply = findViewById(R.id.reply_icon_wrapper);
this.replyIcon = findViewById(R.id.reply_icon);
this.reactionsView = findViewById(R.id.reactions_view);
this.bodyText = findViewById(R.id.conversation_item_body);
this.footer = findViewById(R.id.conversation_item_footer);
this.stickerFooter = findViewById(R.id.conversation_item_sticker_footer);
this.groupSender = findViewById(R.id.group_message_sender);
this.alertView = findViewById(R.id.indicators_parent);
this.contactPhoto = findViewById(R.id.contact_photo);
this.contactPhotoHolder = findViewById(R.id.contact_photo_container);
this.bodyBubble = findViewById(R.id.body_bubble);
this.mediaThumbnailStub = new NullableStub<>(findViewById(R.id.image_view_stub));
this.audioViewStub = new Stub<>(findViewById(R.id.audio_view_stub));
this.documentViewStub = new Stub<>(findViewById(R.id.document_view_stub));
this.sharedContactStub = new Stub<>(findViewById(R.id.shared_contact_view_stub));
this.linkPreviewStub = new Stub<>(findViewById(R.id.link_preview_stub));
this.stickerStub = new Stub<>(findViewById(R.id.sticker_view_stub));
this.revealableStub = new Stub<>(findViewById(R.id.revealable_view_stub));
this.groupSenderHolder = findViewById(R.id.group_sender_holder);
this.quoteView = findViewById(R.id.quote_view);
this.reply = findViewById(R.id.reply_icon_wrapper);
this.replyIcon = findViewById(R.id.reply_icon);
this.reactionsView = findViewById(R.id.reactions_view);
setOnClickListener(new ClickListener(null));
@@ -426,7 +427,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (hasAudio(messageRecord)) {
availableWidth = audioViewStub.get().getMeasuredWidth() + ViewUtil.getLeftMargin(audioViewStub.get()) + ViewUtil.getRightMargin(audioViewStub.get());
} else if (!isViewOnceMessage(messageRecord) && (hasThumbnail(messageRecord) || hasBigImageLinkPreview(messageRecord))) {
availableWidth = mediaThumbnailStub.get().getMeasuredWidth();
availableWidth = mediaThumbnailStub.require().getMeasuredWidth();
} else {
availableWidth = bodyBubble.getMeasuredWidth() - bodyBubble.getPaddingLeft() - bodyBubble.getPaddingRight();
}
@@ -508,7 +509,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
bodyBubble.setOutliners(outliners);
if (mediaThumbnailStub.resolved()) {
mediaThumbnailStub.get().setPulseOutliner(pulseOutliner);
mediaThumbnailStub.require().setPulseOutliner(pulseOutliner);
}
if (audioViewStub.resolved()) {
@@ -551,9 +552,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
if (mediaThumbnailStub.resolved()) {
mediaThumbnailStub.get().setFocusable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty());
mediaThumbnailStub.get().setClickable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty());
mediaThumbnailStub.get().setLongClickable(batchSelected.isEmpty());
mediaThumbnailStub.require().setFocusable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty());
mediaThumbnailStub.require().setClickable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty());
mediaThumbnailStub.require().setLongClickable(batchSelected.isEmpty());
}
if (audioViewStub.resolved()) {
@@ -575,7 +576,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
bodyBubble.invalidate();
if (mediaThumbnailStub.resolved()) {
mediaThumbnailStub.get().invalidate();
mediaThumbnailStub.require().invalidate();
}
});
pulseOutlinerAlphaAnimator.start();
@@ -753,7 +754,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (isViewOnceMessage(messageRecord) && !messageRecord.isRemoteDelete()) {
revealableStub.get().setVisibility(VISIBLE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(View.GONE);
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
@@ -768,7 +769,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
} else if (hasSharedContact(messageRecord)) {
sharedContactStub.get().setVisibility(VISIBLE);
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(View.GONE);
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
@@ -787,7 +788,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
} else if (hasLinkPreview(messageRecord) && messageRequestAccepted) {
linkPreviewStub.get().setVisibility(View.VISIBLE);
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(View.GONE);
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
@@ -797,12 +798,12 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
LinkPreview linkPreview = ((MmsMessageRecord) messageRecord).getLinkPreviews().get(0);
if (hasBigImageLinkPreview(messageRecord)) {
mediaThumbnailStub.get().setVisibility(VISIBLE);
mediaThumbnailStub.get().setMinimumThumbnailWidth(readDimen(R.dimen.media_bubble_min_width_with_content));
mediaThumbnailStub.get().setImageResource(glideRequests, Collections.singletonList(new ImageSlide(context, linkPreview.getThumbnail().get())), showControls, false);
mediaThumbnailStub.get().setThumbnailClickListener(new LinkPreviewThumbnailClickListener());
mediaThumbnailStub.get().setDownloadClickListener(downloadClickListener);
mediaThumbnailStub.get().setOnLongClickListener(passthroughClickListener);
mediaThumbnailStub.require().setVisibility(VISIBLE);
mediaThumbnailStub.require().setMinimumThumbnailWidth(readDimen(R.dimen.media_bubble_min_width_with_content));
mediaThumbnailStub.require().setImageResource(glideRequests, Collections.singletonList(new ImageSlide(context, linkPreview.getThumbnail().get())), showControls, false);
mediaThumbnailStub.require().setThumbnailClickListener(new LinkPreviewThumbnailClickListener());
mediaThumbnailStub.require().setDownloadClickListener(downloadClickListener);
mediaThumbnailStub.require().setOnLongClickListener(passthroughClickListener);
linkPreviewStub.get().setLinkPreview(glideRequests, linkPreview, false);
@@ -826,7 +827,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
footer.setVisibility(VISIBLE);
} else if (hasAudio(messageRecord)) {
audioViewStub.get().setVisibility(View.VISIBLE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(View.GONE);
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
@@ -851,7 +852,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
footer.setVisibility(VISIBLE);
} else if (hasDocument(messageRecord)) {
documentViewStub.get().setVisibility(View.VISIBLE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(View.GONE);
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
@@ -872,7 +873,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
bodyBubble.setBackgroundColor(Color.TRANSPARENT);
stickerStub.get().setVisibility(View.VISIBLE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(View.GONE);
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
@@ -898,7 +899,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
footer.setVisibility(VISIBLE);
} else if (hasThumbnail(messageRecord)) {
mediaThumbnailStub.get().setVisibility(View.VISIBLE);
mediaThumbnailStub.require().setVisibility(View.VISIBLE);
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
@@ -907,25 +908,25 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
List<Slide> thumbnailSlides = ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlides();
mediaThumbnailStub.get().setMinimumThumbnailWidth(readDimen(isCaptionlessMms(messageRecord) ? R.dimen.media_bubble_min_width_solo
mediaThumbnailStub.require().setMinimumThumbnailWidth(readDimen(isCaptionlessMms(messageRecord) ? R.dimen.media_bubble_min_width_solo
: R.dimen.media_bubble_min_width_with_content));
mediaThumbnailStub.get().setImageResource(glideRequests,
mediaThumbnailStub.require().setImageResource(glideRequests,
thumbnailSlides,
showControls,
false);
mediaThumbnailStub.get().setThumbnailClickListener(new ThumbnailClickListener());
mediaThumbnailStub.get().setDownloadClickListener(downloadClickListener);
mediaThumbnailStub.get().setOnLongClickListener(passthroughClickListener);
mediaThumbnailStub.get().setOnClickListener(passthroughClickListener);
mediaThumbnailStub.get().showShade(TextUtils.isEmpty(messageRecord.getDisplayBody(getContext())) && !hasExtraText(messageRecord));
mediaThumbnailStub.require().setThumbnailClickListener(new ThumbnailClickListener());
mediaThumbnailStub.require().setDownloadClickListener(downloadClickListener);
mediaThumbnailStub.require().setOnLongClickListener(passthroughClickListener);
mediaThumbnailStub.require().setOnClickListener(passthroughClickListener);
mediaThumbnailStub.require().showShade(TextUtils.isEmpty(messageRecord.getDisplayBody(getContext())) && !hasExtraText(messageRecord));
if (!messageRecord.isOutgoing()) {
mediaThumbnailStub.get().setConversationColor(getDefaultBubbleColor(hasWallpaper));
mediaThumbnailStub.require().setConversationColor(getDefaultBubbleColor(hasWallpaper));
} else {
mediaThumbnailStub.get().setConversationColor(Color.TRANSPARENT);
mediaThumbnailStub.require().setConversationColor(Color.TRANSPARENT);
}
mediaThumbnailStub.get().setBorderless(false);
mediaThumbnailStub.require().setBorderless(false);
setThumbnailCorners(messageRecord, previousRecord, nextRecord, isGroupThread);
@@ -950,7 +951,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
} else {
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(View.GONE);
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
@@ -1026,9 +1027,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
if (ViewUtil.isRtl(this)) {
mediaThumbnailStub.get().setCorners(topEnd, topStart, bottomStart, bottomEnd);
mediaThumbnailStub.require().setCorners(topEnd, topStart, bottomStart, bottomEnd);
} else {
mediaThumbnailStub.get().setCorners(topStart, topEnd, bottomEnd, bottomStart);
mediaThumbnailStub.require().setCorners(topStart, topEnd, bottomEnd, bottomStart);
}
}
@@ -1161,7 +1162,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
if (mediaThumbnailStub.resolved()) {
ViewUtil.setTopMargin(mediaThumbnailStub.get(), readDimen(R.dimen.message_bubble_top_padding));
ViewUtil.setTopMargin(mediaThumbnailStub.require(), readDimen(R.dimen.message_bubble_top_padding));
}
if (linkPreviewStub.resolved() && !hasBigImageLinkPreview(current)) {
@@ -1173,7 +1174,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
if (mediaThumbnailStub.resolved()) {
ViewUtil.setTopMargin(mediaThumbnailStub.get(), 0);
ViewUtil.setTopMargin(mediaThumbnailStub.require(), 0);
}
if (linkPreviewStub.resolved()) {
@@ -1219,7 +1220,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
footer.setVisibility(GONE);
stickerFooter.setVisibility(GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().getFooter().setVisibility(GONE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().getFooter().setVisibility(GONE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().getFooter().setVisibility(GONE);
boolean differentTimestamps = next.isPresent() && !DateUtils.isSameExtendedRelativeTimestamp(context, locale, next.get().getTimestamp(), current.getTimestamp());
@@ -1260,7 +1261,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
} else if (hasSharedContact(messageRecord) && TextUtils.isEmpty(messageRecord.getDisplayBody(getContext()))) {
return sharedContactStub.get().getFooter();
} else if (hasOnlyThumbnail(messageRecord) && TextUtils.isEmpty(messageRecord.getDisplayBody(getContext()))) {
return mediaThumbnailStub.get().getFooter();
return mediaThumbnailStub.require().getFooter();
} else {
return footer;
}
@@ -1494,7 +1495,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Override
public void showProjectionArea() {
if (mediaThumbnailStub != null && mediaThumbnailStub.resolved()) {
mediaThumbnailStub.get().showThumbnailView();
mediaThumbnailStub.require().showThumbnailView();
bodyBubble.setVideoPlayerProjection(null);
updateSelectedBackgroundDrawableProjections();
}
@@ -1503,9 +1504,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Override
public void hideProjectionArea() {
if (mediaThumbnailStub != null && mediaThumbnailStub.resolved()) {
mediaThumbnailStub.get().hideThumbnailView();
mediaThumbnailStub.get().getDrawingRect(thumbnailMaskingRect);
bodyBubble.setVideoPlayerProjection(Projection.relativeToViewWithCommonRoot(mediaThumbnailStub.get(), bodyBubble, null));
mediaThumbnailStub.require().hideThumbnailView();
mediaThumbnailStub.require().getDrawingRect(thumbnailMaskingRect);
bodyBubble.setVideoPlayerProjection(Projection.relativeToViewWithCommonRoot(mediaThumbnailStub.require(), bodyBubble, null));
updateSelectedBackgroundDrawableProjections();
}
}
@@ -1532,27 +1533,19 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
@Override
public @NonNull Projection getProjection(@NonNull ViewGroup recyclerView) {
return Projection.relativeToParent(recyclerView, mediaThumbnailStub.get(), mediaThumbnailStub.get().getCorners())
.translateX(bodyBubble.getTranslationX());
public @NonNull Projection getGiphyMp4PlayableProjection(@NonNull ViewGroup recyclerView) {
if (mediaThumbnailStub != null && mediaThumbnailStub.isResolvable()) {
return Projection.relativeToParent(recyclerView, mediaThumbnailStub.require(), mediaThumbnailStub.require().getCorners())
.translateX(bodyBubble.getTranslationX());
} else {
return Projection.relativeToParent(recyclerView, bodyBubble, bodyBubbleCorners)
.translateX(bodyBubble.getTranslationX());
}
}
@Override
public boolean canPlayContent() {
return mediaThumbnailStub != null && canPlayContent;
}
public @NonNull Rect getThumbnailMaskingRect(@NonNull ViewGroup parent) {
Rect rect = new Rect();
rect.set(thumbnailMaskingRect);
parent.offsetDescendantRectToMyCoords(mediaThumbnailStub.get(), rect);
return rect;
}
public @NonNull Projection.Corners getThumbnailCorners() {
return mediaThumbnailStub.get().getCorners();
return mediaThumbnailStub != null && mediaThumbnailStub.isResolvable() && canPlayContent;
}
@Override

View File

@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.conversation;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.view.View;
import androidx.annotation.NonNull;
@@ -57,7 +56,7 @@ public final class ConversationItemMaskTarget extends MaskView.MaskTarget {
).toList();
if (videoContainer != null) {
projections.add(conversationItem.getProjection((RecyclerView) conversationItem.getParent()));
projections.add(conversationItem.getGiphyMp4PlayableProjection((RecyclerView) conversationItem.getParent()));
}
for (Projection projection : projections) {

View File

@@ -209,7 +209,7 @@ public final class ConversationUpdateItem extends FrameLayout
}
@Override
public @NonNull Projection getProjection(@NonNull ViewGroup recyclerView) {
public @NonNull Projection getGiphyMp4PlayableProjection(@NonNull ViewGroup recyclerView) {
throw new UnsupportedOperationException("ConversationUpdateItems cannot be projected into.");
}

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

@@ -38,6 +38,7 @@ import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
@@ -58,8 +59,6 @@ import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.core.content.res.ResourcesCompat;
import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
@@ -92,7 +91,6 @@ import org.thoughtcrime.securesms.components.reminder.ReminderView;
import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView;
import org.thoughtcrime.securesms.conversation.ConversationFragment;
@@ -195,7 +193,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private Drawable archiveDrawable;
private AppForegroundObserver.Listener appForegroundObserver;
private VoiceNoteMediaControllerOwner mediaControllerOwner;
private Stub<VoiceNotePlayerView> voiceNotePlayerViewStub;
private Stub<FrameLayout> voiceNotePlayerViewStub;
private VoiceNotePlayerView voiceNotePlayerView;
private Stopwatch startupStopwatch;
@@ -531,18 +531,24 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private void initializeVoiceNotePlayer() {
mediaControllerOwner.getVoiceNoteMediaController().getVoiceNotePlayerViewState().observe(getViewLifecycleOwner(), state -> {
if (state.isPresent()) {
if (!voiceNotePlayerViewStub.resolved()) {
voiceNotePlayerViewStub.get().setListener(new VoiceNotePlayerViewListener());
}
voiceNotePlayerViewStub.get().setState(state.get());
voiceNotePlayerViewStub.get().show();
requireVoiceNotePlayerView().setState(state.get());
requireVoiceNotePlayerView().show();
} else if (voiceNotePlayerViewStub.resolved()) {
voiceNotePlayerViewStub.get().hide();
requireVoiceNotePlayerView().hide();
}
});
}
private @NonNull VoiceNotePlayerView requireVoiceNotePlayerView() {
if (voiceNotePlayerView == null) {
voiceNotePlayerView = voiceNotePlayerViewStub.get().findViewById(R.id.voice_note_player_view);
voiceNotePlayerView.setListener(new VoiceNotePlayerViewListener());
}
return voiceNotePlayerView;
}
private void initializeListAdapters() {
defaultAdapter = new ConversationListAdapter(GlideApp.with(this), this);
searchAdapter = new ConversationListSearchAdapter(GlideApp.with(this), this, Locale.getDefault());

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

@@ -94,6 +94,7 @@ public class JobDatabase extends SQLiteOpenHelper implements SignalDatabase {
if (instance == null) {
synchronized (JobDatabase.class) {
if (instance == null) {
SqlCipherLibraryLoader.load(context);
instance = new JobDatabase(context, DatabaseSecretProvider.getOrCreateDatabaseSecret(context));
}
}

View File

@@ -54,6 +54,7 @@ public class KeyValueDatabase extends SQLiteOpenHelper implements SignalDatabase
if (instance == null) {
synchronized (KeyValueDatabase.class) {
if (instance == null) {
SqlCipherLibraryLoader.load(context);
instance = new KeyValueDatabase(context, DatabaseSecretProvider.getOrCreateDatabaseSecret(context));
}
}
@@ -61,7 +62,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,247 @@
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.ByteUnit
import org.thoughtcrime.securesms.util.CursorUtil
import org.thoughtcrime.securesms.util.SqlUtil
import org.thoughtcrime.securesms.util.Stopwatch
import java.io.Closeable
import java.util.concurrent.TimeUnit
/**
* Stores logs.
*
* Logs are very performance critical. Even though this database is written to on a low-priority background thread, we want to keep throughput high and ensure
* that we aren't creating excess garbage.
*
* 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 val MAX_FILE_SIZE = ByteUnit.MEGABYTES.toBytes(10)
private val DEFAULT_LIFESPAN = TimeUnit.DAYS.toMillis(2)
private val LONGER_LIFESPAN = TimeUnit.DAYS.toMillis(7)
private const val DATABASE_VERSION = 2
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 KEEP_LONGER = "keep_longer"
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,
$KEEP_LONGER INTEGER DEFAULT 0,
$BODY TEXT,
$SIZE INTEGER
)
""".trimIndent()
private val CREATE_INDEXES = arrayOf(
"CREATE INDEX keep_longer_index ON $TABLE_NAME ($KEEP_LONGER)",
"CREATE INDEX log_created_at_keep_longer_index ON $TABLE_NAME ($CREATED_AT, $KEEP_LONGER)"
)
@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) {
SqlCipherLibraryLoader.load(context)
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)")
if (oldVersion < 2) {
db.execSQL("DROP TABLE log")
db.execSQL("CREATE TABLE log (_id INTEGER PRIMARY KEY, created_at INTEGER, keep_longer INTEGER DEFAULT 0, body TEXT, size INTEGER)")
db.execSQL("CREATE INDEX keep_longer_index ON log (keep_longer)")
db.execSQL("CREATE INDEX log_created_at_keep_longer_index ON log (created_at, keep_longer)")
}
}
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,
"($CREATED_AT < ? AND $KEEP_LONGER = ?) OR ($CREATED_AT < ? AND $KEEP_LONGER = ?)",
SqlUtil.buildArgs(currentTime - DEFAULT_LIFESPAN, 0, currentTime - LONGER_LIFESPAN, 1)
)
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
fun getAllBeforeTime(time: Long): Reader {
return CursorReader(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 trimToSize() {
val currentTime = System.currentTimeMillis()
val stopwatch = Stopwatch("trim")
val sizeOfSpecialLogs: Long = getSize("$KEEP_LONGER = ?", arrayOf("1"))
val remainingSize = MAX_FILE_SIZE - sizeOfSpecialLogs
stopwatch.split("keepers-size")
if (remainingSize <= 0) {
writableDatabase.delete(TABLE_NAME, "$KEEP_LONGER = ?", arrayOf("0"))
return
}
val sizeDiffThreshold = MAX_FILE_SIZE * 0.01
var lhs: Long = currentTime - DEFAULT_LIFESPAN
var rhs: Long = currentTime
var mid: Long = 0
var sizeOfChunk: Long
while (lhs < rhs - 2) {
mid = (lhs + rhs) / 2
sizeOfChunk = getSize("$CREATED_AT > ? AND $CREATED_AT < ? AND $KEEP_LONGER = ?", SqlUtil.buildArgs(mid, currentTime, 0))
if (sizeOfChunk > remainingSize) {
lhs = mid
} else if (sizeOfChunk < remainingSize) {
if (remainingSize - sizeOfChunk < sizeDiffThreshold) {
break
} else {
rhs = mid
}
} else {
break
}
}
stopwatch.split("binary-search")
writableDatabase.delete(TABLE_NAME, "$CREATED_AT < ? AND $KEEP_LONGER = ?", SqlUtil.buildArgs(mid, 0))
stopwatch.split("delete")
stopwatch.stop(TAG)
}
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(KEEP_LONGER, if (log.keepLonger) 1 else 0)
put(BODY, log.body)
put(SIZE, log.body.length)
}
}
private fun getSize(query: String?, args: Array<String>?): Long {
readableDatabase.query(TABLE_NAME, arrayOf("SUM($SIZE)"), query, args, null, null, null).use { cursor ->
return if (cursor.moveToFirst()) {
cursor.getLong(0)
} else {
0
}
}
}
private val readableDatabase: SQLiteDatabase
get() = getReadableDatabase(databaseSecret.asString())
private val writableDatabase: SQLiteDatabase
get() = getWritableDatabase(databaseSecret.asString())
interface Reader : Iterator<String>, Closeable
class CursorReader(private val cursor: Cursor) : Reader {
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

@@ -57,6 +57,7 @@ public class MegaphoneDatabase extends SQLiteOpenHelper implements SignalDatabas
if (instance == null) {
synchronized (MegaphoneDatabase.class) {
if (instance == null) {
SqlCipherLibraryLoader.load(context);
instance = new MegaphoneDatabase(context, DatabaseSecretProvider.getOrCreateDatabaseSecret(context));
}
}

View File

@@ -438,7 +438,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.", true);
if (highTrust) {
long id = db.insert(TABLE_NAME, null, buildContentValuesForNewUser(e164, uuid));
finalId = RecipientId.from(id);
@@ -451,7 +451,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), true);
removePhoneNumber(byE164.get(), db);
recipientNeedingRefresh = byE164.get();
@@ -462,18 +462,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), true);
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()), true);
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()), true);
long id = db.insert(TABLE_NAME, null, buildContentValuesForNewUser(null, uuid));
finalId = RecipientId.from(id);
}
@@ -484,11 +484,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()), true);
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()), true);
finalId = byUuid.get();
}
} else {
@@ -498,13 +498,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(), true);
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.", true);
removePhoneNumber(byE164.get(), db);
recipientNeedingRefresh = byE164.get();
@@ -513,17 +513,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.", true);
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.", true);
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.", true);
finalId = byUuid.get();
}
}
@@ -2378,7 +2378,7 @@ public class RecipientDatabase extends Database {
.excludeId(includeSelf ? null : Recipient.self().getId())
.build();
String orderBy = SORT_NAME + ", " + SYSTEM_JOINED_NAME + ", " + SEARCH_PROFILE_NAME + ", " + USERNAME + ", " + PHONE;
String orderBy = orderByPreferringAlphaOverNumeric(SORT_NAME) + ", " + PHONE;
return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, searchSelection.where, searchSelection.args, null, null, orderBy);
}
@@ -2395,7 +2395,7 @@ public class RecipientDatabase extends Database {
String selection = searchSelection.getWhere();
String[] args = searchSelection.getArgs();
String orderBy = SORT_NAME + ", " + SYSTEM_JOINED_NAME + ", " + SEARCH_PROFILE_NAME + ", " + PHONE;
String orderBy = orderByPreferringAlphaOverNumeric(SORT_NAME) + ", " + PHONE;
return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy);
}
@@ -2879,7 +2879,7 @@ public class RecipientDatabase extends Database {
RecipientSettings e164Settings = getRecipientSettings(byE164);
// Recipient
Log.w(TAG, "Deleting recipient " + byE164);
Log.w(TAG, "Deleting recipient " + byE164, true);
db.delete(TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(byE164));
RemappedRecords.getInstance().addRecipient(context, byE164, byUuid);
@@ -2964,17 +2964,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.", true);
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.", true);
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.", true);
} else {
Log.w(TAG, "Had no sessions. No action necessary.");
Log.w(TAG, "Had no sessions. No action necessary.", true);
}
// Mentions
@@ -3091,6 +3091,16 @@ public class RecipientDatabase extends Database {
return "NULLIF(" + column + ", '')";
}
/**
* By default, SQLite will prefer numbers over letters when sorting. e.g. (b, a, 1) is sorted as (1, a, b).
* This order by will using a GLOB pattern to instead sort it as (a, b, 1).
*
* @param column The name of the column to sort by
*/
private static @NonNull String orderByPreferringAlphaOverNumeric(@NonNull String column) {
return "CASE WHEN " + column + " GLOB '[0-9]*' THEN 1 ELSE 0 END, " + column;
}
private static @NonNull String removeWhitespace(@NonNull String column) {
return "REPLACE(" + column + ", ' ', '')";
}

View File

@@ -10,6 +10,7 @@ import net.sqlcipher.database.SQLiteDatabase
class SqlCipherLibraryLoader {
companion object {
@Volatile
private var loaded = false
private val LOCK = Object()

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, true);
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.", true);
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.", true);
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.", true);
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 {
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 keepLonger: Boolean,
val body: String
)

View File

@@ -45,7 +45,7 @@ public interface GiphyMp4Playable {
* Width, height, and (x,y) of view which video player will "project" into
* @param viewGroup
*/
@NonNull Projection getProjection(@NonNull ViewGroup viewGroup);
@NonNull Projection getGiphyMp4PlayableProjection(@NonNull ViewGroup viewGroup);
/**
* Specifies whether the content can start playing.

View File

@@ -8,7 +8,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.conversation.ConversationItemSwipeCallback;
import org.thoughtcrime.securesms.util.Projection;
import java.util.ArrayList;
@@ -86,7 +85,7 @@ public final class GiphyMp4ProjectionRecycler implements GiphyMp4PlaybackControl
}
private void updateDisplay(@NonNull RecyclerView recyclerView, @NonNull GiphyMp4ProjectionPlayerHolder holder, @NonNull GiphyMp4Playable giphyMp4Playable) {
Projection projection = giphyMp4Playable.getProjection(recyclerView);
Projection projection = giphyMp4Playable.getGiphyMp4PlayableProjection(recyclerView);
holder.getContainer().setX(projection.getX());
holder.getContainer().setY(projection.getY());

View File

@@ -82,7 +82,7 @@ final class GiphyMp4ViewHolder extends RecyclerView.ViewHolder implements GiphyM
}
@Override
public @NonNull Projection getProjection(@NonNull ViewGroup recyclerView) {
public @NonNull Projection getGiphyMp4PlayableProjection(@NonNull ViewGroup recyclerView) {
return Projection.relativeToParent(recyclerView, container, CORNERS);
}

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

@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -100,6 +101,10 @@ public class RetrieveProfileAvatarJob extends BaseJob {
try {
AvatarHelper.setAvatar(context, recipient.getId(), avatarStream);
if (recipient.isSelf()) {
SignalStore.misc().markHasEverHadAnAvatar();
}
} catch (AssertionError e) {
throw new IOException("Failed to copy stream. Likely a Conscrypt issue.", e);
}

View File

@@ -14,6 +14,7 @@ public final class MiscellaneousValues extends SignalStoreValues {
private static final String USERNAME_SHOW_REMINDER = "username.show.reminder";
private static final String CLIENT_DEPRECATED = "misc.client_deprecated";
private static final String OLD_DEVICE_TRANSFER_LOCKED = "misc.old_device.transfer.locked";
private static final String HAS_EVER_HAD_AN_AVATAR = "misc.has.ever.had.an.avatar";
MiscellaneousValues(@NonNull KeyValueStore store) {
super(store);
@@ -88,4 +89,12 @@ public final class MiscellaneousValues extends SignalStoreValues {
public void clearOldDeviceTransferLocked() {
putBoolean(OLD_DEVICE_TRANSFER_LOCKED, false);
}
public boolean hasEverHadAnAvatar() {
return getBoolean(HAS_EVER_HAD_AN_AVATAR, false);
}
public void markHasEverHadAnAvatar() {
putBoolean(HAS_EVER_HAD_AN_AVATAR, true);
}
}

View File

@@ -17,6 +17,7 @@ public final class OnboardingValues extends SignalStoreValues {
private static final String SHOW_INVITE_FRIENDS = "onboarding.invite_friends";
private static final String SHOW_SMS = "onboarding.sms";
private static final String SHOW_APPEARANCE = "onboarding.appearance";
private static final String SHOW_ADD_PHOTO = "onboarding.add_photo";
OnboardingValues(@NonNull KeyValueStore store) {
super(store);
@@ -28,6 +29,7 @@ public final class OnboardingValues extends SignalStoreValues {
putBoolean(SHOW_INVITE_FRIENDS, true);
putBoolean(SHOW_SMS, true);
putBoolean(SHOW_APPEARANCE, true);
putBoolean(SHOW_ADD_PHOTO, true);
}
@Override
@@ -40,13 +42,15 @@ public final class OnboardingValues extends SignalStoreValues {
setShowInviteFriends(false);
setShowSms(false);
setShowAppearance(false);
setShowAddPhoto(false);
}
public boolean hasOnboarding(@NonNull Context context) {
return shouldShowNewGroup() ||
shouldShowInviteFriends() ||
shouldShowSms(context) ||
shouldShowAppearance();
shouldShowAppearance() ||
shouldShowAddPhoto();
}
public void setShowNewGroup(boolean value) {
@@ -80,4 +84,12 @@ public final class OnboardingValues extends SignalStoreValues {
public boolean shouldShowAppearance() {
return getBoolean(SHOW_APPEARANCE, false);
}
public void setShowAddPhoto(boolean value) {
putBoolean(SHOW_ADD_PHOTO, value);
}
public boolean shouldShowAddPhoto(){
return getBoolean(SHOW_ADD_PHOTO, false);
}
}

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,201 @@
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 org.thoughtcrime.securesms.logsubmit.util.Scrubber
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
) : Log.Logger() {
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 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?, keepLonger: Boolean) {
write(LOG_V, tag, message, t, keepLonger)
}
override fun d(tag: String?, message: String?, t: Throwable?, keepLonger: Boolean) {
write(LOG_D, tag, message, t, keepLonger)
}
override fun i(tag: String?, message: String?, t: Throwable?, keepLonger: Boolean) {
write(LOG_I, tag, message, t, keepLonger)
}
override fun w(tag: String?, message: String?, t: Throwable?, keepLonger: Boolean) {
write(LOG_W, tag, message, t, keepLonger)
}
override fun e(tag: String?, message: String?, t: Throwable?, keepLonger: Boolean) {
write(LOG_E, tag, message, t, keepLonger)
}
override fun flush() {
logEntries.blockForFlushed()
}
private fun write(level: String, tag: String?, message: String?, t: Throwable?, keepLonger: Boolean) {
logEntries.add(LogRequest(level, tag ?: "null", message, Date(), getThreadString(), t, keepLonger))
}
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 keepLonger: Boolean
)
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,
keepLonger = request.keepLonger,
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,
keepLonger = request.keepLonger,
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: ${Scrubber.scrub(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

@@ -18,4 +18,11 @@ interface LogSection {
* one line at a time.
*/
@NonNull CharSequence getContent(@NonNull Context context);
/**
* Whether or not this section has content.
*/
default boolean hasContent() {
return true;
}
}

View File

@@ -29,7 +29,7 @@ final class LogSectionKeyPreferences implements LogSection {
.append("Client Deprecated : ").append(SignalStore.misc().isClientDeprecated()).append("\n")
.append("Push Registered : ").append(TextSecurePreferences.isPushRegistered(context)).append("\n")
.append("Unauthorized Received: ").append(TextSecurePreferences.isUnauthorizedRecieved(context)).append("\n")
.append("self.isRegistered() : ").append(Recipient.self().isRegistered()).append("\n")
.append("self.isRegistered() : ").append(TextSecurePreferences.getLocalUuid(context) == null ? "false" : Recipient.self().isRegistered()).append("\n")
.append("Thread Trimming : ").append(getThreadTrimmingString()).append("\n");
}

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,11 @@ 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 "";
}
@Override
public boolean hasContent() {
return false;
}
}

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,11 @@
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 android.os.SystemClock;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -11,20 +15,31 @@ 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.thoughtcrime.securesms.util.ByteUnit;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import java.util.zip.GZIPOutputStream;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
@@ -33,6 +48,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 +86,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 +97,97 @@ 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());
Stopwatch stopwatch = new Stopwatch("log-upload");
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());
stopwatch.split("front-matter");
try (LogDatabase.Reader reader = LogDatabase.getInstance(context).getAllBeforeTime(untilTime)) {
while (reader.hasNext()) {
gzipOutput.write(reader.next().getBytes());
gzipOutput.write("\n".getBytes());
}
}
StreamUtil.close(gzipOutput);
stopwatch.split("body");
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);
}
});
stopwatch.split("upload");
stopwatch.stop(TAG);
BlobProvider.getInstance().delete(context, gzipUri);
return Optional.of(logUrl);
} catch (IOException e) {
Log.w(TAG, "Error during log upload.", e);
@@ -125,7 +196,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 +220,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 +236,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()));
@@ -202,14 +273,16 @@ public class SubmitDebugLogRepository {
List<LogLine> out = new ArrayList<>();
out.add(new SimpleLogLine(formatTitle(section.getTitle(), maxTitleLength), LogLine.Style.NONE, LogLine.Placeholder.NONE));
CharSequence content = Scrubber.scrub(section.getContent(context));
if (section.hasContent()) {
CharSequence content = Scrubber.scrub(section.getContent(context));
List<LogLine> lines = Stream.of(Pattern.compile("\\n").split(content))
.map(s -> new SimpleLogLine(s, LogStyleParser.parseStyle(s), LogStyleParser.parsePlaceholderType(s)))
.map(line -> (LogLine) line)
.toList();
List<LogLine> lines = Stream.of(Pattern.compile("\\n").split(content))
.map(s -> new SimpleLogLine(s, LogStyleParser.parseStyle(s), LogStyleParser.parsePlaceholderType(s)))
.map(line -> (LogLine) line)
.toList();
out.addAll(lines);
out.addAll(lines);
}
Log.d(TAG, "[" + section.getTitle() + "] Took " + (System.currentTimeMillis() - startTime) + " ms");

View File

@@ -1,42 +1,63 @@
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.database.LogDatabase;
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);
LogDatabase.getInstance(ApplicationDependencies.getApplication()).trimToSize();
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 +65,10 @@ public class SubmitDebugLogViewModel extends ViewModel {
return lines;
}
@NonNull PagingController getPagingController() {
return pagingController;
}
@NonNull LiveData<Mode> getMode() {
return mode;
}
@@ -53,7 +78,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 +87,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, shouldShowAddAProfilePhotoMegaphone(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,20 @@ public final class Megaphones {
return shouldShow;
}
private static boolean shouldShowAddAProfilePhotoMegaphone(@NonNull Context context) {
if (SignalStore.misc().hasEverHadAnAvatar()) {
return false;
}
boolean hasAnAvatar = AvatarHelper.hasAvatar(context, Recipient.self().getId());
if (hasAnAvatar) {
SignalStore.misc().markHasEverHadAnAvatar();
return false;
}
return true;
}
public enum Event {
REACTIONS("reactions"),
PINS_FOR_ALL("pins_for_all"),
@@ -375,7 +409,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

@@ -19,10 +19,10 @@ import androidx.recyclerview.widget.RecyclerView;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.InviteActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment;
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.profiles.manage.ManageProfileActivity;
import org.thoughtcrime.securesms.util.SmsUtil;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity;
@@ -66,6 +66,7 @@ public class OnboardingMegaphoneView extends FrameLayout {
private static final int TYPE_INVITE = 1;
private static final int TYPE_SMS = 2;
private static final int TYPE_APPEARANCE = 3;
private static final int TYPE_ADD_PHOTO = 4;
private final Context context;
private final MegaphoneActionController controller;
@@ -102,6 +103,7 @@ public class OnboardingMegaphoneView extends FrameLayout {
case TYPE_INVITE: return new InviteCardViewHolder(view);
case TYPE_SMS: return new SmsCardViewHolder(view);
case TYPE_APPEARANCE: return new AppearanceCardViewHolder(view);
case TYPE_ADD_PHOTO: return new AddPhotoCardViewHolder(view);
default: throw new IllegalStateException("Invalid viewType! " + viewType);
}
}
@@ -138,14 +140,18 @@ public class OnboardingMegaphoneView extends FrameLayout {
data.add(TYPE_INVITE);
}
if (SignalStore.onboarding().shouldShowSms(context)) {
data.add(TYPE_SMS);
if (SignalStore.onboarding().shouldShowAddPhoto() && !SignalStore.misc().hasEverHadAnAvatar()) {
data.add(TYPE_ADD_PHOTO);
}
if (SignalStore.onboarding().shouldShowAppearance()) {
data.add(TYPE_APPEARANCE);
}
if (SignalStore.onboarding().shouldShowSms(context)) {
data.add(TYPE_SMS);
}
return data;
}
}
@@ -295,4 +301,32 @@ public class OnboardingMegaphoneView extends FrameLayout {
SignalStore.onboarding().setShowAppearance(false);
}
}
private static class AddPhotoCardViewHolder extends CardViewHolder {
public AddPhotoCardViewHolder(@NonNull View itemView) {
super(itemView);
}
@Override
int getButtonStringRes() {
return R.string.Megaphones_add_photo;
}
@Override
int getImageRes() {
return R.drawable.ic_signal_add_photo;
}
@Override
void onActionClicked(@NonNull MegaphoneActionController controller) {
controller.onMegaphoneNavigationRequested(ManageProfileActivity.getIntentForAvatarEdit(controller.getMegaphoneActivity()));
SignalStore.onboarding().setShowAddPhoto(false);
}
@Override
void onCloseClicked() {
SignalStore.onboarding().setShowAddPhoto(false);
}
}
}

View File

@@ -238,8 +238,8 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G
}
@Override
public @NonNull Projection getProjection(@NonNull ViewGroup recyclerview) {
return conversationItem.getProjection(recyclerview);
public @NonNull Projection getGiphyMp4PlayableProjection(@NonNull ViewGroup recyclerview) {
return conversationItem.getGiphyMp4PlayableProjection(recyclerview);
}
@Override public
@@ -257,7 +257,7 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G
Set<Projection> projections = new HashSet<>();
if (canPlayContent()) {
projections.add(conversationItem.getProjection((ViewGroup) itemView));
projections.add(conversationItem.getGiphyMp4PlayableProjection((ViewGroup) itemView));
}
projections.addAll(Stream.of(conversationItem.getColorizerProjections())

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