mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-16 07:57:38 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dd8f24e14 | ||
|
|
4e409fc9ed | ||
|
|
136826be69 | ||
|
|
0ba7ff911b | ||
|
|
bfbdbdcbc0 | ||
|
|
f2533ac4b7 | ||
|
|
15a5f5966d | ||
|
|
3c748b2df6 | ||
|
|
c1b54b3532 | ||
|
|
ab56856f41 | ||
|
|
ce31e642dd | ||
|
|
aa67c82634 | ||
|
|
c9c4187d2e | ||
|
|
60b4862b1b | ||
|
|
b2c3a34d68 | ||
|
|
90925f4d8c | ||
|
|
833f90ce53 | ||
|
|
26c9b5166e | ||
|
|
a27d60f830 | ||
|
|
a75f634c0a | ||
|
|
963c018e0c | ||
|
|
6cc0eed5fe | ||
|
|
cdcc7b6fa5 | ||
|
|
24482b5a65 | ||
|
|
b100262c6a | ||
|
|
ed23c3fe7c | ||
|
|
0093e1d3eb | ||
|
|
7419da7247 | ||
|
|
0b85852621 | ||
|
|
556518973d | ||
|
|
b9514d0b94 | ||
|
|
a9dab90a1e | ||
|
|
39709c8d64 | ||
|
|
c2a6963a6d | ||
|
|
bfdebbfa5d | ||
|
|
167a691018 |
4
.github/workflows/android.yml
vendored
4
.github/workflows/android.yml
vendored
@@ -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
|
||||
|
||||
@@ -57,8 +57,8 @@ protobuf {
|
||||
}
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 879
|
||||
def canonicalVersionName = "5.17.1"
|
||||
def canonicalVersionCode = 884
|
||||
def canonicalVersionName = "5.18.2"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -368,7 +368,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.5.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.2.5'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.3.5'
|
||||
lintChecks project(':lintchecks')
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
|
||||
|
||||
BIN
app/src/main/assets/fonts/Inter-Medium.otf
Normal file
BIN
app/src/main/assets/fonts/Inter-Medium.otf
Normal file
Binary file not shown.
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
153
app/src/main/java/org/thoughtcrime/securesms/avatar/Avatars.kt
Normal file
153
app/src/main/java/org/thoughtcrime/securesms/avatar/Avatars.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.thoughtcrime.securesms.avatar
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
|
||||
/**
|
||||
* Uses EmojiTextView to properly render a Text Avatar with emoji in it.
|
||||
*/
|
||||
class TextAvatarDrawable(
|
||||
context: Context,
|
||||
avatar: Avatar.Text,
|
||||
inverted: Boolean = false,
|
||||
private val size: Int = AvatarRenderer.DIMENSIONS,
|
||||
) : Drawable() {
|
||||
|
||||
private val layout: FrameLayout = FrameLayout(context)
|
||||
private val textView: EmojiTextView = EmojiTextView(context)
|
||||
|
||||
init {
|
||||
textView.typeface = AvatarRenderer.getTypeface(context)
|
||||
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f))
|
||||
textView.text = avatar.text
|
||||
textView.gravity = Gravity.CENTER
|
||||
textView.setTextColor(if (inverted) avatar.color.backgroundColor else avatar.color.foregroundColor)
|
||||
|
||||
layout.addView(textView)
|
||||
|
||||
textView.updateLayoutParams {
|
||||
width = size
|
||||
height = size
|
||||
}
|
||||
|
||||
layout.measure(size, size)
|
||||
layout.layout(0, 0, size, size)
|
||||
}
|
||||
|
||||
override fun getIntrinsicHeight(): Int = size
|
||||
|
||||
override fun getIntrinsicWidth(): Int = size
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
layout.draw(canvas)
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) = Unit
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
|
||||
|
||||
override fun getOpacity(): Int = PixelFormat.OPAQUE
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package org.thoughtcrime.securesms.avatar.picker
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarBundler
|
||||
import org.thoughtcrime.securesms.avatar.photo.PhotoEditorFragment
|
||||
import org.thoughtcrime.securesms.avatar.text.TextAvatarCreationFragment
|
||||
import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment
|
||||
import org.thoughtcrime.securesms.components.ButtonStripItemView
|
||||
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
|
||||
import org.thoughtcrime.securesms.groups.ParcelableGroupId
|
||||
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import java.util.Objects
|
||||
|
||||
/**
|
||||
* Primary Avatar picker fragment, displays current user avatar and a list of recently used avatars and defaults.
|
||||
*/
|
||||
class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY_SELECT_AVATAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR"
|
||||
const val SELECT_AVATAR_MEDIA = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_MEDIA"
|
||||
const val SELECT_AVATAR_CLEAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_CLEAR"
|
||||
|
||||
private const val REQUEST_CODE_SELECT_IMAGE = 1
|
||||
}
|
||||
|
||||
private val viewModel: AvatarPickerViewModel by viewModels(factoryProducer = this::createFactory)
|
||||
|
||||
private fun createFactory(): AvatarPickerViewModel.Factory {
|
||||
val args = AvatarPickerFragmentArgs.fromBundle(requireArguments())
|
||||
val groupId = ParcelableGroupId.get(args.groupId)
|
||||
|
||||
return AvatarPickerViewModel.Factory(AvatarPickerRepository(requireContext()), groupId, args.isNewGroup, args.groupAvatarMedia)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar: Toolbar = view.findViewById(R.id.avatar_picker_toolbar)
|
||||
val recycler: RecyclerView = view.findViewById(R.id.avatar_picker_recycler)
|
||||
val cameraButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_camera)
|
||||
val photoButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_photo)
|
||||
val textButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_text)
|
||||
val saveButton: View = view.findViewById(R.id.avatar_picker_save)
|
||||
val clearButton: View = view.findViewById(R.id.avatar_picker_clear)
|
||||
|
||||
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
|
||||
|
||||
val adapter = MappingAdapter()
|
||||
AvatarPickerItem.register(adapter, this::onAvatarClick, this::onAvatarLongClick)
|
||||
|
||||
recycler.adapter = adapter
|
||||
|
||||
val avatarViewHolder = AvatarPickerItem.ViewHolder(view)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
if (state.currentAvatar != null) {
|
||||
avatarViewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
|
||||
}
|
||||
|
||||
clearButton.visible = state.canClear
|
||||
|
||||
val wasEnabled = saveButton.isEnabled
|
||||
saveButton.isEnabled = state.canSave
|
||||
if (wasEnabled != state.canSave) {
|
||||
val alpha = if (state.canSave) 1f else 0.5f
|
||||
saveButton.animate().cancel()
|
||||
saveButton.animate().alpha(alpha)
|
||||
}
|
||||
|
||||
adapter.submitList(state.selectableAvatars.map { AvatarPickerItem.Model(it, it == state.currentAvatar) })
|
||||
}
|
||||
|
||||
toolbar.setNavigationOnClickListener { Navigation.findNavController(it).popBackStack() }
|
||||
cameraButton.setOnIconClickedListener { openCameraCapture() }
|
||||
photoButton.setOnIconClickedListener { openGallery() }
|
||||
textButton.setOnIconClickedListener { openTextEditor(null) }
|
||||
saveButton.setOnClickListener { v ->
|
||||
viewModel.save(
|
||||
{
|
||||
setFragmentResult(
|
||||
REQUEST_KEY_SELECT_AVATAR,
|
||||
Bundle().apply {
|
||||
putParcelable(SELECT_AVATAR_MEDIA, it)
|
||||
}
|
||||
)
|
||||
Navigation.findNavController(v).popBackStack()
|
||||
},
|
||||
{
|
||||
setFragmentResult(
|
||||
REQUEST_KEY_SELECT_AVATAR,
|
||||
Bundle().apply {
|
||||
putBoolean(SELECT_AVATAR_CLEAR, true)
|
||||
}
|
||||
)
|
||||
Navigation.findNavController(v).popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
clearButton.setOnClickListener { viewModel.clear() }
|
||||
|
||||
setFragmentResultListener(TextAvatarCreationFragment.REQUEST_KEY_TEXT) { _, bundle ->
|
||||
val text = AvatarBundler.extractText(bundle)
|
||||
viewModel.onAvatarEditCompleted(text)
|
||||
}
|
||||
|
||||
setFragmentResultListener(VectorAvatarCreationFragment.REQUEST_KEY_VECTOR) { _, bundle ->
|
||||
val vector = AvatarBundler.extractVector(bundle)
|
||||
viewModel.onAvatarEditCompleted(vector)
|
||||
}
|
||||
|
||||
setFragmentResultListener(PhotoEditorFragment.REQUEST_KEY_EDIT) { _, bundle ->
|
||||
val photo = AvatarBundler.extractPhoto(bundle)
|
||||
viewModel.onAvatarEditCompleted(photo)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
ViewUtil.hideKeyboard(requireContext(), requireView())
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_CODE_SELECT_IMAGE && resultCode == Activity.RESULT_OK && data != null) {
|
||||
val media: Media = Objects.requireNonNull(data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA))
|
||||
viewModel.onAvatarPhotoSelectionCompleted(media)
|
||||
} else {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAvatarClick(avatar: Avatar, isSelected: Boolean) {
|
||||
if (isSelected) {
|
||||
openEditor(avatar)
|
||||
} else {
|
||||
viewModel.onAvatarSelectedFromGrid(avatar)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAvatarLongClick(anchorView: View, avatar: Avatar): Boolean {
|
||||
val menuRes = when (avatar) {
|
||||
is Avatar.Photo -> R.menu.avatar_picker_context
|
||||
is Avatar.Text -> R.menu.avatar_picker_context
|
||||
is Avatar.Vector -> return 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package org.thoughtcrime.securesms.avatar.text
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.constraintlayout.widget.ConstraintSet
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.TransitionManager
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import org.signal.core.util.EditTextUtil
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarBundler
|
||||
import org.thoughtcrime.securesms.avatar.AvatarColorItem
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
import org.thoughtcrime.securesms.avatar.picker.AvatarPickerItem
|
||||
import org.thoughtcrime.securesms.components.BoldSelectionTabItem
|
||||
import org.thoughtcrime.securesms.components.ControllableTabLayout
|
||||
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* Fragment to create an avatar based off of a Vector or Text (via a pager)
|
||||
*/
|
||||
class TextAvatarCreationFragment : Fragment(R.layout.text_avatar_creation_fragment) {
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class OnTabSelectedListener : TabLayout.OnTabSelectedListener {
|
||||
override fun onTabSelected(tab: TabLayout.Tab) {
|
||||
when (tab.position) {
|
||||
0 -> {
|
||||
textInput.isEnabled = true
|
||||
ViewUtil.focusAndShowKeyboard(textInput)
|
||||
|
||||
TransitionManager.endTransitions(content)
|
||||
withoutRecyclerSet.applyTo(content)
|
||||
TransitionManager.beginDelayedTransition(content)
|
||||
textInput.setSelection(textInput.length())
|
||||
}
|
||||
1 -> {
|
||||
textInput.isEnabled = false
|
||||
ViewUtil.hideKeyboard(requireContext(), textInput)
|
||||
|
||||
TransitionManager.endTransitions(content)
|
||||
withRecyclerSet.applyTo(content)
|
||||
TransitionManager.beginDelayedTransition(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -88,7 +88,6 @@ import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
@@ -1123,6 +1122,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
|
||||
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread, @NonNull ChatColors chatColors) {
|
||||
boolean startOfCluster = isStartOfMessageCluster(current, previous, isGroupThread);
|
||||
if (current.isMms() && !current.isMmsNotification() && ((MediaMmsMessageRecord)current).getQuote() != null) {
|
||||
if (quoteView == null) {
|
||||
throw new AssertionError();
|
||||
@@ -1144,7 +1144,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
quoteView.setOnLongClickListener(passthroughClickListener);
|
||||
|
||||
if (isStartOfMessageCluster(current, previous, isGroupThread)) {
|
||||
if (startOfCluster) {
|
||||
if (current.isOutgoing()) {
|
||||
quoteView.setTopCornerSizes(true, true);
|
||||
} else if (isGroupThread) {
|
||||
@@ -1162,6 +1162,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
if (mediaThumbnailStub.resolved()) {
|
||||
ViewUtil.setTopMargin(mediaThumbnailStub.get(), readDimen(R.dimen.message_bubble_top_padding));
|
||||
} else if (startOfCluster && !current.isOutgoing() && groupThread) {
|
||||
ViewUtil.setTopMargin(quoteView, readDimen(R.dimen.message_bubble_quote_negative_margin));
|
||||
} else {
|
||||
ViewUtil.setTopMargin(quoteView, readDimen(R.dimen.message_bubble_top_padding));
|
||||
}
|
||||
|
||||
if (linkPreviewStub.resolved() && !hasBigImageLinkPreview(current)) {
|
||||
@@ -1175,10 +1179,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (mediaThumbnailStub.resolved()) {
|
||||
ViewUtil.setTopMargin(mediaThumbnailStub.get(), 0);
|
||||
}
|
||||
|
||||
if (linkPreviewStub.resolved()) {
|
||||
ViewUtil.setTopMargin(linkPreviewStub.get(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 $TABLE_NAME ($KEEP_LONGER)")
|
||||
db.execSQL("CREATE INDEX log_created_at_keep_longer_index ON $TABLE_NAME ($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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 + ", ' ', '')";
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import net.sqlcipher.database.SQLiteDatabase
|
||||
class SqlCipherLibraryLoader {
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var loaded = false
|
||||
private val LOCK = Object()
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package org.thoughtcrime.securesms.database.model
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
import org.thoughtcrime.securesms.database.Database
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.CustomAvatar
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.util.SqlUtil
|
||||
|
||||
/**
|
||||
* Database which manages the record keeping for custom created avatars.
|
||||
*/
|
||||
class AvatarPickerDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Database(context, databaseHelper) {
|
||||
|
||||
companion object {
|
||||
private const val TABLE_NAME = "avatar_picker"
|
||||
private const val ID = "_id"
|
||||
private const val LAST_USED = "last_used"
|
||||
private const val GROUP_ID = "group_id"
|
||||
private const val AVATAR = "avatar"
|
||||
|
||||
//language=sql
|
||||
@JvmField
|
||||
val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$LAST_USED INTEGER DEFAULT 0,
|
||||
$GROUP_ID TEXT DEFAULT NULL,
|
||||
$AVATAR BLOB NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
fun saveAvatarForSelf(avatar: Avatar): Avatar {
|
||||
return saveAvatar(avatar, null)
|
||||
}
|
||||
|
||||
fun saveAvatarForGroup(avatar: Avatar, groupId: GroupId): Avatar {
|
||||
return saveAvatar(avatar, groupId)
|
||||
}
|
||||
|
||||
fun markUsage(avatar: Avatar) {
|
||||
val databaseId = avatar.databaseId
|
||||
if (databaseId !is Avatar.DatabaseId.Saved) {
|
||||
throw IllegalArgumentException("Must save this avatar before trying to mark usage.")
|
||||
}
|
||||
|
||||
val db = databaseHelper.writableDatabase
|
||||
val where = ID_WHERE
|
||||
val args = SqlUtil.buildArgs(databaseId.id)
|
||||
val values = ContentValues(1)
|
||||
|
||||
values.put(LAST_USED, System.currentTimeMillis())
|
||||
db.update(TABLE_NAME, values, where, args)
|
||||
}
|
||||
|
||||
fun update(avatar: Avatar) {
|
||||
val databaseId = avatar.databaseId
|
||||
if (databaseId !is Avatar.DatabaseId.Saved) {
|
||||
throw IllegalArgumentException("Cannot update an unsaved avatar")
|
||||
}
|
||||
|
||||
val db = databaseHelper.writableDatabase
|
||||
val where = ID_WHERE
|
||||
val values = ContentValues(1)
|
||||
|
||||
values.put(AVATAR, avatar.toProto().toByteArray())
|
||||
db.update(TABLE_NAME, values, where, SqlUtil.buildArgs(databaseId.id))
|
||||
}
|
||||
|
||||
fun deleteAvatar(avatar: Avatar) {
|
||||
val databaseId = avatar.databaseId
|
||||
if (databaseId !is Avatar.DatabaseId.Saved) {
|
||||
throw IllegalArgumentException("Cannot delete an unsaved avatar.")
|
||||
}
|
||||
|
||||
val db = databaseHelper.writableDatabase
|
||||
val where = ID_WHERE
|
||||
val args = SqlUtil.buildArgs(databaseId.id)
|
||||
|
||||
db.delete(TABLE_NAME, where, args)
|
||||
}
|
||||
|
||||
private fun saveAvatar(avatar: Avatar, groupId: GroupId?): Avatar {
|
||||
val db = databaseHelper.writableDatabase
|
||||
val databaseId = avatar.databaseId
|
||||
|
||||
if (databaseId is Avatar.DatabaseId.DoNotPersist) {
|
||||
throw IllegalArgumentException("Cannot persist this avatar")
|
||||
}
|
||||
|
||||
if (databaseId is Avatar.DatabaseId.Saved) {
|
||||
val values = ContentValues(2)
|
||||
values.put(AVATAR, avatar.toProto().toByteArray())
|
||||
|
||||
db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(databaseId.id))
|
||||
|
||||
return avatar
|
||||
} else {
|
||||
val values = ContentValues(4)
|
||||
values.put(AVATAR, avatar.toProto().toByteArray())
|
||||
|
||||
if (groupId != null) {
|
||||
values.put(GROUP_ID, groupId.toString())
|
||||
}
|
||||
|
||||
val id = db.insert(TABLE_NAME, null, values)
|
||||
if (id == -1L) {
|
||||
throw AssertionError("Failed to save avatar")
|
||||
}
|
||||
|
||||
return avatar.withDatabaseId(Avatar.DatabaseId.Saved(id))
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllAvatars(): List<Avatar> {
|
||||
val db = databaseHelper.readableDatabase
|
||||
val results = mutableListOf<Avatar>()
|
||||
|
||||
db.query(TABLE_NAME, SqlUtil.buildArgs(ID, AVATAR), null, null, null, null, null)?.use {
|
||||
while (it.moveToNext()) {
|
||||
val id = CursorUtil.requireLong(it, ID)
|
||||
val blob = CursorUtil.requireBlob(it, AVATAR)
|
||||
val proto = CustomAvatar.parseFrom(blob)
|
||||
results.add(proto.toAvatar(id))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
fun getAvatarsForSelf(): List<Avatar> {
|
||||
return getAvatars(null)
|
||||
}
|
||||
|
||||
fun getAvatarsForGroup(groupId: GroupId): List<Avatar> {
|
||||
return getAvatars(groupId)
|
||||
}
|
||||
|
||||
private fun getAvatars(groupId: GroupId?): List<Avatar> {
|
||||
val db = databaseHelper.readableDatabase
|
||||
val orderBy = "$LAST_USED DESC"
|
||||
val results = mutableListOf<Avatar>()
|
||||
|
||||
val (where, args) = if (groupId == null) {
|
||||
Pair("$GROUP_ID is NULL", null)
|
||||
} else {
|
||||
Pair("$GROUP_ID = ?", SqlUtil.buildArgs(groupId))
|
||||
}
|
||||
|
||||
db.query(TABLE_NAME, SqlUtil.buildArgs(ID, AVATAR), where, args, null, null, orderBy)?.use {
|
||||
while (it.moveToNext()) {
|
||||
val id = CursorUtil.requireLong(it, ID)
|
||||
val blob = CursorUtil.requireBlob(it, AVATAR)
|
||||
val proto = CustomAvatar.parseFrom(blob)
|
||||
results.add(proto.toAvatar(id))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private fun Avatar.toProto(): CustomAvatar {
|
||||
return when (this) {
|
||||
is Avatar.Photo -> CustomAvatar.newBuilder().setPhoto(CustomAvatar.Photo.newBuilder().setUri(this.uri.toString())).build()
|
||||
is Avatar.Text -> CustomAvatar.newBuilder().setText(CustomAvatar.Text.newBuilder().setText(this.text).setColors(this.color.code)).build()
|
||||
is Avatar.Vector -> CustomAvatar.newBuilder().setVector(CustomAvatar.Vector.newBuilder().setKey(this.key).setColors(this.color.code)).build()
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
}
|
||||
|
||||
private fun CustomAvatar.toAvatar(id: Long): Avatar {
|
||||
return when {
|
||||
hasPhoto() -> Avatar.Photo(Uri.parse(photo.uri), photo.size, Avatar.DatabaseId.Saved(id))
|
||||
hasText() -> Avatar.Text(text.text, Avatars.colorMap[text.colors] ?: Avatars.colors[0], Avatar.DatabaseId.Saved(id))
|
||||
hasVector() -> Avatar.Vector(vector.key, Avatars.colorMap[vector.colors] ?: Avatars.colors[0], Avatar.DatabaseId.Saved(id))
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.database.model
|
||||
|
||||
data class LogEntry(
|
||||
val createdAt: Long,
|
||||
val keepLonger: Boolean,
|
||||
val body: String
|
||||
)
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
|
||||
import org.whispersystems.signalservice.api.push.DistributionId;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.InvalidUnidentifiedAccessHeaderException;
|
||||
import org.whispersystems.signalservice.internal.push.http.CancelationSignal;
|
||||
import org.whispersystems.signalservice.internal.push.http.PartialSendCompleteListener;
|
||||
|
||||
@@ -206,6 +207,9 @@ public final class GroupSendUtil {
|
||||
if (sendOperation.shouldIncludeInMessageLog()) {
|
||||
DatabaseFactory.getMessageLogDatabase(context).insertIfPossible(sendOperation.getSentTimestamp(), senderKeyTargets, results, sendOperation.getContentHint(), sendOperation.getRelatedMessageId());
|
||||
}
|
||||
} catch (InvalidUnidentifiedAccessHeaderException e) {
|
||||
Log.w(TAG, "Someone had a bad UD header. Falling back to legacy sends.", e);
|
||||
legacyTargets.addAll(senderKeyTargets);
|
||||
} catch (NoSessionException e) {
|
||||
Log.w(TAG, "No session. Falling back to legacy sends.", e);
|
||||
legacyTargets.addAll(senderKeyTargets);
|
||||
|
||||
@@ -78,9 +78,10 @@ public class ApplicationMigrations {
|
||||
static final int SENDER_KEY_2 = 36;
|
||||
static final int DB_AUTOINCREMENT = 37;
|
||||
static final int ATTACHMENT_CLEANUP = 38;
|
||||
static final int LOG_CLEANUP = 39;
|
||||
}
|
||||
|
||||
public static final int CURRENT_VERSION = 38;
|
||||
public static final int CURRENT_VERSION = 39;
|
||||
|
||||
/**
|
||||
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
|
||||
@@ -342,6 +343,10 @@ public class ApplicationMigrations {
|
||||
jobs.put(Version.ATTACHMENT_CLEANUP, new AttachmentCleanupMigrationJob());
|
||||
}
|
||||
|
||||
if (lastSeenVersion < Version.LOG_CLEANUP) {
|
||||
jobs.put(Version.LOG_CLEANUP, new DeleteDeprecatedLogsMigrationJob());
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.thoughtcrime.securesms.migrations;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* We moved from storing logs in encrypted files to just storing them in an encrypted database. So we need to delete the leftover files.
|
||||
*/
|
||||
public class DeleteDeprecatedLogsMigrationJob extends MigrationJob {
|
||||
|
||||
private static final String TAG = Log.tag(DeleteDeprecatedLogsMigrationJob.class);
|
||||
|
||||
public static final String KEY = "DeleteDeprecatedLogsMigrationJob";
|
||||
|
||||
public DeleteDeprecatedLogsMigrationJob() {
|
||||
this(new Parameters.Builder().build());
|
||||
}
|
||||
|
||||
private DeleteDeprecatedLogsMigrationJob(@NonNull Parameters parameters) {
|
||||
super(parameters);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean isUiBlocking() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
void performMigration() {
|
||||
File logDir = new File(context.getCacheDir(), "log");
|
||||
if (logDir.exists()) {
|
||||
File[] files = logDir.listFiles();
|
||||
|
||||
int count = 0;
|
||||
if (files != null) {
|
||||
for (File f : files) {
|
||||
count += f.delete() ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!logDir.delete()) {
|
||||
Log.w(TAG, "Failed to delete log directory.");
|
||||
}
|
||||
|
||||
Log.i(TAG, "Deleted " + count + " log files.");
|
||||
} else {
|
||||
Log.w(TAG, "Log directory does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean shouldRetry(@NonNull Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static final class Factory implements Job.Factory<DeleteDeprecatedLogsMigrationJob> {
|
||||
@Override
|
||||
public @NonNull DeleteDeprecatedLogsMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new DeleteDeprecatedLogsMigrationJob(parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.apache.http.auth.AUTH;
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
||||
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiFiles;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
@@ -24,22 +24,25 @@ import java.io.InputStream;
|
||||
|
||||
public class PartAuthority {
|
||||
|
||||
private static final String AUTHORITY = BuildConfig.APPLICATION_ID;
|
||||
private static final String PART_URI_STRING = "content://" + AUTHORITY + "/part";
|
||||
private static final String STICKER_URI_STRING = "content://" + AUTHORITY + "/sticker";
|
||||
private static final String WALLPAPER_URI_STRING = "content://" + AUTHORITY + "/wallpaper";
|
||||
private static final String EMOJI_URI_STRING = "content://" + AUTHORITY + "/emoji";
|
||||
private static final Uri PART_CONTENT_URI = Uri.parse(PART_URI_STRING);
|
||||
private static final Uri STICKER_CONTENT_URI = Uri.parse(STICKER_URI_STRING);
|
||||
private static final Uri WALLPAPER_CONTENT_URI = Uri.parse(WALLPAPER_URI_STRING);
|
||||
private static final Uri EMOJI_CONTENT_URI = Uri.parse(EMOJI_URI_STRING);
|
||||
private static final String AUTHORITY = BuildConfig.APPLICATION_ID;
|
||||
private static final String PART_URI_STRING = "content://" + AUTHORITY + "/part";
|
||||
private static final String STICKER_URI_STRING = "content://" + AUTHORITY + "/sticker";
|
||||
private static final String WALLPAPER_URI_STRING = "content://" + AUTHORITY + "/wallpaper";
|
||||
private static final String EMOJI_URI_STRING = "content://" + AUTHORITY + "/emoji";
|
||||
private static final String AVATAR_PICKER_URI_STRING = "content://" + AUTHORITY + "/avatar_picker";
|
||||
private static final Uri PART_CONTENT_URI = Uri.parse(PART_URI_STRING);
|
||||
private static final Uri STICKER_CONTENT_URI = Uri.parse(STICKER_URI_STRING);
|
||||
private static final Uri WALLPAPER_CONTENT_URI = Uri.parse(WALLPAPER_URI_STRING);
|
||||
private static final Uri EMOJI_CONTENT_URI = Uri.parse(EMOJI_URI_STRING);
|
||||
private static final Uri AVATAR_PICKER_CONTENT_URI = Uri.parse(AVATAR_PICKER_URI_STRING);
|
||||
|
||||
private static final int PART_ROW = 1;
|
||||
private static final int PERSISTENT_ROW = 2;
|
||||
private static final int BLOB_ROW = 3;
|
||||
private static final int STICKER_ROW = 4;
|
||||
private static final int WALLPAPER_ROW = 5;
|
||||
private static final int EMOJI_ROW = 6;
|
||||
private static final int PART_ROW = 1;
|
||||
private static final int PERSISTENT_ROW = 2;
|
||||
private static final int BLOB_ROW = 3;
|
||||
private static final int STICKER_ROW = 4;
|
||||
private static final int WALLPAPER_ROW = 5;
|
||||
private static final int EMOJI_ROW = 6;
|
||||
private static final int AVATAR_PICKER_ROW = 7;
|
||||
|
||||
private static final UriMatcher uriMatcher;
|
||||
|
||||
@@ -49,6 +52,7 @@ public class PartAuthority {
|
||||
uriMatcher.addURI(AUTHORITY, "sticker/#", STICKER_ROW);
|
||||
uriMatcher.addURI(AUTHORITY, "wallpaper/*", WALLPAPER_ROW);
|
||||
uriMatcher.addURI(AUTHORITY, "emoji/*", EMOJI_ROW);
|
||||
uriMatcher.addURI(AUTHORITY, "avatar_picker/*", AVATAR_PICKER_ROW);
|
||||
uriMatcher.addURI(DeprecatedPersistentBlobProvider.AUTHORITY, DeprecatedPersistentBlobProvider.EXPECTED_PATH_OLD, PERSISTENT_ROW);
|
||||
uriMatcher.addURI(DeprecatedPersistentBlobProvider.AUTHORITY, DeprecatedPersistentBlobProvider.EXPECTED_PATH_NEW, PERSISTENT_ROW);
|
||||
uriMatcher.addURI(BlobProvider.AUTHORITY, BlobProvider.PATH, BLOB_ROW);
|
||||
@@ -66,13 +70,14 @@ public class PartAuthority {
|
||||
int match = uriMatcher.match(uri);
|
||||
try {
|
||||
switch (match) {
|
||||
case PART_ROW: return DatabaseFactory.getAttachmentDatabase(context).getAttachmentStream(new PartUriParser(uri).getPartId(), 0);
|
||||
case STICKER_ROW: return DatabaseFactory.getStickerDatabase(context).getStickerStream(ContentUris.parseId(uri));
|
||||
case PERSISTENT_ROW: return DeprecatedPersistentBlobProvider.getInstance(context).getStream(context, ContentUris.parseId(uri));
|
||||
case BLOB_ROW: return BlobProvider.getInstance().getStream(context, uri);
|
||||
case WALLPAPER_ROW: return WallpaperStorage.read(context, getWallpaperFilename(uri));
|
||||
case EMOJI_ROW: return EmojiFiles.openForReading(context, getEmojiFilename(uri));
|
||||
default: return context.getContentResolver().openInputStream(uri);
|
||||
case PART_ROW: return DatabaseFactory.getAttachmentDatabase(context).getAttachmentStream(new PartUriParser(uri).getPartId(), 0);
|
||||
case STICKER_ROW: return DatabaseFactory.getStickerDatabase(context).getStickerStream(ContentUris.parseId(uri));
|
||||
case PERSISTENT_ROW: return DeprecatedPersistentBlobProvider.getInstance(context).getStream(context, ContentUris.parseId(uri));
|
||||
case BLOB_ROW: return BlobProvider.getInstance().getStream(context, uri);
|
||||
case WALLPAPER_ROW: return WallpaperStorage.read(context, getWallpaperFilename(uri));
|
||||
case EMOJI_ROW: return EmojiFiles.openForReading(context, getEmojiFilename(uri));
|
||||
case AVATAR_PICKER_ROW: return AvatarPickerStorage.read(context, getAvatarPickerFilename(uri));
|
||||
default: return context.getContentResolver().openInputStream(uri);
|
||||
}
|
||||
} catch (SecurityException se) {
|
||||
throw new IOException(se);
|
||||
@@ -169,6 +174,10 @@ public class PartAuthority {
|
||||
return Uri.withAppendedPath(WALLPAPER_CONTENT_URI, filename);
|
||||
}
|
||||
|
||||
public static Uri getAvatarPickerUri(String filename) {
|
||||
return Uri.withAppendedPath(AVATAR_PICKER_CONTENT_URI, filename);
|
||||
}
|
||||
|
||||
public static Uri getEmojiUri(String sprite) {
|
||||
return Uri.withAppendedPath(EMOJI_CONTENT_URI, sprite);
|
||||
}
|
||||
@@ -181,6 +190,10 @@ public class PartAuthority {
|
||||
return uri.getPathSegments().get(1);
|
||||
}
|
||||
|
||||
public static String getAvatarPickerFilename(Uri uri) {
|
||||
return uri.getPathSegments().get(1);
|
||||
}
|
||||
|
||||
public static boolean isLocalUri(final @NonNull Uri uri) {
|
||||
int match = uriMatcher.match(uri);
|
||||
switch (match) {
|
||||
|
||||
@@ -52,7 +52,7 @@ data class NotificationConversation(
|
||||
return if (SignalStore.settings().messageNotificationsPrivacy.isDisplayContact) {
|
||||
recipient.getContactDrawable(context)
|
||||
} else {
|
||||
GeneratedContactPhoto("Unknown", R.drawable.ic_profile_outline_40).asDrawable(context, AvatarColor.UNKNOWN.colorInt())
|
||||
GeneratedContactPhoto("Unknown", R.drawable.ic_profile_outline_40).asDrawable(context, AvatarColor.UNKNOWN)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,12 +56,12 @@ fun Recipient.getContactDrawable(context: Context): Drawable? {
|
||||
)
|
||||
.get()
|
||||
} catch (e: InterruptedException) {
|
||||
fallbackContactPhoto.asDrawable(context, avatarColor.colorInt())
|
||||
fallbackContactPhoto.asDrawable(context, avatarColor)
|
||||
} catch (e: ExecutionException) {
|
||||
fallbackContactPhoto.asDrawable(context, avatarColor.colorInt())
|
||||
fallbackContactPhoto.asDrawable(context, avatarColor)
|
||||
}
|
||||
} else {
|
||||
fallbackContactPhoto.asDrawable(context, avatarColor.colorInt())
|
||||
fallbackContactPhoto.asDrawable(context, avatarColor)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,10 +39,8 @@ final class MobileCoinLogAdapter implements LogAdapter {
|
||||
Log.w(tag, message, throwable);
|
||||
break;
|
||||
case ERROR:
|
||||
Log.e(tag, message, throwable);
|
||||
break;
|
||||
case WTF:
|
||||
Log.wtf(tag, message, throwable);
|
||||
Log.e(tag, message, throwable);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import android.view.Display;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import androidx.activity.result.ActivityResultCallback;
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.core.util.Consumer;
|
||||
|
||||
import org.signal.core.util.StreamUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeException;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
@@ -34,6 +35,11 @@ class EditGroupProfileRepository implements EditProfileRepository {
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void getCurrentAvatarColor(@NonNull Consumer<AvatarColor> avatarColorConsumer) {
|
||||
SimpleTask.run(() -> Recipient.resolved(getRecipientId()).getAvatarColor(), avatarColorConsumer::accept);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void getCurrentProfileName(@NonNull Consumer<ProfileName> profileNameConsumer) {
|
||||
profileNameConsumer.accept(ProfileName.EMPTY);
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.annotation.NonNull;
|
||||
import androidx.navigation.NavDirections;
|
||||
import androidx.navigation.NavGraph;
|
||||
import androidx.navigation.Navigation;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
|
||||
import org.thoughtcrime.securesms.BaseActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -61,10 +62,10 @@ public class EditProfileActivity extends BaseActivity implements EditProfileFrag
|
||||
setContentView(R.layout.profile_create_activity);
|
||||
|
||||
if (bundle == null) {
|
||||
Bundle extras = getIntent().getExtras();
|
||||
NavGraph graph = Navigation.findNavController(this, R.id.nav_host_fragment).getGraph();
|
||||
|
||||
Navigation.findNavController(this, R.id.nav_host_fragment).setGraph(graph, extras != null ? extras : new Bundle());
|
||||
NavHostFragment fragment = NavHostFragment.create(R.navigation.edit_profile, getIntent().getExtras());
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.add(R.id.fragment_container, fragment)
|
||||
.commit();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
import android.text.InputType;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
@@ -20,7 +21,9 @@ import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import com.airbnb.lottie.SimpleColorFilter;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.dd.CircularProgressButton;
|
||||
|
||||
@@ -29,11 +32,12 @@ import org.signal.core.util.StreamUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.avatar.Avatars;
|
||||
import org.thoughtcrime.securesms.avatar.picker.AvatarPickerFragment;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity;
|
||||
import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFragment;
|
||||
import org.thoughtcrime.securesms.groups.ParcelableGroupId;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.profiles.manage.EditProfileNameFragment;
|
||||
@@ -47,7 +51,6 @@ import org.thoughtcrime.securesms.util.views.LearnMoreTextView;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
import static android.app.Activity.RESULT_OK;
|
||||
import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.EXCLUDE_SYSTEM;
|
||||
import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.GROUP_ID;
|
||||
import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.NEXT_BUTTON_TEXT;
|
||||
@@ -57,7 +60,6 @@ import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.SHOW_
|
||||
public class EditProfileFragment extends LoggingFragment {
|
||||
|
||||
private static final String TAG = Log.tag(EditProfileFragment.class);
|
||||
private static final short REQUEST_CODE_SELECT_AVATAR = 31726;
|
||||
private static final int MAX_DESCRIPTION_GLYPHS = 480;
|
||||
private static final int MAX_DESCRIPTION_BYTES = 8192;
|
||||
|
||||
@@ -69,6 +71,8 @@ public class EditProfileFragment extends LoggingFragment {
|
||||
private EditText familyName;
|
||||
private View reveal;
|
||||
private TextView preview;
|
||||
private ImageView avatarPreviewBackground;
|
||||
private ImageView avatarPreview;
|
||||
|
||||
private Intent nextIntent;
|
||||
|
||||
@@ -100,45 +104,44 @@ public class EditProfileFragment extends LoggingFragment {
|
||||
initializeResources(view, groupId);
|
||||
initializeProfileAvatar();
|
||||
initializeProfileName();
|
||||
|
||||
getParentFragmentManager().setFragmentResultListener(AvatarPickerFragment.REQUEST_KEY_SELECT_AVATAR, getViewLifecycleOwner(), (key, bundle) -> {
|
||||
if (bundle.getBoolean(AvatarPickerFragment.SELECT_AVATAR_CLEAR)) {
|
||||
viewModel.setAvatarMedia(null);
|
||||
viewModel.setAvatar(null);
|
||||
avatar.setImageDrawable(null);
|
||||
} else {
|
||||
Media media = bundle.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA);
|
||||
handleMediaFromResult(media);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
private void handleMediaFromResult(@NonNull Media media) {
|
||||
SimpleTask.run(() -> {
|
||||
try {
|
||||
InputStream stream = BlobProvider.getInstance().getStream(requireContext(), media.getUri());
|
||||
|
||||
if (requestCode == REQUEST_CODE_SELECT_AVATAR && resultCode == RESULT_OK) {
|
||||
|
||||
if (data != null && data.getBooleanExtra("delete", false)) {
|
||||
viewModel.setAvatar(null);
|
||||
avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_camera_solid_white_24).asDrawable(requireActivity(), AvatarColor.UNKNOWN.colorInt()));
|
||||
return;
|
||||
return StreamUtil.readFully(stream);
|
||||
} catch (IOException ioException) {
|
||||
Log.w(TAG, ioException);
|
||||
return null;
|
||||
}
|
||||
|
||||
SimpleTask.run(() -> {
|
||||
try {
|
||||
Media result = data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA);
|
||||
InputStream stream = BlobProvider.getInstance().getStream(requireContext(), result.getUri());
|
||||
|
||||
return StreamUtil.readFully(stream);
|
||||
} catch (IOException ioException) {
|
||||
Log.w(TAG, ioException);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
(avatarBytes) -> {
|
||||
if (avatarBytes != null) {
|
||||
viewModel.setAvatar(avatarBytes);
|
||||
GlideApp.with(EditProfileFragment.this)
|
||||
.load(avatarBytes)
|
||||
.skipMemoryCache(true)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.circleCrop()
|
||||
.into(avatar);
|
||||
} else {
|
||||
Toast.makeText(requireActivity(), R.string.CreateProfileActivity_error_setting_profile_photo, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
(avatarBytes) -> {
|
||||
if (avatarBytes != null) {
|
||||
viewModel.setAvatarMedia(media);
|
||||
viewModel.setAvatar(avatarBytes);
|
||||
GlideApp.with(EditProfileFragment.this)
|
||||
.load(avatarBytes)
|
||||
.skipMemoryCache(true)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.circleCrop()
|
||||
.into(avatar);
|
||||
} else {
|
||||
Toast.makeText(requireActivity(), R.string.CreateProfileActivity_error_setting_profile_photo, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeViewModel(boolean excludeSystem, @Nullable GroupId groupId, boolean hasSavedInstanceState) {
|
||||
@@ -160,15 +163,17 @@ public class EditProfileFragment extends LoggingFragment {
|
||||
Bundle arguments = requireArguments();
|
||||
boolean isEditingGroup = groupId != null;
|
||||
|
||||
this.toolbar = view.findViewById(R.id.toolbar);
|
||||
this.title = view.findViewById(R.id.title);
|
||||
this.avatar = view.findViewById(R.id.avatar);
|
||||
this.givenName = view.findViewById(R.id.given_name);
|
||||
this.familyName = view.findViewById(R.id.family_name);
|
||||
this.finishButton = view.findViewById(R.id.finish_button);
|
||||
this.reveal = view.findViewById(R.id.reveal);
|
||||
this.preview = view.findViewById(R.id.name_preview);
|
||||
this.nextIntent = arguments.getParcelable(NEXT_INTENT);
|
||||
this.toolbar = view.findViewById(R.id.toolbar);
|
||||
this.title = view.findViewById(R.id.title);
|
||||
this.avatar = view.findViewById(R.id.avatar);
|
||||
this.givenName = view.findViewById(R.id.given_name);
|
||||
this.familyName = view.findViewById(R.id.family_name);
|
||||
this.finishButton = view.findViewById(R.id.finish_button);
|
||||
this.reveal = view.findViewById(R.id.reveal);
|
||||
this.preview = view.findViewById(R.id.name_preview);
|
||||
this.avatarPreviewBackground = view.findViewById(R.id.avatar_background);
|
||||
this.avatarPreview = view.findViewById(R.id.avatar_placeholder);
|
||||
this.nextIntent = arguments.getParcelable(NEXT_INTENT);
|
||||
|
||||
this.avatar.setOnClickListener(v -> startAvatarSelection());
|
||||
|
||||
@@ -248,13 +253,23 @@ public class EditProfileFragment extends LoggingFragment {
|
||||
|
||||
private void initializeProfileAvatar() {
|
||||
viewModel.avatar().observe(getViewLifecycleOwner(), bytes -> {
|
||||
if (bytes == null) return;
|
||||
if (bytes == null) {
|
||||
GlideApp.with(this).clear(avatar);
|
||||
return;
|
||||
}
|
||||
|
||||
GlideApp.with(this)
|
||||
.load(bytes)
|
||||
.circleCrop()
|
||||
.into(avatar);
|
||||
});
|
||||
|
||||
viewModel.avatarColor().observe(getViewLifecycleOwner(), avatarColor -> {
|
||||
Avatars.ForegroundColor foregroundColor = Avatars.getForegroundColor(avatarColor);
|
||||
|
||||
avatarPreview.getDrawable().setColorFilter(new SimpleColorFilter(foregroundColor.getColorInt()));
|
||||
avatarPreviewBackground.getDrawable().setColorFilter(new SimpleColorFilter(avatarColor.colorInt()));
|
||||
});
|
||||
}
|
||||
|
||||
private static void updateFieldIfNeeded(@NonNull EditText field, @NonNull String value) {
|
||||
@@ -273,11 +288,12 @@ public class EditProfileFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
private void startAvatarSelection() {
|
||||
AvatarSelectionBottomSheetDialogFragment.create(viewModel.canRemoveProfilePhoto(),
|
||||
true,
|
||||
REQUEST_CODE_SELECT_AVATAR,
|
||||
viewModel.isGroup())
|
||||
.show(getChildFragmentManager(), null);
|
||||
if (viewModel.isGroup()) {
|
||||
Parcelable groupId = ParcelableGroupId.from(viewModel.getGroupId());
|
||||
Navigation.findNavController(requireView()).navigate(EditProfileFragmentDirections.actionCreateProfileFragmentToAvatarPicker((ParcelableGroupId) groupId, viewModel.getAvatarMedia()));
|
||||
} else {
|
||||
Navigation.findNavController(requireView()).navigate(EditProfileFragmentDirections.actionCreateProfileFragmentToAvatarPicker(null, null));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleUpload() {
|
||||
|
||||
@@ -4,11 +4,14 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
interface EditProfileRepository {
|
||||
|
||||
void getCurrentAvatarColor(@NonNull Consumer<AvatarColor> avatarColorConsumer);
|
||||
|
||||
void getCurrentProfileName(@NonNull Consumer<ProfileName> profileNameConsumer);
|
||||
|
||||
void getCurrentAvatar(@NonNull Consumer<byte[]> avatarConsumer);
|
||||
|
||||
@@ -8,7 +8,9 @@ import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName;
|
||||
import org.thoughtcrime.securesms.profiles.edit.EditProfileRepository.UploadResult;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
@@ -29,10 +31,12 @@ class EditProfileViewModel extends ViewModel {
|
||||
private final MutableLiveData<byte[]> originalAvatar = new MutableLiveData<>();
|
||||
private final MutableLiveData<String> originalDisplayName = new MutableLiveData<>();
|
||||
private final SingleLiveEvent<UploadResult> uploadResult = new SingleLiveEvent<>();
|
||||
private final MutableLiveData<AvatarColor> avatarColor = new MutableLiveData<>();
|
||||
private final LiveData<Boolean> isFormValid;
|
||||
private final EditProfileRepository repository;
|
||||
private final GroupId groupId;
|
||||
private String originalDescription;
|
||||
private Media avatarMedia;
|
||||
|
||||
private EditProfileViewModel(@NonNull EditProfileRepository repository, boolean hasInstanceState, @Nullable GroupId groupId) {
|
||||
this.repository = repository;
|
||||
@@ -59,9 +63,15 @@ class EditProfileViewModel extends ViewModel {
|
||||
internalAvatar.setValue(value);
|
||||
originalAvatar.setValue(value);
|
||||
});
|
||||
|
||||
repository.getCurrentAvatarColor(avatarColor::setValue);
|
||||
}
|
||||
}
|
||||
|
||||
public LiveData<AvatarColor> avatarColor() {
|
||||
return Transformations.distinctUntilChanged(avatarColor);
|
||||
}
|
||||
|
||||
public LiveData<String> givenName() {
|
||||
return Transformations.distinctUntilChanged(givenName);
|
||||
}
|
||||
@@ -90,6 +100,18 @@ class EditProfileViewModel extends ViewModel {
|
||||
return groupId != null;
|
||||
}
|
||||
|
||||
public @Nullable Media getAvatarMedia() {
|
||||
return avatarMedia;
|
||||
}
|
||||
|
||||
public void setAvatarMedia(@Nullable Media avatarMedia) {
|
||||
this.avatarMedia = avatarMedia;
|
||||
}
|
||||
|
||||
public @Nullable GroupId getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
|
||||
public boolean canRemoveProfilePhoto() {
|
||||
return hasAvatar();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user