recipientId, String nu
SimpleTask.run(getLifecycle(), () -> {
Recipient resolved = Recipient.external(this, number);
- if (!resolved.isRegistered() || !resolved.hasUuid()) {
+ if (!resolved.isRegistered() || !resolved.hasServiceId()) {
Log.i(TAG, "[onContactSelected] Not registered or no UUID. Doing a directory refresh.");
try {
- DirectoryHelper.refreshDirectoryFor(this, resolved, false);
+ ContactDiscovery.refresh(this, resolved, false);
resolved = Recipient.resolved(resolved.getId());
} catch (IOException e) {
Log.w(TAG, "[onContactSelected] Failed to refresh directory for new contact.");
@@ -103,7 +104,7 @@ public void onSelectionChanged() {
}
private void launch(Recipient recipient) {
- long existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId());
+ long existingThread = SignalDatabase.threads().getThreadIdIfExistsFor(recipient.getId());
Intent intent = ConversationIntents.createBuilder(this, recipient.getId(), existingThread)
.withDraftText(getIntent().getStringExtra(Intent.EXTRA_TEXT))
.withDataUri(getIntent().getData())
diff --git a/app/src/main/java/org/thoughtcrime/securesms/OverlayTransformation.kt b/app/src/main/java/org/thoughtcrime/securesms/OverlayTransformation.kt
new file mode 100644
index 00000000000..d7a08547fcf
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/OverlayTransformation.kt
@@ -0,0 +1,40 @@
+package org.thoughtcrime.securesms
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import androidx.annotation.ColorInt
+import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
+import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
+import java.security.MessageDigest
+
+/**
+ * BitmapTransformation which overlays the given bitmap with the given color.
+ */
+class OverlayTransformation(
+ @ColorInt private val color: Int
+) : BitmapTransformation() {
+
+ private val id = "${OverlayTransformation::class.java.name}$color"
+
+ override fun updateDiskCacheKey(messageDigest: MessageDigest) {
+ messageDigest.update(id.toByteArray(CHARSET))
+ }
+
+ override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
+ val outBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(outBitmap)
+
+ canvas.drawBitmap(toTransform, 0f, 0f, null)
+ canvas.drawColor(color)
+
+ return outBitmap
+ }
+
+ override fun equals(other: Any?): Boolean {
+ return (other as? OverlayTransformation)?.color == color
+ }
+
+ override fun hashCode(): Int {
+ return id.hashCode()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseCreateActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseCreateActivity.java
index 18c3887b7db..c0adaa33aba 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/PassphraseCreateActivity.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseCreateActivity.java
@@ -19,9 +19,9 @@
import android.os.AsyncTask;
import android.os.Bundle;
-import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
import org.thoughtcrime.securesms.crypto.MasterSecret;
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
+import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.VersionTracker;
/**
@@ -61,7 +61,8 @@ protected Void doInBackground(String... params) {
passphrase);
MasterSecretUtil.generateAsymmetricMasterSecret(PassphraseCreateActivity.this, masterSecret);
- IdentityKeyUtil.generateIdentityKeys(PassphraseCreateActivity.this);
+ SignalStore.account().generateAciIdentityKeyIfNecessary();
+ SignalStore.account().generatePniIdentityKeyIfNecessary();
VersionTracker.updateLastSeenVersion(PassphraseCreateActivity.this);
return null;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java
index 6f3f0fe19a9..ab27c327f13 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java
@@ -35,7 +35,6 @@
import android.view.MenuItem;
import android.view.View;
import android.view.View.OnClickListener;
-import android.view.WindowManager;
import android.view.animation.Animation;
import android.view.animation.BounceInterpolator;
import android.view.animation.TranslateAnimation;
@@ -51,6 +50,7 @@
import androidx.biometric.BiometricManager.Authenticators;
import androidx.biometric.BiometricPrompt;
+import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.AnimatingToggle;
@@ -98,13 +98,16 @@ public class PassphrasePromptActivity extends PassphraseActivity {
private boolean hadFailure;
private boolean alreadyShown;
+ private final Runnable resumeScreenLockRunnable = () -> {
+ resumeScreenLock(!alreadyShown);
+ alreadyShown = true;
+ };
+
@Override
public void onCreate(Bundle savedInstanceState) {
Log.i(TAG, "onCreate()");
dynamicTheme.onCreate(this);
dynamicLanguage.onCreate(this);
- getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
- getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
super.onCreate(savedInstanceState);
setContentView(R.layout.prompt_passphrase_activity);
@@ -129,11 +132,20 @@ public void onResume() {
setLockTypeVisibility();
if (TextSecurePreferences.isScreenLockEnabled(this) && !authenticated && !hadFailure) {
- resumeScreenLock(!alreadyShown);
- alreadyShown = true;
+ ThreadUtil.postToMain(resumeScreenLockRunnable);
}
hadFailure = false;
+
+ fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
+ fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_accent_primary), PorterDuff.Mode.SRC_IN);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ ThreadUtil.cancelRunnableOnMain(resumeScreenLockRunnable);
+ biometricPrompt.cancelAuthentication();
}
@Override
@@ -388,9 +400,6 @@ public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationRes
@Override
public void onAnimationEnd(Animator animation) {
handleAuthenticated();
-
- fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
- fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN);
}
}).start();
}
@@ -412,7 +421,7 @@ public void onAnimationStart(Animation animation) {}
@Override
public void onAnimationEnd(Animation animation) {
fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
- fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN);
+ fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_accent_primary), PorterDuff.Mode.SRC_IN);
}
@Override
diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java
index eaa3f8109a6..10a8c1a2db3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java
@@ -15,6 +15,7 @@
import org.signal.core.util.logging.Log;
import org.signal.core.util.tracing.Tracer;
import org.signal.devicetransfer.TransferStatus;
+import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberLockActivity;
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity;
@@ -50,6 +51,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
private static final int STATE_CREATE_SIGNAL_PIN = 7;
private static final int STATE_TRANSFER_ONGOING = 8;
private static final int STATE_TRANSFER_LOCKED = 9;
+ private static final int STATE_CHANGE_NUMBER_LOCK = 10;
private SignalServiceNetworkAccess networkAccess;
private BroadcastReceiver clearKeyReceiver;
@@ -58,7 +60,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
protected final void onCreate(Bundle savedInstanceState) {
Tracer.getInstance().start(Log.tag(getClass()) + "#onCreate()");
AppStartup.getInstance().onCriticalRenderEventStart();
- this.networkAccess = new SignalServiceNetworkAccess(this);
+ this.networkAccess = ApplicationDependencies.getSignalServiceNetworkAccess();
onPreCreate();
final boolean locked = KeyCachingService.isLocked(this);
@@ -82,7 +84,7 @@ protected void onCreate(Bundle savedInstanceState, boolean ready) {}
protected void onResume() {
super.onResume();
- if (networkAccess.isCensored(this)) {
+ if (networkAccess.isCensored()) {
ApplicationDependencies.getJobManager().add(new PushNotificationReceiveJob());
}
}
@@ -153,6 +155,7 @@ private Intent getIntentForState(int state) {
case STATE_CREATE_PROFILE_NAME: return getCreateProfileNameIntent();
case STATE_TRANSFER_ONGOING: return getOldDeviceTransferIntent();
case STATE_TRANSFER_LOCKED: return getOldDeviceTransferLockedIntent();
+ case STATE_CHANGE_NUMBER_LOCK: return getChangeNumberLockIntent();
default: return null;
}
}
@@ -176,6 +179,8 @@ private int getApplicationState(boolean locked) {
return STATE_TRANSFER_ONGOING;
} else if (SignalStore.misc().isOldDeviceTransferLocked()) {
return STATE_TRANSFER_LOCKED;
+ } else if (SignalStore.misc().isChangeNumberLocked() && getClass() != ChangeNumberLockActivity.class) {
+ return STATE_CHANGE_NUMBER_LOCK;
} else {
return STATE_NORMAL;
}
@@ -243,6 +248,10 @@ private Intent getOldDeviceTransferIntent() {
return MainActivity.clearTop(this);
}
+ private Intent getChangeNumberLockIntent() {
+ return ChangeNumberLockActivity.createIntent(this);
+ }
+
private Intent getRoutedIntent(Class> destination, @Nullable Intent nextIntent) {
final Intent intent = new Intent(this, destination);
if (nextIntent != null) intent.putExtra("next_intent", nextIntent);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/RtcDeviceLists.java b/app/src/main/java/org/thoughtcrime/securesms/RtcDeviceLists.java
deleted file mode 100644
index e7e6bf9e156..00000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/RtcDeviceLists.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package org.thoughtcrime.securesms;
-
-import android.os.Build;
-
-import java.util.HashSet;
-import java.util.Set;
-
-/**
- * Device hardware capability lists.
- *
- * Moved outside of ApplicationContext as the indirection was important for API19 support with desugaring: https://issuetracker.google.com/issues/183419297
- */
-final class RtcDeviceLists {
-
- private RtcDeviceLists() {}
-
- static Set hardwareAECBlockList() {
- return new HashSet() {{
- add("Pixel");
- add("Pixel XL");
- add("Moto G5");
- add("Moto G (5S) Plus");
- add("Moto G4");
- add("TA-1053");
- add("Mi A1");
- add("Mi A2");
- add("E5823"); // Sony z5 compact
- add("Redmi Note 5");
- add("FP2"); // Fairphone FP2
- add("MI 5");
- }};
- }
-
- static Set openSlEsAllowList() {
- return new HashSet() {{
- add("Pixel");
- add("Pixel XL");
- }};
- }
-
- static boolean hardwareAECBlocked() {
- return hardwareAECBlockList().contains(Build.MODEL);
- }
-
- static boolean openSLESAllowed() {
- return openSlEsAllowList().contains(Build.MODEL);
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/SmsSendtoActivity.java b/app/src/main/java/org/thoughtcrime/securesms/SmsSendtoActivity.java
index 8068fa02513..b472b3a49d1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/SmsSendtoActivity.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/SmsSendtoActivity.java
@@ -12,7 +12,7 @@
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
-import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.Rfc5724Uri;
@@ -48,7 +48,7 @@ private Intent getNextIntent(Intent original) {
Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show();
} else {
Recipient recipient = Recipient.external(this, destination.getDestination());
- long threadId = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId());
+ long threadId = SignalDatabase.threads().getThreadIdIfExistsFor(recipient.getId());
nextIntent = ConversationIntents.createBuilder(this, recipient.getId(), threadId)
.withDraftText(destination.getBody())
diff --git a/app/src/main/java/org/thoughtcrime/securesms/TransportOption.java b/app/src/main/java/org/thoughtcrime/securesms/TransportOption.java
index 902bd7558e5..a8afc974d89 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/TransportOption.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/TransportOption.java
@@ -9,7 +9,9 @@
import org.thoughtcrime.securesms.util.CharacterCalculator;
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
-import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.util.Optional;
+
public class TransportOption implements Parcelable {
@@ -18,8 +20,8 @@ public enum Type {
TEXTSECURE
}
- private final int drawable;
- private final int backgroundColor;
+ private final int drawable;
+ private final int backgroundColor;
private final @NonNull String text;
private final @NonNull Type type;
private final @NonNull String composeHint;
@@ -35,7 +37,7 @@ public TransportOption(@NonNull Type type,
@NonNull CharacterCalculator characterCalculator)
{
this(type, drawable, backgroundColor, text, composeHint, characterCalculator,
- Optional.absent(), Optional.absent());
+ Optional.empty(), Optional.empty());
}
public TransportOption(@NonNull Type type,
@@ -64,8 +66,8 @@ public TransportOption(@NonNull Type type,
in.readString(),
in.readString(),
CharacterCalculator.readFromParcel(in),
- Optional.fromNullable(TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in)),
- in.readInt() == 1 ? Optional.of(in.readInt()) : Optional.absent());
+ Optional.ofNullable(TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in)),
+ in.readInt() == 1 ? Optional.of(in.readInt()) : Optional.empty());
}
public @NonNull Type getType() {
@@ -123,7 +125,7 @@ public void writeToParcel(Parcel dest, int flags) {
dest.writeString(text);
dest.writeString(composeHint);
CharacterCalculator.writeToParcel(dest, characterCalculator);
- TextUtils.writeToParcel(simName.orNull(), dest, flags);
+ TextUtils.writeToParcel(simName.orElse(null), dest, flags);
if (simSubscriptionId.isPresent()) {
dest.writeInt(1);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/TransportOptions.java b/app/src/main/java/org/thoughtcrime/securesms/TransportOptions.java
index 846ca59dbb8..6d0058ac2b6 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/TransportOptions.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/TransportOptions.java
@@ -14,13 +14,14 @@
import org.thoughtcrime.securesms.util.SmsCharacterCalculator;
import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat;
import org.thoughtcrime.securesms.util.dualsim.SubscriptionManagerCompat;
-import org.whispersystems.libsignal.util.guava.Optional;
+import org.whispersystems.signalservice.api.util.OptionalUtil;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
+import java.util.Optional;
import static org.thoughtcrime.securesms.TransportOption.Type;
@@ -33,8 +34,8 @@ public class TransportOptions {
private final List enabledTransports;
private Type defaultTransportType = Type.SMS;
- private Optional defaultSubscriptionId = Optional.absent();
- private Optional selectedOption = Optional.absent();
+ private Optional defaultSubscriptionId = Optional.empty();
+ private Optional selectedOption = Optional.empty();
private final Optional systemSubscriptionId;
@@ -54,7 +55,7 @@ public void reset(boolean media) {
setSelectedTransport(null);
} else {
this.defaultTransportType = Type.SMS;
- this.defaultSubscriptionId = Optional.absent();
+ this.defaultSubscriptionId = Optional.empty();
notifyTransportChangeListeners();
}
@@ -81,7 +82,7 @@ public void setDefaultSubscriptionId(Optional subscriptionId) {
}
public void setSelectedTransport(@Nullable TransportOption transportOption) {
- this.selectedOption = Optional.fromNullable(transportOption);
+ this.selectedOption = Optional.ofNullable(transportOption);
notifyTransportChangeListeners();
}
@@ -93,7 +94,7 @@ public boolean isManualSelection() {
if (selectedOption.isPresent()) return selectedOption.get();
if (defaultTransportType == Type.SMS) {
- TransportOption transportOption = findEnabledSmsTransportOption(defaultSubscriptionId.or(systemSubscriptionId));
+ TransportOption transportOption = findEnabledSmsTransportOption(OptionalUtil.or(defaultSubscriptionId, systemSubscriptionId));
if (transportOption != null) {
return transportOption;
}
@@ -124,7 +125,7 @@ public boolean isManualSelection() {
for (TransportOption transportOption : enabledTransports) {
if (transportOption.getType() == Type.SMS &&
- subId == transportOption.getSimSubscriptionId().or(-1)) {
+ subId == transportOption.getSimSubscriptionId().orElse(-1)) {
return transportOption;
}
}
@@ -133,7 +134,7 @@ public boolean isManualSelection() {
}
public void disableTransport(Type type) {
- TransportOption selected = selectedOption.orNull();
+ TransportOption selected = selectedOption.orElse(null);
Iterator iterator = enabledTransports.iterator();
while (iterator.hasNext()) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java b/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java
deleted file mode 100644
index 6ef67a5fd67..00000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java
+++ /dev/null
@@ -1,689 +0,0 @@
-/*
- * Copyright (C) 2016-2017 Open Whisper Systems
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.thoughtcrime.securesms;
-
-import android.Manifest;
-import android.animation.TypeEvaluator;
-import android.animation.ValueAnimator;
-import android.annotation.SuppressLint;
-import android.content.ActivityNotFoundException;
-import android.content.Context;
-import android.content.Intent;
-import android.content.res.Configuration;
-import android.graphics.Bitmap;
-import android.graphics.BitmapFactory;
-import android.graphics.Canvas;
-import android.graphics.PorterDuff;
-import android.graphics.drawable.BitmapDrawable;
-import android.os.AsyncTask;
-import android.os.Bundle;
-import android.os.Vibrator;
-import android.text.Html;
-import android.text.TextUtils;
-import android.text.method.LinkMovementMethod;
-import android.view.ContextMenu;
-import android.view.ContextMenu.ContextMenuInfo;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.animation.Animation;
-import android.view.animation.AnticipateInterpolator;
-import android.view.animation.OvershootInterpolator;
-import android.view.animation.ScaleAnimation;
-import android.widget.CompoundButton;
-import android.widget.ImageView;
-import android.widget.TextView;
-import android.widget.Toast;
-
-import androidx.annotation.DrawableRes;
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.AlertDialog;
-import androidx.appcompat.widget.SwitchCompat;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentTransaction;
-
-import org.signal.core.util.ThreadUtil;
-import org.signal.core.util.concurrent.SignalExecutors;
-import org.signal.core.util.logging.Log;
-import org.thoughtcrime.securesms.components.camera.CameraView;
-import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
-import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable;
-import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
-import org.thoughtcrime.securesms.database.DatabaseFactory;
-import org.thoughtcrime.securesms.database.IdentityDatabase;
-import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
-import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
-import org.thoughtcrime.securesms.jobs.MultiDeviceVerifiedUpdateJob;
-import org.thoughtcrime.securesms.permissions.Permissions;
-import org.thoughtcrime.securesms.qr.QrCode;
-import org.thoughtcrime.securesms.qr.ScanListener;
-import org.thoughtcrime.securesms.qr.ScanningThread;
-import org.thoughtcrime.securesms.recipients.LiveRecipient;
-import org.thoughtcrime.securesms.recipients.Recipient;
-import org.thoughtcrime.securesms.recipients.RecipientId;
-import org.thoughtcrime.securesms.storage.StorageSyncHelper;
-import org.thoughtcrime.securesms.util.DynamicTheme;
-import org.thoughtcrime.securesms.util.FeatureFlags;
-import org.thoughtcrime.securesms.util.IdentityUtil;
-import org.thoughtcrime.securesms.util.TextSecurePreferences;
-import org.thoughtcrime.securesms.util.Util;
-import org.thoughtcrime.securesms.util.ViewUtil;
-import org.whispersystems.libsignal.IdentityKey;
-import org.whispersystems.libsignal.fingerprint.Fingerprint;
-import org.whispersystems.libsignal.fingerprint.FingerprintVersionMismatchException;
-import org.whispersystems.libsignal.fingerprint.NumericFingerprintGenerator;
-import org.whispersystems.signalservice.api.SignalSessionLock;
-import org.whispersystems.signalservice.api.util.UuidUtil;
-
-import java.nio.charset.Charset;
-import java.util.Locale;
-
-/**
- * Activity for verifying identity keys.
- *
- * @author Moxie Marlinspike
- */
-@SuppressLint("StaticFieldLeak")
-public class VerifyIdentityActivity extends PassphraseRequiredActivity implements ScanListener, View.OnClickListener {
-
- private static final String TAG = Log.tag(VerifyIdentityActivity.class);
-
- private static final String RECIPIENT_EXTRA = "recipient_id";
- private static final String IDENTITY_EXTRA = "recipient_identity";
- private static final String VERIFIED_EXTRA = "verified_state";
-
- private final DynamicTheme dynamicTheme = new DynamicTheme();
-
- private final VerifyDisplayFragment displayFragment = new VerifyDisplayFragment();
- private final VerifyScanFragment scanFragment = new VerifyScanFragment();
-
- public static Intent newIntent(@NonNull Context context,
- @NonNull IdentityDatabase.IdentityRecord identityRecord)
- {
- return newIntent(context,
- identityRecord.getRecipientId(),
- identityRecord.getIdentityKey(),
- identityRecord.getVerifiedStatus() == IdentityDatabase.VerifiedStatus.VERIFIED);
- }
-
- public static Intent newIntent(@NonNull Context context,
- @NonNull IdentityDatabase.IdentityRecord identityRecord,
- boolean verified)
- {
- return newIntent(context,
- identityRecord.getRecipientId(),
- identityRecord.getIdentityKey(),
- verified);
- }
-
- public static Intent newIntent(@NonNull Context context,
- @NonNull RecipientId recipientId,
- @NonNull IdentityKey identityKey,
- boolean verified)
- {
- Intent intent = new Intent(context, VerifyIdentityActivity.class);
-
- intent.putExtra(RECIPIENT_EXTRA, recipientId);
- intent.putExtra(IDENTITY_EXTRA, new IdentityKeyParcelable(identityKey));
- intent.putExtra(VERIFIED_EXTRA, verified);
-
- return intent;
- }
-
- @Override
- public void onPreCreate() {
- dynamicTheme.onCreate(this);
- }
-
- @Override
- protected void onCreate(Bundle state, boolean ready) {
- getSupportActionBar().setDisplayHomeAsUpEnabled(true);
- getSupportActionBar().setTitle(R.string.AndroidManifest__verify_safety_number);
-
- Bundle extras = new Bundle();
- extras.putParcelable(VerifyDisplayFragment.RECIPIENT_ID, getIntent().getParcelableExtra(RECIPIENT_EXTRA));
- extras.putParcelable(VerifyDisplayFragment.REMOTE_IDENTITY, getIntent().getParcelableExtra(IDENTITY_EXTRA));
- extras.putParcelable(VerifyDisplayFragment.LOCAL_IDENTITY, new IdentityKeyParcelable(IdentityKeyUtil.getIdentityKey(this)));
- extras.putString(VerifyDisplayFragment.LOCAL_NUMBER, TextSecurePreferences.getLocalNumber(this));
- extras.putBoolean(VerifyDisplayFragment.VERIFIED_STATE, getIntent().getBooleanExtra(VERIFIED_EXTRA, false));
-
- scanFragment.setScanListener(this);
- displayFragment.setClickListener(this);
-
- initFragment(android.R.id.content, displayFragment, Locale.getDefault(), extras);
- }
-
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
- switch (item.getItemId()) {
- case android.R.id.home: finish(); return true;
- }
-
- return false;
- }
-
- @Override
- public void onQrDataFound(final String data) {
- ThreadUtil.runOnMain(() -> {
- ((Vibrator)getSystemService(Context.VIBRATOR_SERVICE)).vibrate(50);
-
- getSupportFragmentManager().popBackStack();
- displayFragment.setScannedFingerprint(data);
- });
- }
-
- @Override
- public void onClick(View v) {
- Permissions.with(this)
- .request(Manifest.permission.CAMERA)
- .ifNecessary()
- .withPermanentDenialDialog(getString(R.string.VerifyIdentityActivity_signal_needs_the_camera_permission_in_order_to_scan_a_qr_code_but_it_has_been_permanently_denied))
- .onAllGranted(() -> {
- FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
- transaction.setCustomAnimations(R.anim.slide_from_top, R.anim.slide_to_bottom,
- R.anim.slide_from_bottom, R.anim.slide_to_top);
-
- transaction.replace(android.R.id.content, scanFragment)
- .addToBackStack(null)
- .commitAllowingStateLoss();
- })
- .onAnyDenied(() -> Toast.makeText(this, R.string.VerifyIdentityActivity_unable_to_scan_qr_code_without_camera_permission, Toast.LENGTH_LONG).show())
- .execute();
- }
-
- @SuppressLint("MissingSuperCall")
- @Override
- public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
- Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
- }
-
- public static class VerifyDisplayFragment extends Fragment implements CompoundButton.OnCheckedChangeListener {
-
- public static final String RECIPIENT_ID = "recipient_id";
- public static final String REMOTE_NUMBER = "remote_number";
- public static final String REMOTE_IDENTITY = "remote_identity";
- public static final String LOCAL_IDENTITY = "local_identity";
- public static final String LOCAL_NUMBER = "local_number";
- public static final String VERIFIED_STATE = "verified_state";
-
- private LiveRecipient recipient;
- private IdentityKey localIdentity;
- private IdentityKey remoteIdentity;
- private Fingerprint fingerprint;
-
- private View container;
- private View numbersContainer;
- private ImageView qrCode;
- private ImageView qrVerified;
- private TextView tapLabel;
- private TextView description;
- private View.OnClickListener clickListener;
- private SwitchCompat verified;
-
- private TextView[] codes = new TextView[12];
- private boolean animateSuccessOnDraw = false;
- private boolean animateFailureOnDraw = false;
-
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
- this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_display_fragment);
- this.numbersContainer = container.findViewById(R.id.number_table);
- this.qrCode = container.findViewById(R.id.qr_code);
- this.verified = container.findViewById(R.id.verified_switch);
- this.qrVerified = container.findViewById(R.id.qr_verified);
- this.description = container.findViewById(R.id.description);
- this.tapLabel = container.findViewById(R.id.tap_label);
- this.codes[0] = container.findViewById(R.id.code_first);
- this.codes[1] = container.findViewById(R.id.code_second);
- this.codes[2] = container.findViewById(R.id.code_third);
- this.codes[3] = container.findViewById(R.id.code_fourth);
- this.codes[4] = container.findViewById(R.id.code_fifth);
- this.codes[5] = container.findViewById(R.id.code_sixth);
- this.codes[6] = container.findViewById(R.id.code_seventh);
- this.codes[7] = container.findViewById(R.id.code_eighth);
- this.codes[8] = container.findViewById(R.id.code_ninth);
- this.codes[9] = container.findViewById(R.id.code_tenth);
- this.codes[10] = container.findViewById(R.id.code_eleventh);
- this.codes[11] = container.findViewById(R.id.code_twelth);
-
- this.qrCode.setOnClickListener(clickListener);
- this.registerForContextMenu(numbersContainer);
-
- this.verified.setChecked(getArguments().getBoolean(VERIFIED_STATE, false));
- this.verified.setOnCheckedChangeListener(this);
-
- return container;
- }
-
- @Override
- public void onCreate(Bundle bundle) {
- super.onCreate(bundle);
-
- RecipientId recipientId = getArguments().getParcelable(RECIPIENT_ID);
- IdentityKeyParcelable localIdentityParcelable = getArguments().getParcelable(LOCAL_IDENTITY);
- IdentityKeyParcelable remoteIdentityParcelable = getArguments().getParcelable(REMOTE_IDENTITY);
-
- if (recipientId == null) throw new AssertionError("RecipientId required");
- if (localIdentityParcelable == null) throw new AssertionError("local identity required");
- if (remoteIdentityParcelable == null) throw new AssertionError("remote identity required");
-
- this.localIdentity = localIdentityParcelable.get();
- this.recipient = Recipient.live(recipientId);
- this.remoteIdentity = remoteIdentityParcelable.get();
-
- int version;
- byte[] localId;
- byte[] remoteId;
-
- //noinspection WrongThread
- Recipient resolved = recipient.resolve();
-
- if (FeatureFlags.verifyV2() && resolved.getUuid().isPresent()) {
- Log.i(TAG, "Using UUID (version 2).");
- version = 2;
- localId = UuidUtil.toByteArray(TextSecurePreferences.getLocalUuid(requireContext()));
- remoteId = UuidUtil.toByteArray(resolved.getUuid().get());
- } else if (!FeatureFlags.verifyV2() && resolved.getE164().isPresent()) {
- Log.i(TAG, "Using E164 (version 1).");
- version = 1;
- localId = TextSecurePreferences.getLocalNumber(requireContext()).getBytes();
- remoteId = resolved.requireE164().getBytes();
- } else {
- Log.w(TAG, String.format(Locale.ENGLISH, "Could not show proper verification! verifyV2: %s, hasUuid: %s, hasE164: %s", FeatureFlags.verifyV2(), resolved.getUuid().isPresent(), resolved.getE164().isPresent()));
- new AlertDialog.Builder(requireContext())
- .setMessage(getString(R.string.VerifyIdentityActivity_you_must_first_exchange_messages_in_order_to_view, resolved.getDisplayName(requireContext())))
- .setPositiveButton(android.R.string.ok, (dialog, which) -> requireActivity().finish())
- .setOnDismissListener(dialog -> requireActivity().finish())
- .show();
- return;
- }
-
- this.recipient.observe(this, this::setRecipientText);
-
- new AsyncTask() {
- @Override
- protected Fingerprint doInBackground(Void... params) {
- return new NumericFingerprintGenerator(5200).createFor(version,
- localId, localIdentity,
- remoteId, remoteIdentity);
- }
-
- @Override
- protected void onPostExecute(Fingerprint fingerprint) {
- VerifyDisplayFragment.this.fingerprint = fingerprint;
- setFingerprintViews(fingerprint, true);
- getActivity().supportInvalidateOptionsMenu();
- }
- }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
-
- setHasOptionsMenu(true);
- }
-
- @Override
- public void onResume() {
- super.onResume();
-
- setRecipientText(recipient.get());
-
- if (fingerprint != null) {
- setFingerprintViews(fingerprint, false);
- }
-
- if (animateSuccessOnDraw) {
- animateSuccessOnDraw = false;
- animateVerifiedSuccess();
- } else if (animateFailureOnDraw) {
- animateFailureOnDraw = false;
- animateVerifiedFailure();
- }
- }
-
- @Override
- public void onCreateContextMenu(ContextMenu menu, View view,
- ContextMenuInfo menuInfo)
- {
- super.onCreateContextMenu(menu, view, menuInfo);
-
- if (fingerprint != null) {
- MenuInflater inflater = getActivity().getMenuInflater();
- inflater.inflate(R.menu.verify_display_fragment_context_menu, menu);
- }
- }
-
- @Override
- public boolean onContextItemSelected(MenuItem item) {
- if (fingerprint == null) return super.onContextItemSelected(item);
-
- switch (item.getItemId()) {
- case R.id.menu_copy: handleCopyToClipboard(fingerprint, codes.length); return true;
- case R.id.menu_compare: handleCompareWithClipboard(fingerprint); return true;
- default: return super.onContextItemSelected(item);
- }
- }
-
- @Override
- public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
- super.onCreateOptionsMenu(menu, inflater);
-
- if (fingerprint != null) {
- inflater.inflate(R.menu.verify_identity, menu);
- }
- }
-
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
- switch (item.getItemId()) {
- case R.id.verify_identity__share: handleShare(fingerprint, codes.length); return true;
- }
-
- return false;
- }
-
- public void setScannedFingerprint(String scanned) {
- try {
- if (fingerprint.getScannableFingerprint().compareTo(scanned.getBytes("ISO-8859-1"))) {
- this.animateSuccessOnDraw = true;
- } else {
- this.animateFailureOnDraw = true;
- }
- } catch (FingerprintVersionMismatchException e) {
- Log.w(TAG, e);
- if (e.getOurVersion() < e.getTheirVersion()) {
- Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_your_contact_is_running_a_newer_version_of_Signal, Toast.LENGTH_LONG).show();
- } else {
- Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_your_contact_is_running_an_old_version_of_signal, Toast.LENGTH_LONG).show();
- }
- } catch (Exception e) {
- Log.w(TAG, e);
- Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_the_scanned_qr_code_is_not_a_correctly_formatted_safety_number, Toast.LENGTH_LONG).show();
- }
- }
-
- public void setClickListener(View.OnClickListener listener) {
- this.clickListener = listener;
- }
-
- private @NonNull String getFormattedSafetyNumbers(@NonNull Fingerprint fingerprint, int segmentCount) {
- String[] segments = getSegments(fingerprint, segmentCount);
- StringBuilder result = new StringBuilder();
-
- for (int i = 0; i < segments.length; i++) {
- result.append(segments[i]);
-
- if (i != segments.length - 1) {
- if (((i+1) % 4) == 0) result.append('\n');
- else result.append(' ');
- }
- }
-
- return result.toString();
- }
-
- private void handleCopyToClipboard(Fingerprint fingerprint, int segmentCount) {
- Util.writeTextToClipboard(getActivity(), getFormattedSafetyNumbers(fingerprint, segmentCount));
- }
-
- private void handleCompareWithClipboard(Fingerprint fingerprint) {
- String clipboardData = Util.readTextFromClipboard(getActivity());
-
- if (clipboardData == null) {
- Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard, Toast.LENGTH_LONG).show();
- return;
- }
-
- String numericClipboardData = clipboardData.replaceAll("\\D", "");
-
- if (TextUtils.isEmpty(numericClipboardData) || numericClipboardData.length() != 60) {
- Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard, Toast.LENGTH_LONG).show();
- return;
- }
-
- if (fingerprint.getDisplayableFingerprint().getDisplayText().equals(numericClipboardData)) {
- animateVerifiedSuccess();
- } else {
- animateVerifiedFailure();
- }
- }
-
- private void handleShare(@NonNull Fingerprint fingerprint, int segmentCount) {
- String shareString =
- getString(R.string.VerifyIdentityActivity_our_signal_safety_number) + "\n" +
- getFormattedSafetyNumbers(fingerprint, segmentCount) + "\n";
-
- Intent intent = new Intent();
- intent.setAction(Intent.ACTION_SEND);
- intent.putExtra(Intent.EXTRA_TEXT, shareString);
- intent.setType("text/plain");
-
- try {
- startActivity(Intent.createChooser(intent, getString(R.string.VerifyIdentityActivity_share_safety_number_via)));
- } catch (ActivityNotFoundException e) {
- Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_app_to_share_to, Toast.LENGTH_LONG).show();
- }
- }
-
- private void setRecipientText(Recipient recipient) {
- description.setText(Html.fromHtml(String.format(getActivity().getString(R.string.verify_display_fragment__if_you_wish_to_verify_the_security_of_your_end_to_end_encryption_with_s), recipient.getDisplayName(getContext()))));
- description.setMovementMethod(LinkMovementMethod.getInstance());
- }
-
- private void setFingerprintViews(Fingerprint fingerprint, boolean animate) {
- String[] segments = getSegments(fingerprint, codes.length);
-
- for (int i=0;i() {
- public Integer evaluate(float fraction, Integer startValue, Integer endValue) {
- return Math.round(startValue + (endValue - startValue) * fraction);
- }
- });
-
- valueAnimator.setDuration(1000);
- valueAnimator.start();
- }
-
- private String[] getSegments(Fingerprint fingerprint, int segmentCount) {
- String[] segments = new String[segmentCount];
- String digits = fingerprint.getDisplayableFingerprint().getDisplayText();
- int partSize = digits.length() / segmentCount;
-
- for (int i=0;i {
- try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
- if (isChecked) {
- Log.i(TAG, "Saving identity: " + recipientId);
- DatabaseFactory.getIdentityDatabase(getActivity())
- .saveIdentity(recipientId,
- remoteIdentity,
- VerifiedStatus.VERIFIED, false,
- System.currentTimeMillis(), true);
- } else {
- DatabaseFactory.getIdentityDatabase(getActivity())
- .setVerified(recipientId,
- remoteIdentity,
- VerifiedStatus.DEFAULT);
- }
-
- ApplicationDependencies.getJobManager()
- .add(new MultiDeviceVerifiedUpdateJob(recipientId,
- remoteIdentity,
- isChecked ? VerifiedStatus.VERIFIED
- : VerifiedStatus.DEFAULT));
- StorageSyncHelper.scheduleSyncForDataChange();
-
- IdentityUtil.markIdentityVerified(getActivity(), recipient, isChecked, false);
- }
- });
- }
- }
-
- public static class VerifyScanFragment extends Fragment {
-
- private View container;
- private CameraView cameraView;
- private ScanningThread scanningThread;
- private ScanListener scanListener;
-
- public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
- this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_scan_fragment);
- this.cameraView = container.findViewById(R.id.scanner);
-
- return container;
- }
-
- @Override
- public void onResume() {
- super.onResume();
- this.scanningThread = new ScanningThread();
- this.scanningThread.setScanListener(scanListener);
- this.scanningThread.setCharacterSet("ISO-8859-1");
- this.cameraView.onResume();
- this.cameraView.setPreviewCallback(scanningThread);
- this.scanningThread.start();
- }
-
- @Override
- public void onPause() {
- super.onPause();
- this.cameraView.onPause();
- this.scanningThread.stopScanning();
- }
-
- @Override
- public void onConfigurationChanged(Configuration newConfiguration) {
- super.onConfigurationChanged(newConfiguration);
- this.cameraView.onPause();
- this.cameraView.onResume();
- this.cameraView.setPreviewCallback(scanningThread);
- }
-
- public void setScanListener(ScanListener listener) {
- if (this.scanningThread != null) scanningThread.setScanListener(listener);
- this.scanListener = listener;
- }
-
- }
-
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java
index 6bf2f76a6aa..42a39e5e15c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java
@@ -17,8 +17,6 @@
package org.thoughtcrime.securesms;
-import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
-
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.PictureInPictureParams;
@@ -34,6 +32,7 @@
import android.util.Rational;
import android.view.Window;
import android.view.WindowManager;
+import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
@@ -50,6 +49,7 @@
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
+import org.signal.libsignal.protocol.IdentityKey;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
@@ -71,22 +71,32 @@
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
+import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.ThrottledDebouncer;
import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.VibrateUtil;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState;
-import org.whispersystems.libsignal.IdentityKey;
+import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
import java.util.List;
import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.disposables.Disposable;
+
+import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback {
private static final String TAG = Log.tag(WebRtcCallActivity.class);
private static final int STANDARD_DELAY_FINISH = 1000;
+ private static final int VIBRATE_DURATION = 50;
public static final String ANSWER_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_ACTION";
public static final String DENY_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".DENY_ACTION";
@@ -104,6 +114,9 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private boolean enableVideoIfAvailable;
private androidx.window.WindowManager windowManager;
private WindowLayoutInfoConsumer windowLayoutInfoConsumer;
+ private ThrottledDebouncer requestNewSizesThrottle;
+
+ private Disposable ephemeralStateDisposable = Disposable.empty();
@Override
protected void attachBaseContext(@NonNull Context newBase) {
@@ -143,6 +156,20 @@ public void onCreate(Bundle savedInstanceState) {
windowLayoutInfoConsumer = new WindowLayoutInfoConsumer();
windowManager.registerLayoutChangeCallback(SignalExecutors.BOUNDED, windowLayoutInfoConsumer);
+
+ requestNewSizesThrottle = new ThrottledDebouncer(TimeUnit.SECONDS.toMillis(1));
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+
+ ephemeralStateDisposable = ApplicationDependencies.getSignalCallManager()
+ .ephemeralStates()
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(state -> {
+ viewModel.updateFromEphemeralState(state);
+ });
}
@Override
@@ -185,8 +212,11 @@ protected void onStop() {
Log.i(TAG, "onStop");
super.onStop();
+ ephemeralStateDisposable.dispose();
+
if (!isInPipMode() || isFinishing()) {
EventBus.getDefault().unregister(this);
+ requestNewSizesThrottle.clear();
}
if (!viewModel.isCallStarting()) {
@@ -247,7 +277,6 @@ private boolean isInPipMode() {
private void processIntent(@NonNull Intent intent) {
if (ANSWER_ACTION.equals(intent.getAction())) {
- viewModel.setRecipient(EventBus.getDefault().getStickyEvent(WebRtcViewModel.class).getRecipient());
handleAnswerWithAudio();
} else if (DENY_ACTION.equals(intent.getAction())) {
handleDenyCall();
@@ -284,20 +313,23 @@ private void initializeViewModel(boolean isLandscapeEnabled) {
viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls);
viewModel.getEvents().observe(this, this::handleViewModelEvent);
viewModel.getCallTime().observe(this, this::handleCallTime);
+
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(),
viewModel.getOrientationAndLandscapeEnabled(),
- (s, o) -> new CallParticipantsViewState(s, o.first == PORTRAIT_BOTTOM_EDGE, o.second))
+ viewModel.getEphemeralState(),
+ (s, o, e) -> new CallParticipantsViewState(s, e, o.first == PORTRAIT_BOTTOM_EDGE, o.second))
.observe(this, p -> callScreen.updateCallParticipants(p));
viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate);
viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent);
- viewModel.getGroupMembers().observe(this, unused -> updateGroupMembersForGroupCall());
+ viewModel.getGroupMembersChanged().observe(this, unused -> updateGroupMembersForGroupCall());
+ viewModel.getGroupMemberCount().observe(this, this::handleGroupMemberCountChange);
viewModel.shouldShowSpeakerHint().observe(this, this::updateSpeakerHint);
callScreen.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
if (state != null) {
if (state.needsNewRequestSizes()) {
- ApplicationDependencies.getSignalCallManager().updateRenderedResolutions();
+ requestNewSizesThrottle.publish(() -> ApplicationDependencies.getSignalCallManager().updateRenderedResolutions());
}
}
});
@@ -356,15 +388,15 @@ private void handleCallTime(long callTime) {
}
private void handleSetAudioHandset() {
- ApplicationDependencies.getSignalCallManager().setAudioSpeaker(false);
+ ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.EARPIECE);
}
private void handleSetAudioSpeaker() {
- ApplicationDependencies.getSignalCallManager().setAudioSpeaker(true);
+ ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.SPEAKER_PHONE);
}
private void handleSetAudioBluetooth() {
- ApplicationDependencies.getSignalCallManager().setAudioBluetooth(true);
+ ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.BLUETOOTH);
}
private void handleSetMuteAudio(boolean enabled) {
@@ -392,24 +424,19 @@ private void handleFlipCamera() {
}
private void handleAnswerWithAudio() {
- Recipient recipient = viewModel.getRecipient().get();
+ Permissions.with(this)
+ .request(Manifest.permission.RECORD_AUDIO)
+ .ifNecessary()
+ .withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_give_signal_access_to_your_microphone),
+ R.drawable.ic_mic_solid_24)
+ .withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))
+ .onAllGranted(() -> {
+ callScreen.setStatus(getString(R.string.RedPhone_answering));
- if (!recipient.equals(Recipient.UNKNOWN)) {
- Permissions.with(this)
- .request(Manifest.permission.RECORD_AUDIO)
- .ifNecessary()
- .withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, recipient.getDisplayName(this)),
- R.drawable.ic_mic_solid_24)
- .withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))
- .onAllGranted(() -> {
- callScreen.setRecipient(recipient);
- callScreen.setStatus(getString(R.string.RedPhone_answering));
-
- ApplicationDependencies.getSignalCallManager().acceptCall(false);
- })
- .onAnyDenied(this::handleDenyCall)
- .execute();
- }
+ ApplicationDependencies.getSignalCallManager().acceptCall(false);
+ })
+ .onAnyDenied(this::handleDenyCall)
+ .execute();
}
private void handleAnswerWithVideo() {
@@ -473,6 +500,12 @@ private void handleTerminate(@NonNull Recipient recipient, @NonNull HangupMessag
delayedFinish();
}
+ private void handleGlare(@NonNull Recipient recipient) {
+ Log.i(TAG, "handleGlare: " + recipient.getId());
+
+ callScreen.setStatus("");
+ }
+
private void handleCallRinging() {
callScreen.setStatus(getString(R.string.RedPhone_ringing));
}
@@ -490,6 +523,11 @@ private void handleCallConnected(@NonNull WebRtcViewModel event) {
}
}
+ private void handleCallReconnecting() {
+ callScreen.setStatus(getString(R.string.WebRtcCallActivity__reconnecting));
+ VibrateUtil.vibrate(this, VIBRATE_DURATION);
+ }
+
private void handleRecipientUnavailable() {
EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class);
callScreen.setStatus(getString(R.string.RedPhone_recipient_unavailable));
@@ -540,6 +578,12 @@ private void updateGroupMembersForGroupCall() {
ApplicationDependencies.getSignalCallManager().requestUpdateGroupMembers();
}
+ public void handleGroupMemberCountChange(int count) {
+ boolean canRing = count <= FeatureFlags.maxGroupCallRingSize() && FeatureFlags.groupCallRinging();
+ callScreen.enableRingGroup(canRing);
+ ApplicationDependencies.getSignalCallManager().setRingGroup(canRing);
+ }
+
private void updateSpeakerHint(boolean showSpeakerHint) {
if (showSpeakerHint) {
callScreen.showSpeakerViewHint();
@@ -606,12 +650,16 @@ public void onEventMainThread(@NonNull WebRtcViewModel event) {
handleCallPreJoin(event); break;
case CALL_CONNECTED:
handleCallConnected(event); break;
+ case CALL_RECONNECTING:
+ handleCallReconnecting(); break;
case NETWORK_FAILURE:
handleServerFailure(); break;
case CALL_RINGING:
handleCallRinging(); break;
case CALL_DISCONNECTED:
handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL); break;
+ case CALL_DISCONNECTED_GLARE:
+ handleGlare(event.getRecipient()); break;
case CALL_ACCEPTED_ELSEWHERE:
handleTerminate(event.getRecipient(), HangupMessage.Type.ACCEPTED); break;
case CALL_DECLINED_ELSEWHERE:
@@ -645,6 +693,11 @@ public void onEventMainThread(@NonNull WebRtcViewModel event) {
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
if (event.getGroupState().isNotIdle()) {
callScreen.setStatusFromGroupCallState(event.getGroupState());
+ callScreen.setRingGroup(event.shouldRingGroup());
+
+ if (event.shouldRingGroup() && event.areRemoteDevicesInCall()) {
+ ApplicationDependencies.getSignalCallManager().setRingGroup(false);
+ }
}
}
@@ -759,6 +812,16 @@ public void onPageChanged(@NonNull CallParticipantsState.SelectedPage page) {
public void onLocalPictureInPictureClicked() {
viewModel.onLocalPictureInPictureClicked();
}
+
+ @Override
+ public void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed) {
+ if (ringingAllowed) {
+ ApplicationDependencies.getSignalCallManager().setRingGroup(ringGroup);
+ } else {
+ ApplicationDependencies.getSignalCallManager().setRingGroup(false);
+ Toast.makeText(WebRtcCallActivity.this, R.string.WebRtcCallActivity__group_is_too_large_to_ring_the_participants, Toast.LENGTH_SHORT).show();
+ }
+ }
}
private class WindowLayoutInfoConsumer implements Consumer {
@@ -772,8 +835,8 @@ public void accept(WindowLayoutInfo windowLayoutInfo) {
setRequestedOrientation(feature.isPresent() ? ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED : ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
if (feature.isPresent()) {
FoldingFeature foldingFeature = (FoldingFeature) feature.get();
- Rect bounds = foldingFeature.getBounds();
- if (foldingFeature.getState() == FoldingFeature.State.HALF_OPENED && bounds.top == bounds.bottom) {
+ Rect bounds = foldingFeature.getBounds();
+ if (foldingFeature.isSeparating()) {
Log.d(TAG, "OnWindowLayoutInfo accepted: ensure call view is in table-top display mode");
viewModel.setFoldableState(WebRtcControls.FoldableState.folded(bounds.top));
} else {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleAvatarTransition.kt b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleAvatarTransition.kt
index 1fda6fe7d38..30a034b42f0 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleAvatarTransition.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleAvatarTransition.kt
@@ -14,7 +14,6 @@ import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import android.view.animation.Interpolator
import androidx.annotation.RequiresApi
-import org.thoughtcrime.securesms.components.AvatarImageView
private const val POSITION_ON_SCREEN = "signal.circleavatartransition.positiononscreen"
private const val WIDTH = "signal.circleavatartransition.width"
@@ -36,7 +35,7 @@ class CircleAvatarTransition(context: Context, attrs: AttributeSet?) : Transitio
private fun captureValues(transitionValues: TransitionValues) {
val view: View = transitionValues.view
- if (view is AvatarImageView) {
+ if (view.transitionName == "avatar") {
val topLeft = intArrayOf(0, 0)
view.getLocationOnScreen(topLeft)
transitionValues.values[POSITION_ON_SCREEN] = topLeft
@@ -51,7 +50,7 @@ class CircleAvatarTransition(context: Context, attrs: AttributeSet?) : Transitio
}
val view: View = endValues.view
- if (view !is AvatarImageView || view.transitionName != "avatar") {
+ if (view.transitionName != "avatar") {
return null
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CrossfaderTransition.kt b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CrossfaderTransition.kt
new file mode 100644
index 00000000000..96f71f8d174
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CrossfaderTransition.kt
@@ -0,0 +1,63 @@
+package org.thoughtcrime.securesms.animation.transitions
+
+import android.animation.Animator
+import android.animation.ValueAnimator
+import android.content.Context
+import android.transition.Transition
+import android.transition.TransitionValues
+import android.util.AttributeSet
+import android.view.ViewGroup
+import androidx.annotation.RequiresApi
+import androidx.core.animation.doOnEnd
+import androidx.core.animation.doOnStart
+
+@RequiresApi(21)
+class CrossfaderTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
+
+ companion object {
+ private const val WIDTH = "CrossfaderTransition.WIDTH"
+ }
+
+ override fun captureStartValues(transitionValues: TransitionValues) {
+ if (transitionValues.view is Crossfadeable) {
+ transitionValues.values[WIDTH] = transitionValues.view.width
+ }
+ }
+
+ override fun captureEndValues(transitionValues: TransitionValues) {
+ if (transitionValues.view is Crossfadeable) {
+ transitionValues.values[WIDTH] = transitionValues.view.width
+ }
+ }
+
+ override fun createAnimator(sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
+ if (startValues == null || endValues == null) {
+ return null
+ }
+
+ val startWidth = (startValues.values[WIDTH] ?: 0) as Int
+ val endWidth = (endValues.values[WIDTH] ?: 0) as Int
+ val view: Crossfadeable = endValues.view as? Crossfadeable ?: return null
+ val reverse = startWidth > endWidth
+
+ return ValueAnimator.ofFloat(0f, 1f).apply {
+ addUpdateListener {
+ view.onCrossfadeAnimationUpdated(it.animatedValue as Float, reverse)
+ }
+
+ doOnStart {
+ view.onCrossfadeStarted(reverse)
+ }
+
+ doOnEnd {
+ view.onCrossfadeFinished(reverse)
+ }
+ }
+ }
+
+ interface Crossfadeable {
+ fun onCrossfadeAnimationUpdated(progress: Float, reverse: Boolean)
+ fun onCrossfadeStarted(reverse: Boolean)
+ fun onCrossfadeFinished(reverse: Boolean)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/FabElevationFadeTransform.kt b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/FabElevationFadeTransform.kt
new file mode 100644
index 00000000000..f5be57cd136
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/FabElevationFadeTransform.kt
@@ -0,0 +1,53 @@
+package org.thoughtcrime.securesms.animation.transitions
+
+import android.animation.Animator
+import android.animation.ValueAnimator
+import android.content.Context
+import android.transition.Transition
+import android.transition.TransitionValues
+import android.util.AttributeSet
+import android.view.ViewGroup
+import androidx.annotation.RequiresApi
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+
+@RequiresApi(21)
+class FabElevationFadeTransform(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
+
+ companion object {
+ private const val ELEVATION = "CrossfaderTransition.ELEVATION"
+ }
+
+ override fun captureStartValues(transitionValues: TransitionValues) {
+ if (transitionValues.view is FloatingActionButton) {
+ transitionValues.values[ELEVATION] = transitionValues.view.elevation
+ }
+ }
+
+ override fun captureEndValues(transitionValues: TransitionValues) {
+ if (transitionValues.view is FloatingActionButton) {
+ transitionValues.values[ELEVATION] = transitionValues.view.elevation
+ }
+ }
+
+ override fun createAnimator(sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
+ if (startValues?.view !is FloatingActionButton || endValues?.view !is FloatingActionButton) {
+ return null
+ }
+
+ val startElevation = startValues.view.elevation
+ val endElevation = endValues.view.elevation
+ if (startElevation == endElevation) {
+ return null
+ }
+
+ return ValueAnimator.ofFloat(
+ startValues.values[ELEVATION] as Float,
+ endValues.values[ELEVATION] as Float
+ ).apply {
+ addUpdateListener {
+ val elevation = it.animatedValue as Float
+ endValues.view.elevation = elevation
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.java
index 58e3343effb..d6dd753af95 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.java
@@ -9,12 +9,12 @@
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.util.Base64;
-import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import java.util.LinkedList;
import java.util.List;
+import java.util.Optional;
public class PointerAttachment extends Attachment {
@@ -93,7 +93,7 @@ public static Optional forPointer(Optional
}
public static Optional forPointer(Optional pointer, @Nullable StickerLocator stickerLocator, @Nullable String fastPreflightId) {
- if (!pointer.isPresent() || !pointer.get().isPointer()) return Optional.absent();
+ if (!pointer.isPresent() || !pointer.get().isPointer()) return Optional.empty();
String encodedKey = null;
@@ -103,12 +103,12 @@ public static Optional forPointer(Optional
return Optional.of(new PointerAttachment(pointer.get().getContentType(),
AttachmentDatabase.TRANSFER_PROGRESS_PENDING,
- pointer.get().asPointer().getSize().or(0),
- pointer.get().asPointer().getFileName().orNull(),
+ pointer.get().asPointer().getSize().orElse(0),
+ pointer.get().asPointer().getFileName().orElse(null),
pointer.get().asPointer().getCdnNumber(),
pointer.get().asPointer().getRemoteId().toString(),
encodedKey, null,
- pointer.get().asPointer().getDigest().orNull(),
+ pointer.get().asPointer().getDigest().orElse(null),
fastPreflightId,
pointer.get().asPointer().getVoiceNote(),
pointer.get().asPointer().isBorderless(),
@@ -116,9 +116,9 @@ public static Optional forPointer(Optional
pointer.get().asPointer().getWidth(),
pointer.get().asPointer().getHeight(),
pointer.get().asPointer().getUploadTimestamp(),
- pointer.get().asPointer().getCaption().orNull(),
+ pointer.get().asPointer().getCaption().orElse(null),
stickerLocator,
- BlurHash.parseOrNull(pointer.get().asPointer().getBlurHash().orNull())));
+ BlurHash.parseOrNull(pointer.get().asPointer().getBlurHash().orElse(null))));
}
@@ -127,13 +127,13 @@ public static Optional forPointer(SignalServiceDataMessage.Quote.Quo
return Optional.of(new PointerAttachment(pointer.getContentType(),
AttachmentDatabase.TRANSFER_PROGRESS_PENDING,
- thumbnail != null ? thumbnail.asPointer().getSize().or(0) : 0,
+ thumbnail != null ? thumbnail.asPointer().getSize().orElse(0) : 0,
pointer.getFileName(),
thumbnail != null ? thumbnail.asPointer().getCdnNumber() : 0,
thumbnail != null ? thumbnail.asPointer().getRemoteId().toString() : "0",
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeBytes(thumbnail.asPointer().getKey()) : null,
null,
- thumbnail != null ? thumbnail.asPointer().getDigest().orNull() : null,
+ thumbnail != null ? thumbnail.asPointer().getDigest().orElse(null) : null,
null,
false,
false,
@@ -141,7 +141,7 @@ public static Optional forPointer(SignalServiceDataMessage.Quote.Quo
thumbnail != null ? thumbnail.asPointer().getWidth() : 0,
thumbnail != null ? thumbnail.asPointer().getHeight() : 0,
thumbnail != null ? thumbnail.asPointer().getUploadTimestamp() : 0,
- thumbnail != null ? thumbnail.asPointer().getCaption().orNull() : null,
+ thumbnail != null ? thumbnail.asPointer().getCaption().orElse(null) : null,
null,
null));
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java
index 996dc43b700..f220bc0c3f7 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java
@@ -1,13 +1,12 @@
package org.thoughtcrime.securesms.audio;
-import android.annotation.TargetApi;
import android.media.AudioFormat;
import android.media.AudioRecord;
import android.media.MediaCodec;
import android.media.MediaCodecInfo;
import android.media.MediaFormat;
import android.media.MediaRecorder;
-import android.os.Build;
+import android.os.ParcelFileDescriptor;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.logging.Log;
@@ -17,8 +16,7 @@
import java.io.OutputStream;
import java.nio.ByteBuffer;
-@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
-public class AudioCodec {
+public class AudioCodec implements Recorder {
private static final String TAG = Log.tag(AudioCodec.class);
@@ -51,12 +49,19 @@ public AudioCodec() throws IOException {
}
}
+ @Override
+ public void start(ParcelFileDescriptor fileDescriptor) {
+ Log.i(TAG, "Recording voice note using AudioCodec.");
+ start(new ParcelFileDescriptor.AutoCloseOutputStream(fileDescriptor));
+ }
+
+ @Override
public synchronized void stop() {
running = false;
while (!finished) Util.wait(this, 0);
}
- public void start(final OutputStream outputStream) {
+ private void start(final OutputStream outputStream) {
new Thread(new Runnable() {
@Override
public void run() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java
index 43c32327887..ff600ef8629 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java
@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.audio;
-import android.annotation.TargetApi;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
@@ -20,7 +19,6 @@
import java.io.IOException;
import java.util.concurrent.ExecutorService;
-@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
public class AudioRecorder {
private static final String TAG = Log.tag(AudioRecorder.class);
@@ -29,8 +27,8 @@ public class AudioRecorder {
private final Context context;
- private AudioCodec audioCodec;
- private Uri captureUri;
+ private Recorder recorder;
+ private Uri captureUri;
public AudioRecorder(@NonNull Context context) {
this.context = context;
@@ -42,7 +40,7 @@ public void startRecording() {
executor.execute(() -> {
Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId());
try {
- if (audioCodec != null) {
+ if (recorder != null) {
throw new AssertionError("We can only record once at a time.");
}
@@ -52,9 +50,9 @@ public void startRecording() {
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType(MediaUtil.AUDIO_AAC)
.createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
- audioCodec = new AudioCodec();
- audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1]));
+ recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
+ recorder.start(fds[1]);
} catch (IOException e) {
Log.w(TAG, e);
}
@@ -67,12 +65,12 @@ public void startRecording() {
final SettableFuture future = new SettableFuture<>();
executor.execute(() -> {
- if (audioCodec == null) {
+ if (recorder == null) {
sendToFuture(future, new IOException("MediaRecorder was never initialized successfully!"));
return;
}
- audioCodec.stop();
+ recorder.stop();
try {
long size = MediaUtil.getMediaSize(context, captureUri);
@@ -82,7 +80,7 @@ public void startRecording() {
sendToFuture(future, ioe);
}
- audioCodec = null;
+ recorder = null;
captureUri = null;
});
diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForm.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForm.java
index b36618da0b9..d06c08ed029 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForm.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForm.java
@@ -22,7 +22,7 @@
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
-import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
import org.thoughtcrime.securesms.media.DecryptableUriMediaInput;
import org.thoughtcrime.securesms.media.MediaInput;
@@ -100,7 +100,7 @@ public void getWaveForm(@NonNull Consumer onSuccess, @NonNull Run
if (attachment instanceof DatabaseAttachment) {
try {
- AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
+ AttachmentDatabase attachmentDatabase = SignalDatabase.attachments();
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
long startTime = System.currentTimeMillis();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/MediaRecorderWrapper.java b/app/src/main/java/org/thoughtcrime/securesms/audio/MediaRecorderWrapper.java
new file mode 100644
index 00000000000..606aaeadb6c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/audio/MediaRecorderWrapper.java
@@ -0,0 +1,65 @@
+package org.thoughtcrime.securesms.audio;
+
+import android.media.MediaRecorder;
+import android.os.ParcelFileDescriptor;
+
+import org.signal.core.util.logging.Log;
+
+import java.io.IOException;
+
+/**
+ * Wrap Android's {@link MediaRecorder} for use with voice notes.
+ */
+public class MediaRecorderWrapper implements Recorder {
+
+ private static final String TAG = Log.tag(MediaRecorderWrapper.class);
+
+ private static final int SAMPLE_RATE = 44100;
+ private static final int CHANNELS = 1;
+ private static final int BIT_RATE = 32000;
+
+ private MediaRecorder recorder = null;
+
+ @Override
+ public void start(ParcelFileDescriptor fileDescriptor) throws IOException {
+ Log.i(TAG, "Recording voice note using MediaRecorderWrapper.");
+ recorder = new MediaRecorder();
+
+ try {
+ recorder.setAudioSource(MediaRecorder.AudioSource.MIC);
+ recorder.setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS);
+ recorder.setOutputFile(fileDescriptor.getFileDescriptor());
+ recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
+ recorder.setAudioSamplingRate(SAMPLE_RATE);
+ recorder.setAudioEncodingBitRate(BIT_RATE);
+ recorder.setAudioChannels(CHANNELS);
+ recorder.prepare();
+ recorder.start();
+ } catch (IllegalStateException e) {
+ Log.w(TAG, "Unable to start recording", e);
+ recorder.release();
+ recorder = null;
+ throw new IOException(e);
+ }
+ }
+
+ @Override
+ public void stop() {
+ if (recorder == null) {
+ return;
+ }
+
+ try {
+ recorder.stop();
+ } catch (RuntimeException e) {
+ if (e.getClass() != RuntimeException.class) {
+ throw e;
+ } else {
+ Log.d(TAG, "Recording stopped with no data captured.");
+ }
+ } finally {
+ recorder.release();
+ recorder = null;
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/Recorder.java b/app/src/main/java/org/thoughtcrime/securesms/audio/Recorder.java
new file mode 100644
index 00000000000..c1838a175f1
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/audio/Recorder.java
@@ -0,0 +1,13 @@
+package org.thoughtcrime.securesms.audio;
+
+import android.os.ParcelFileDescriptor;
+
+import java.io.IOException;
+
+/**
+ * Simple abstraction of the interface for the original voice note recording and the new.
+ */
+public interface Recorder {
+ void start(ParcelFileDescriptor fileDescriptor) throws IOException;
+ void stop();
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarBundler.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarBundler.kt
index f72784247e5..786e2d70b04 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarBundler.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarBundler.kt
@@ -1,7 +1,6 @@
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.
diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarColorItem.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarColorItem.kt
index 2780ff912d8..ef27d0754aa 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarColorItem.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarColorItem.kt
@@ -4,9 +4,10 @@ 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
+import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
typealias OnAvatarColorClickListener = (Avatars.ColorPair) -> Unit
@@ -20,7 +21,7 @@ data class AvatarColorItem(
companion object {
fun registerViewHolder(adapter: MappingAdapter, onAvatarColorClickListener: OnAvatarColorClickListener) {
- adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onAvatarColorClickListener) }, R.layout.avatar_color_item))
+ adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onAvatarColorClickListener) }, R.layout.avatar_color_item))
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarPickerStorage.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarPickerStorage.kt
index d3e498fec19..aa65b6f217e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarPickerStorage.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarPickerStorage.kt
@@ -3,7 +3,7 @@ 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.database.SignalDatabase
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.util.MediaUtil
@@ -33,7 +33,7 @@ object AvatarPickerStorage {
@JvmStatic
fun cleanOrphans(context: Context) {
val avatarFiles = FileStorage.getAllFiles(context, DIRECTORY, FILENAME_BASE)
- val database = DatabaseFactory.getAvatarPickerDatabase(context)
+ val database = SignalDatabase.avatarPicker
val photoAvatars = database
.getAllAvatars()
.filterIsInstance()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarRenderer.kt
index a3c4062330d..da9cbb4dede 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarRenderer.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarRenderer.kt
@@ -14,10 +14,10 @@ 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 java.util.Optional
import javax.annotation.meta.Exhaustive
/**
@@ -48,8 +48,9 @@ object AvatarRenderer {
avatar: Avatar.Text,
inverted: Boolean = false,
size: Int = DIMENSIONS,
+ synchronous: Boolean = false
): Drawable {
- return TextAvatarDrawable(context, avatar, inverted, size)
+ return TextAvatarDrawable(context, avatar, inverted, size, synchronous)
}
private fun renderVector(context: Context, avatar: Avatar.Vector, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
@@ -66,7 +67,7 @@ object AvatarRenderer {
private fun renderText(context: Context, avatar: Avatar.Text, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
- val textDrawable = createTextDrawable(context, avatar)
+ val textDrawable = createTextDrawable(context, avatar, synchronous = true)
canvas.drawColor(avatar.color.backgroundColor)
textDrawable.draw(canvas)
@@ -127,6 +128,6 @@ object AvatarRenderer {
}
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())
+ return Media(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), DIMENSIONS, DIMENSIONS, size, 0, false, false, Optional.empty(), Optional.empty(), Optional.empty())
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/Avatars.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/Avatars.kt
index 4ffb9d5dad6..8222e2d4748 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/avatar/Avatars.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/Avatars.kt
@@ -143,11 +143,10 @@ object Avatars {
)
data class ColorPair(
- val backgroundAvatarColor: AvatarColor,
- val foregroundAvatarColor: ForegroundColor
+ @ColorInt val backgroundColor: Int,
+ @ColorInt val foregroundColor: Int,
+ val code: String
) {
- @ColorInt val backgroundColor: Int = backgroundAvatarColor.colorInt()
- @ColorInt val foregroundColor: Int = foregroundAvatarColor.colorInt
- val code: String = backgroundAvatarColor.serialize()
+ constructor(backgroundAvatarColor: AvatarColor, foregroundAvatarColor: ForegroundColor) : this(backgroundAvatarColor.colorInt(), foregroundAvatarColor.colorInt, backgroundAvatarColor.serialize())
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/TextAvatarDrawable.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/TextAvatarDrawable.kt
index d41e010b561..3acd7eaf8ed 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/avatar/TextAvatarDrawable.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/TextAvatarDrawable.kt
@@ -3,52 +3,58 @@ package org.thoughtcrime.securesms.avatar
import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
+import android.graphics.Paint
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.
- */
+import android.text.Layout
+import android.text.SpannableString
+import android.text.StaticLayout
+import android.text.TextPaint
+import androidx.core.graphics.withTranslation
+import org.thoughtcrime.securesms.components.emoji.EmojiProvider
+
class TextAvatarDrawable(
- context: Context,
- avatar: Avatar.Text,
+ private val context: Context,
+ private val avatar: Avatar.Text,
inverted: Boolean = false,
private val size: Int = AvatarRenderer.DIMENSIONS,
+ private val synchronous: Boolean = false
) : Drawable() {
- private val layout: FrameLayout = FrameLayout(context)
- private val textView: EmojiTextView = EmojiTextView(context)
-
+ private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
init {
- textView.typeface = AvatarRenderer.getTypeface(context)
- textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f))
- textView.text = avatar.text
- textView.gravity = Gravity.CENTER
- textView.setTextColor(if (inverted) avatar.color.backgroundColor else avatar.color.foregroundColor)
- textView.setForceCustomEmoji(true)
-
- layout.addView(textView)
-
- textView.updateLayoutParams {
- width = size
- height = size
- }
+ textPaint.typeface = AvatarRenderer.getTypeface(context)
+ textPaint.color = if (inverted) avatar.color.backgroundColor else avatar.color.foregroundColor
+ textPaint.density = context.resources.displayMetrics.density
- layout.measure(size, size)
- layout.layout(0, 0, size, size)
+ setBounds(0, 0, size, size)
}
- override fun getIntrinsicHeight(): Int = size
+ override fun draw(canvas: Canvas) {
+ val width = bounds.width()
+ val textSize = Avatars.getTextSizeForLength(context, avatar.text, width * 0.8f, width * 0.45f)
+ val candidates = EmojiProvider.getCandidates(avatar.text)
- override fun getIntrinsicWidth(): Int = size
+ textPaint.textSize = textSize
- override fun draw(canvas: Canvas) {
- layout.draw(canvas)
+ val newText = if (candidates == null || candidates.size() == 0) {
+ SpannableString(avatar.text)
+ } else {
+ EmojiProvider.emojify(context, candidates, avatar.text, textPaint, synchronous, true)
+ }
+
+ if (newText == null) return
+
+ val layout = StaticLayout(SpannableString(newText), textPaint, width, Layout.Alignment.ALIGN_NORMAL, 0f, 0f, true)
+ layout.draw(canvas, getStartX(layout), ((bounds.height() / 2) - ((layout.height / 2))).toFloat())
+ }
+
+ private fun getStartX(layout: StaticLayout): Float {
+ val direction = layout.getParagraphDirection(0)
+ val lineWidth = layout.getLineWidth(0)
+ val width = bounds.width()
+ val xPos = (width - lineWidth) / 2
+ return if (direction == Layout.DIR_LEFT_TO_RIGHT) xPos else -xPos
}
override fun setAlpha(alpha: Int) = Unit
@@ -56,4 +62,10 @@ class TextAvatarDrawable(
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
override fun getOpacity(): Int = PixelFormat.OPAQUE
+
+ private fun Layout.draw(canvas: Canvas, x: Float, y: Float) {
+ canvas.withTranslation(x, y) {
+ draw(canvas)
+ }
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/photo/PhotoEditorFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/photo/PhotoEditorFragment.kt
index 8ba36306208..cd3365102b0 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/avatar/photo/PhotoEditorFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/photo/PhotoEditorFragment.kt
@@ -11,7 +11,7 @@ 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.database.SignalDatabase
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
@@ -44,7 +44,7 @@ class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), Ima
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 database = SignalDatabase.avatarPicker
val newPhoto = photo.copy(uri = onDiskUri, size = size)
database.update(newPhoto)
@@ -57,6 +57,16 @@ class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), Ima
}
}
+ override fun onCancelEditing() {
+ Navigation.findNavController(requireView()).popBackStack()
+ }
+
+ override fun onMainImageLoaded() {
+ }
+
+ override fun onMainImageFailedToLoad() {
+ }
+
companion object {
const val REQUEST_KEY_EDIT = "org.thoughtcrime.securesms.avatar.photo.EDIT"
diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt
index 6651a5b9fc5..b0664035cf1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt
@@ -15,6 +15,7 @@ import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.recyclerview.widget.RecyclerView
+import org.signal.core.util.ThreadUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarBundler
@@ -27,10 +28,10 @@ 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.adapter.mapping.MappingAdapter
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
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.
@@ -111,7 +112,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
putParcelable(SELECT_AVATAR_MEDIA, it)
}
)
- Navigation.findNavController(v).popBackStack()
+ ThreadUtil.runOnMain { Navigation.findNavController(v).popBackStack() }
},
{
setFragmentResult(
@@ -120,7 +121,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
putBoolean(SELECT_AVATAR_CLEAR, true)
}
)
- Navigation.findNavController(v).popBackStack()
+ ThreadUtil.runOnMain { Navigation.findNavController(v).popBackStack() }
}
)
}
@@ -147,9 +148,10 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
ViewUtil.hideKeyboard(requireContext(), requireView())
}
+ @Suppress("DEPRECATION")
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))
+ val media: Media = requireNotNull(data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA))
viewModel.onAvatarPhotoSelectionCompleted(media)
} else {
super.onActivityResult(requestCode, resultCode, data)
@@ -195,23 +197,24 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
}
}
- fun openPhotoEditor(photo: Avatar.Photo) {
+ private fun openPhotoEditor(photo: Avatar.Photo) {
Navigation.findNavController(requireView())
- .navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToAvatarPhotoEditorFragment(AvatarBundler.bundlePhoto(photo)))
+ .safeNavigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToAvatarPhotoEditorFragment(AvatarBundler.bundlePhoto(photo)))
}
- fun openVectorEditor(vector: Avatar.Vector) {
+ private fun openVectorEditor(vector: Avatar.Vector) {
Navigation.findNavController(requireView())
- .navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToVectorAvatarCreationFragment(AvatarBundler.bundleVector(vector)))
+ .safeNavigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToVectorAvatarCreationFragment(AvatarBundler.bundleVector(vector)))
}
- fun openTextEditor(text: Avatar.Text?) {
+ private fun openTextEditor(text: Avatar.Text?) {
val bundle = if (text != null) AvatarBundler.bundleText(text) else null
Navigation.findNavController(requireView())
- .navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToTextAvatarCreationFragment(bundle))
+ .safeNavigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToTextAvatarCreationFragment(bundle))
}
- fun openCameraCapture() {
+ @Suppress("DEPRECATION")
+ private fun openCameraCapture() {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
@@ -226,7 +229,8 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
.execute()
}
- fun openGallery() {
+ @Suppress("DEPRECATION")
+ private fun openGallery() {
Permissions.with(this)
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
.ifNecessary()
@@ -240,4 +244,8 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
}
.execute()
}
+
+ override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
+ Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerItem.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerItem.kt
index 2ae07454825..e30d349ecbc 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerItem.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerItem.kt
@@ -14,9 +14,10 @@ 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.adapter.mapping.LayoutFactory
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
typealias OnAvatarClickListener = (Avatar, Boolean) -> Unit
@@ -27,7 +28,7 @@ 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))
+ adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onAvatarClickListener, onAvatarLongClickListener) }, R.layout.avatar_picker_item))
}
class Model(val avatar: Avatar, val isSelected: Boolean) : MappingModel {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerRepository.kt
index 3b26c5cc754..821651af127 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerRepository.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerRepository.kt
@@ -14,7 +14,7 @@ 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.database.SignalDatabase
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.profiles.AvatarHelper
@@ -70,11 +70,11 @@ class AvatarPickerRepository(context: Context) {
}
fun getPersistedAvatarsForSelf(): Single> = Single.fromCallable {
- DatabaseFactory.getAvatarPickerDatabase(applicationContext).getAvatarsForSelf()
+ SignalDatabase.avatarPicker.getAvatarsForSelf()
}
fun getPersistedAvatarsForGroup(groupId: GroupId): Single> = Single.fromCallable {
- DatabaseFactory.getAvatarPickerDatabase(applicationContext).getAvatarsForGroup(groupId)
+ SignalDatabase.avatarPicker.getAvatarsForGroup(groupId)
}
fun getDefaultAvatarsForSelf(): Single> = Single.fromCallable {
@@ -97,7 +97,7 @@ class AvatarPickerRepository(context: Context) {
fun persistAvatarForSelf(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
SignalExecutors.BOUNDED.execute {
- val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
+ val avatarDatabase = SignalDatabase.avatarPicker
val savedAvatar = avatarDatabase.saveAvatarForSelf(avatar)
avatarDatabase.markUsage(savedAvatar)
onPersisted(savedAvatar)
@@ -106,7 +106,7 @@ class AvatarPickerRepository(context: Context) {
fun persistAvatarForGroup(avatar: Avatar, groupId: GroupId, onPersisted: (Avatar) -> Unit) {
SignalExecutors.BOUNDED.execute {
- val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
+ val avatarDatabase = SignalDatabase.avatarPicker
val savedAvatar = avatarDatabase.saveAvatarForGroup(avatar, groupId)
avatarDatabase.markUsage(savedAvatar)
onPersisted(savedAvatar)
@@ -180,7 +180,7 @@ class AvatarPickerRepository(context: Context) {
fun delete(avatar: Avatar, onDelete: () -> Unit) {
SignalExecutors.BOUNDED.execute {
if (avatar.databaseId is Avatar.DatabaseId.Saved) {
- val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
+ val avatarDatabase = SignalDatabase.avatarPicker
avatarDatabase.deleteAvatar(avatar)
}
onDelete()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationFragment.kt
index 47b05bec776..7ea8f10957d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/text/TextAvatarCreationFragment.kt
@@ -27,8 +27,8 @@ import org.thoughtcrime.securesms.components.BoldSelectionTabItem
import org.thoughtcrime.securesms.components.ControllableTabLayout
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
-import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ViewUtil
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
/**
* Fragment to create an avatar based off of a Vector or Text (via a pager)
@@ -106,7 +106,7 @@ class TextAvatarCreationFragment : Fragment(R.layout.text_avatar_creation_fragme
Navigation.findNavController(v).popBackStack()
}
- textInput.setOnEditorActionListener { v, actionId, event ->
+ textInput.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_NEXT) {
tabLayout.getTabAt(1)?.select()
true
diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/vector/VectorAvatarCreationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/vector/VectorAvatarCreationFragment.kt
index 802ecd7d5db..99c87f93e6c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/avatar/vector/VectorAvatarCreationFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/vector/VectorAvatarCreationFragment.kt
@@ -15,8 +15,8 @@ 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
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
/**
* Fragment to create an avatar based off a default vector.
diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/view/AvatarView.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/view/AvatarView.kt
new file mode 100644
index 00000000000..cc190feaa83
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/view/AvatarView.kt
@@ -0,0 +1,94 @@
+package org.thoughtcrime.securesms.avatar.view
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import android.widget.FrameLayout
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.AvatarImageView
+import org.thoughtcrime.securesms.database.model.StoryViewState
+import org.thoughtcrime.securesms.mms.GlideRequests
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.stories.Stories
+import org.thoughtcrime.securesms.util.visible
+
+/**
+ * AvatarView encapsulating the AvatarImageView and decorations.
+ */
+class AvatarView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null
+) : FrameLayout(context, attrs) {
+
+ init {
+ inflate(context, R.layout.avatar_view, this)
+
+ isClickable = false
+ }
+
+ private val avatar: AvatarImageView = findViewById(R.id.avatar_image_view).apply {
+ initialize(context, attrs)
+ }
+
+ private val storyRing: View = findViewById(R.id.avatar_story_ring)
+
+ private fun showStoryRing(hasUnreadStory: Boolean) {
+ if (!Stories.isFeatureEnabled()) {
+ return
+ }
+
+ storyRing.visible = true
+ storyRing.isActivated = hasUnreadStory
+
+ avatar.scaleX = 0.8f
+ avatar.scaleY = 0.8f
+ }
+
+ private fun hideStoryRing() {
+ storyRing.visible = false
+
+ avatar.scaleX = 1f
+ avatar.scaleY = 1f
+ }
+
+ fun hasStory(): Boolean {
+ return storyRing.visible
+ }
+
+ fun setStoryRingFromState(storyViewState: StoryViewState) {
+ when (storyViewState) {
+ StoryViewState.NONE -> hideStoryRing()
+ StoryViewState.UNVIEWED -> showStoryRing(true)
+ StoryViewState.VIEWED -> showStoryRing(false)
+ }
+ }
+
+ /**
+ * Displays Note-to-Self
+ */
+ fun displayChatAvatar(recipient: Recipient) {
+ avatar.setAvatar(recipient)
+ }
+
+ /**
+ * Displays Note-to-Self
+ */
+ fun displayChatAvatar(requestManager: GlideRequests, recipient: Recipient, isQuickContactEnabled: Boolean) {
+ avatar.setAvatar(requestManager, recipient, isQuickContactEnabled)
+ }
+
+ /**
+ * Displays Profile image
+ */
+ fun displayProfileAvatar(recipient: Recipient) {
+ avatar.setRecipient(recipient)
+ }
+
+ fun setFallbackPhotoProvider(fallbackPhotoProvider: Recipient.FallbackPhotoProvider) {
+ avatar.setFallbackPhotoProvider(fallbackPhotoProvider)
+ }
+
+ fun disableQuickContact() {
+ avatar.disableQuickContact()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupCountQueries.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupCountQueries.kt
new file mode 100644
index 00000000000..aab1210ed78
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupCountQueries.kt
@@ -0,0 +1,30 @@
+package org.thoughtcrime.securesms.backup
+
+import org.thoughtcrime.securesms.database.AttachmentDatabase
+import org.thoughtcrime.securesms.database.GroupReceiptDatabase
+import org.thoughtcrime.securesms.database.MmsDatabase
+import org.thoughtcrime.securesms.database.SmsDatabase
+
+/**
+ * Queries used by backup exporter to estimate total counts for various complicated tables.
+ */
+object BackupCountQueries {
+
+ const val mmsCount: String = "SELECT COUNT(*) FROM ${MmsDatabase.TABLE_NAME} WHERE ${MmsDatabase.EXPIRES_IN} <= 0 AND ${MmsDatabase.VIEW_ONCE} <= 0"
+
+ const val smsCount: String = "SELECT COUNT(*) FROM ${SmsDatabase.TABLE_NAME} WHERE ${SmsDatabase.EXPIRES_IN} <= 0"
+
+ @get:JvmStatic
+ val groupReceiptCount: String = """
+ SELECT COUNT(*) FROM ${GroupReceiptDatabase.TABLE_NAME}
+ INNER JOIN ${MmsDatabase.TABLE_NAME} ON ${GroupReceiptDatabase.TABLE_NAME}.${GroupReceiptDatabase.MMS_ID} = ${MmsDatabase.TABLE_NAME}.${MmsDatabase.ID}
+ WHERE ${MmsDatabase.TABLE_NAME}.${MmsDatabase.EXPIRES_IN} <= 0 AND ${MmsDatabase.TABLE_NAME}.${MmsDatabase.VIEW_ONCE} <= 0
+ """.trimIndent()
+
+ @get:JvmStatic
+ val attachmentCount: String = """
+ SELECT COUNT(*) FROM ${AttachmentDatabase.TABLE_NAME}
+ INNER JOIN ${MmsDatabase.TABLE_NAME} ON ${AttachmentDatabase.TABLE_NAME}.${AttachmentDatabase.MMS_ID} = ${MmsDatabase.TABLE_NAME}.${MmsDatabase.ID}
+ WHERE ${MmsDatabase.TABLE_NAME}.${MmsDatabase.EXPIRES_IN} <= 0 AND ${MmsDatabase.TABLE_NAME}.${MmsDatabase.VIEW_ONCE} <= 0
+ """.trimIndent()
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java
index d71e5d4cf7b..444fdfd2405 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java
@@ -23,6 +23,8 @@
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -110,7 +112,7 @@ public static void showEnableBackupDialog(@NonNull Context context,
@RequiresApi(29)
public static void showChooseBackupLocationDialog(@NonNull Fragment fragment, int requestCode) {
- new AlertDialog.Builder(fragment.requireContext())
+ new MaterialAlertDialogBuilder(fragment.requireContext())
.setView(R.layout.backup_choose_location_dialog)
.setCancelable(true)
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
@@ -141,7 +143,7 @@ public static void showChooseBackupLocationDialog(@NonNull Fragment fragment, in
}
public static void showDisableBackupDialog(@NonNull Context context, @NonNull Runnable onBackupsDisabled) {
- new AlertDialog.Builder(context)
+ new MaterialAlertDialogBuilder(context)
.setTitle(R.string.BackupDialog_delete_backups)
.setMessage(R.string.BackupDialog_disable_and_delete_all_local_backups)
.setNegativeButton(android.R.string.cancel, null)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java
index d6e28422f89..d4effce360a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java
@@ -5,21 +5,19 @@
import androidx.annotation.Nullable;
import org.greenrobot.eventbus.EventBus;
-import org.signal.core.util.logging.Log;
-import org.whispersystems.libsignal.util.ByteUtil;
+import org.signal.libsignal.protocol.util.ByteUtil;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public abstract class FullBackupBase {
- @SuppressWarnings("unused")
- private static final String TAG = Log.tag(FullBackupBase.class);
+ private static final int DIGEST_ROUNDS = 250_000;
static class BackupStream {
static @NonNull byte[] getBackupKey(@NonNull String passphrase, @Nullable byte[] salt) {
try {
- EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0));
+ EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0, 0));
MessageDigest digest = MessageDigest.getInstance("SHA-512");
byte[] input = passphrase.replace(" ", "").getBytes();
@@ -27,8 +25,8 @@ static class BackupStream {
if (salt != null) digest.update(salt);
- for (int i=0;i<250000;i++) {
- if (i % 1000 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0));
+ for (int i = 0; i < DIGEST_ROUNDS; i++) {
+ if (i % 1000 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0, 0));
digest.update(hash);
hash = digest.digest(input);
}
@@ -47,20 +45,34 @@ public enum Type {
}
private final Type type;
- private final int count;
+ private final long count;
+ private final long estimatedTotalCount;
- BackupEvent(Type type, int count) {
- this.type = type;
- this.count = count;
+ BackupEvent(Type type, long count, long estimatedTotalCount) {
+ this.type = type;
+ this.count = count;
+ this.estimatedTotalCount = estimatedTotalCount;
}
public Type getType() {
return type;
}
- public int getCount() {
+ public long getCount() {
return count;
}
+
+ public long getEstimatedTotalCount() {
+ return estimatedTotalCount;
+ }
+
+ public double getCompletionPercentage() {
+ if (estimatedTotalCount == 0) {
+ return 0;
+ }
+
+ return Math.min(99.9f, (double) count * 100L / (double) estimatedTotalCount);
+ }
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java
index 81ad11f5ce0..b5c5a3015b2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java
@@ -13,24 +13,27 @@
import com.annimon.stream.function.Predicate;
import com.google.protobuf.ByteString;
-import net.sqlcipher.database.SQLiteDatabase;
+import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.greenrobot.eventbus.EventBus;
import org.signal.core.util.Conversions;
import org.signal.core.util.logging.Log;
+import org.signal.libsignal.protocol.kdf.HKDFv3;
+import org.signal.libsignal.protocol.util.ByteUtil;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
-import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
import org.thoughtcrime.securesms.database.KeyValueDatabase;
+import org.thoughtcrime.securesms.database.MentionDatabase;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase;
import org.thoughtcrime.securesms.database.PendingRetryReceiptDatabase;
+import org.thoughtcrime.securesms.database.ReactionDatabase;
import org.thoughtcrime.securesms.database.SearchDatabase;
import org.thoughtcrime.securesms.database.SenderKeyDatabase;
import org.thoughtcrime.securesms.database.SenderKeySharedDatabase;
@@ -39,17 +42,17 @@
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.StickerDatabase;
import org.thoughtcrime.securesms.database.model.AvatarPickerDatabase;
+import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
-import org.thoughtcrime.securesms.service.PendingRetryReceiptManager;
-import org.thoughtcrime.securesms.util.SetUtil;
+import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.signal.core.util.CursorUtil;
+import org.signal.core.util.SetUtil;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
-import org.whispersystems.libsignal.kdf.HKDFv3;
-import org.whispersystems.libsignal.util.ByteUtil;
import java.io.File;
import java.io.FileOutputStream;
@@ -76,6 +79,11 @@ public class FullBackupExporter extends FullBackupBase {
private static final String TAG = Log.tag(FullBackupExporter.class);
+ private static final long DATABASE_VERSION_RECORD_COUNT = 1L;
+ private static final long TABLE_RECORD_COUNT_MULTIPLIER = 3L;
+ private static final long IDENTITY_KEY_BACKUP_RECORD_COUNT = 2L;
+ private static final long FINAL_MESSAGE_COUNT = 1L;
+
private static final Set BLACKLISTED_TABLES = SetUtil.newHashSet(
SignedPreKeyDatabase.TABLE_NAME,
OneTimePreKeyDatabase.TABLE_NAME,
@@ -135,58 +143,60 @@ private static void internalExport(@NonNull Context context,
@NonNull BackupCancellationSignal cancellationSignal)
throws IOException
{
- BackupFrameOutputStream outputStream = new BackupFrameOutputStream(fileOutputStream, passphrase);
- int count = 0;
+ BackupFrameOutputStream outputStream = new BackupFrameOutputStream(fileOutputStream, passphrase);
+ int count = 0;
+ long estimatedCountOutside = 0L;
try {
outputStream.writeDatabaseVersion(input.getVersion());
count++;
List tables = exportSchema(input, outputStream);
- count += tables.size() * 3;
+ count += tables.size() * TABLE_RECORD_COUNT_MULTIPLIER;
+
+ final long estimatedCount = calculateCount(context, input, tables);
+ estimatedCountOutside = estimatedCount;
Stopwatch stopwatch = new Stopwatch("Backup");
for (String table : tables) {
throwIfCanceled(cancellationSignal);
if (table.equals(MmsDatabase.TABLE_NAME)) {
- count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringMmsMessage, null, count, cancellationSignal);
+ count = exportTable(table, input, outputStream, cursor -> isNonExpiringMmsMessage(cursor) && isNotReleaseChannel(cursor), null, count, estimatedCount, cancellationSignal);
} else if (table.equals(SmsDatabase.TABLE_NAME)) {
- count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringSmsMessage, null, count, cancellationSignal);
+ count = exportTable(table, input, outputStream, cursor -> isNonExpiringSmsMessage(cursor) && isNotReleaseChannel(cursor), null, count, estimatedCount, cancellationSignal);
+ } else if (table.equals(ReactionDatabase.TABLE_NAME)) {
+ count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, new MessageId(CursorUtil.requireLong(cursor, ReactionDatabase.MESSAGE_ID), CursorUtil.requireBoolean(cursor, ReactionDatabase.IS_MMS))), null, count, estimatedCount, cancellationSignal);
+ } else if (table.equals(MentionDatabase.TABLE_NAME)) {
+ count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessageAndNotReleaseChannel(input, CursorUtil.requireLong(cursor, MentionDatabase.MESSAGE_ID)), null, count, estimatedCount, cancellationSignal);
} else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) {
- count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count, cancellationSignal);
+ count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessageAndNotReleaseChannel(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count, estimatedCount, cancellationSignal);
} else if (table.equals(AttachmentDatabase.TABLE_NAME)) {
- count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount), count, cancellationSignal);
+ count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessageAndNotReleaseChannel(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
} else if (table.equals(StickerDatabase.TABLE_NAME)) {
- count = exportTable(table, input, outputStream, cursor -> true, (cursor, innerCount) -> exportSticker(attachmentSecret, cursor, outputStream, innerCount), count, cancellationSignal);
+ count = exportTable(table, input, outputStream, cursor -> true, (cursor, innerCount) -> exportSticker(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
} else if (!BLACKLISTED_TABLES.contains(table) && !table.startsWith("sqlite_")) {
- count = exportTable(table, input, outputStream, null, null, count, cancellationSignal);
+ count = exportTable(table, input, outputStream, null, null, count, estimatedCount, cancellationSignal);
}
stopwatch.split("table::" + table);
}
- for (BackupProtos.SharedPreference preference : IdentityKeyUtil.getBackupRecord(context)) {
- throwIfCanceled(cancellationSignal);
- EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
- outputStream.write(preference);
- }
-
for (BackupProtos.SharedPreference preference : TextSecurePreferences.getPreferencesToSaveToBackup(context)) {
throwIfCanceled(cancellationSignal);
- EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
+ EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(preference);
}
stopwatch.split("prefs");
- count = exportKeyValues(outputStream, SignalStore.getKeysToIncludeInBackup(), count, cancellationSignal);
+ count = exportKeyValues(outputStream, SignalStore.getKeysToIncludeInBackup(), count, estimatedCount, cancellationSignal);
stopwatch.split("key_values");
for (AvatarHelper.Avatar avatar : AvatarHelper.getAvatars(context)) {
throwIfCanceled(cancellationSignal);
if (avatar != null) {
- EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
+ EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(avatar.getFilename(), avatar.getInputStream(), avatar.getLength());
}
}
@@ -199,7 +209,49 @@ private static void internalExport(@NonNull Context context,
if (closeOutputStream) {
outputStream.close();
}
- EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count));
+ EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count, estimatedCountOutside));
+ }
+ }
+
+ private static long calculateCount(@NonNull Context context, @NonNull SQLiteDatabase input, List tables) {
+ long count = DATABASE_VERSION_RECORD_COUNT + TABLE_RECORD_COUNT_MULTIPLIER * tables.size();
+
+ for (String table : tables) {
+ if (table.equals(MmsDatabase.TABLE_NAME)) {
+ count += getCount(input, BackupCountQueries.mmsCount);
+ } else if (table.equals(SmsDatabase.TABLE_NAME)) {
+ count += getCount(input, BackupCountQueries.smsCount);
+ } else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) {
+ count += getCount(input, BackupCountQueries.getGroupReceiptCount());
+ } else if (table.equals(AttachmentDatabase.TABLE_NAME)) {
+ count += getCount(input, BackupCountQueries.getAttachmentCount());
+ } else if (table.equals(StickerDatabase.TABLE_NAME)) {
+ count += getCount(input, "SELECT COUNT(*) FROM " + table);
+ } else if (!BLACKLISTED_TABLES.contains(table) && !table.startsWith("sqlite_")) {
+ count += getCount(input, "SELECT COUNT(*) FROM " + table);
+ }
+ }
+
+ count += IDENTITY_KEY_BACKUP_RECORD_COUNT;
+
+ count += TextSecurePreferences.getPreferencesToSaveToBackupCount(context);
+
+ KeyValueDataSet dataSet = KeyValueDatabase.getInstance(ApplicationDependencies.getApplication())
+ .getDataSet();
+ for (String key : SignalStore.getKeysToIncludeInBackup()) {
+ if (dataSet.containsKey(key)) {
+ count++;
+ }
+ }
+
+ count += AvatarHelper.getAvatarCount(context);
+
+ return count + FINAL_MESSAGE_COUNT;
+ }
+
+ private static long getCount(@NonNull SQLiteDatabase input, @NonNull String query) {
+ try (Cursor cursor = input.rawQuery(query)) {
+ return cursor.moveToFirst() ? cursor.getLong(0) : 0;
}
}
@@ -245,6 +297,7 @@ private static int exportTable(@NonNull String table,
@Nullable Predicate predicate,
@Nullable PostProcessor postProcess,
int count,
+ long estimatedCount,
@NonNull BackupCancellationSignal cancellationSignal)
throws IOException
{
@@ -284,7 +337,7 @@ private static int exportTable(@NonNull String table,
statement.append(')');
- EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
+ EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(statementBuilder.setStatement(statement.toString()).build());
if (postProcess != null) {
@@ -297,7 +350,7 @@ private static int exportTable(@NonNull String table,
return count;
}
- private static int exportAttachment(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count) {
+ private static int exportAttachment(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count, long estimatedCount) {
try {
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID));
long uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID));
@@ -322,7 +375,7 @@ private static int exportAttachment(@NonNull AttachmentSecret attachmentSecret,
if (random != null && random.length == 32) inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
else inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data));
- EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
+ EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(new AttachmentId(rowId, uniqueId), inputStream, size);
}
} catch (IOException e) {
@@ -332,7 +385,7 @@ private static int exportAttachment(@NonNull AttachmentSecret attachmentSecret,
return count;
}
- private static int exportSticker(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count) {
+ private static int exportSticker(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count, long estimatedCount) {
try {
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase._ID));
long size = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_LENGTH));
@@ -341,7 +394,7 @@ private static int exportSticker(@NonNull AttachmentSecret attachmentSecret, @No
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_RANDOM));
if (!TextUtils.isEmpty(data) && size > 0) {
- EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
+ EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
outputStream.writeSticker(rowId, inputStream, size);
}
@@ -372,6 +425,7 @@ private static long calculateVeryOldStreamLength(@NonNull AttachmentSecret attac
private static int exportKeyValues(@NonNull BackupFrameOutputStream outputStream,
@NonNull List keysToIncludeInBackup,
int count,
+ long estimatedCount,
BackupCancellationSignal cancellationSignal) throws IOException
{
KeyValueDataSet dataSet = KeyValueDatabase.getInstance(ApplicationDependencies.getApplication())
@@ -387,7 +441,12 @@ private static int exportKeyValues(@NonNull BackupFrameOutputStream outputStream
Class> type = dataSet.getType(key);
if (type == byte[].class) {
- builder.setBlobValue(ByteString.copyFrom(dataSet.getBlob(key, null)));
+ byte[] data = dataSet.getBlob(key, null);
+ if (data != null) {
+ builder.setBlobValue(ByteString.copyFrom(dataSet.getBlob(key, null)));
+ } else {
+ Log.w(TAG, "Skipping storing null blob for key: " + key);
+ }
} else if (type == Boolean.class) {
builder.setBooleanValue(dataSet.getBoolean(key, false));
} else if (type == Float.class) {
@@ -397,12 +456,17 @@ private static int exportKeyValues(@NonNull BackupFrameOutputStream outputStream
} else if (type == Long.class) {
builder.setLongValue(dataSet.getLong(key, 0));
} else if (type == String.class) {
- builder.setStringValue(dataSet.getString(key, null));
+ String data = dataSet.getString(key, null);
+ if (data != null) {
+ builder.setStringValue(dataSet.getString(key, null));
+ } else {
+ Log.w(TAG, "Skipping storing null string for key: " + key);
+ }
} else {
throw new AssertionError("Unknown type: " + type);
}
- EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
+ EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(builder.build());
}
@@ -410,29 +474,54 @@ private static int exportKeyValues(@NonNull BackupFrameOutputStream outputStream
}
private static boolean isNonExpiringMmsMessage(@NonNull Cursor cursor) {
- return cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0 &&
- cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) <= 0;
+ return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0 &&
+ cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) <= 0;
}
private static boolean isNonExpiringSmsMessage(@NonNull Cursor cursor) {
- return cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0;
+ return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0;
+ }
+
+ private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, @NonNull MessageId messageId) {
+ if (messageId.isMms()) {
+ return isForNonExpiringMmsMessageAndNotReleaseChannel(db, messageId.getId());
+ } else {
+ return isForNonExpiringSmsMessage(db, messageId.getId());
+ }
}
- private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, long mmsId) {
- String[] columns = new String[] { MmsDatabase.EXPIRES_IN, MmsDatabase.VIEW_ONCE};
+ private static boolean isForNonExpiringSmsMessage(@NonNull SQLiteDatabase db, long smsId) {
+ String[] columns = new String[] { SmsDatabase.EXPIRES_IN };
+ String where = SmsDatabase.ID + " = ?";
+ String[] args = new String[] { String.valueOf(smsId) };
+
+ try (Cursor cursor = db.query(SmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ return isNonExpiringSmsMessage(cursor);
+ }
+ }
+
+ return false;
+ }
+
+ private static boolean isForNonExpiringMmsMessageAndNotReleaseChannel(@NonNull SQLiteDatabase db, long mmsId) {
+ String[] columns = new String[] { MmsDatabase.RECIPIENT_ID, MmsDatabase.EXPIRES_IN, MmsDatabase.VIEW_ONCE};
String where = MmsDatabase.ID + " = ?";
String[] args = new String[] { String.valueOf(mmsId) };
try (Cursor mmsCursor = db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) {
if (mmsCursor != null && mmsCursor.moveToFirst()) {
- return mmsCursor.getLong(mmsCursor.getColumnIndexOrThrow(MmsDatabase.EXPIRES_IN)) == 0 &&
- mmsCursor.getLong(mmsCursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 0;
+ return isNonExpiringMmsMessage(mmsCursor) && isNotReleaseChannel(mmsCursor);
}
}
return false;
}
+ private static boolean isNotReleaseChannel(Cursor cursor) {
+ RecipientId releaseChannel = SignalStore.releaseChannelValues().getReleaseChannelRecipientId();
+ return releaseChannel == null || cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.RECIPIENT_ID)) != releaseChannel.toLong();
+ }
private static class BackupFrameOutputStream extends BackupStream {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java
index 9fb8b43ef76..09fe1c2f150 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java
@@ -11,12 +11,14 @@
import androidx.annotation.NonNull;
-import net.sqlcipher.database.SQLiteDatabase;
+import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.greenrobot.eventbus.EventBus;
import org.signal.core.util.Conversions;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.logging.Log;
+import org.signal.libsignal.protocol.kdf.HKDFv3;
+import org.signal.libsignal.protocol.util.ByteUtil;
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment;
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame;
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion;
@@ -32,12 +34,11 @@
import org.thoughtcrime.securesms.database.StickerDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
+import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.BackupUtil;
-import org.thoughtcrime.securesms.util.SqlUtil;
-import org.whispersystems.libsignal.kdf.HKDFv3;
-import org.whispersystems.libsignal.util.ByteUtil;
+import org.signal.core.util.SqlUtil;
import java.io.ByteArrayOutputStream;
import java.io.File;
@@ -68,6 +69,18 @@ public class FullBackupImporter extends FullBackupBase {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(FullBackupImporter.class);
+ private static final String[] TABLES_TO_DROP_FIRST = {
+ "distribution_list_member",
+ "distribution_list",
+ "message_send_log_recipients",
+ "msl_recipient",
+ "msl_message",
+ "reaction",
+ "notification_profile_schedule",
+ "notification_profile_allowed_members",
+ "story_sends"
+ };
+
public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret,
@NonNull SQLiteDatabase db, @NonNull Uri uri, @NonNull String passphrase)
throws IOException
@@ -84,18 +97,18 @@ public static void importFile(@NonNull Context context, @NonNull AttachmentSecre
int count = 0;
SQLiteDatabase keyValueDatabase = KeyValueDatabase.getInstance(ApplicationDependencies.getApplication()).getSqlCipherDatabase();
+
+ db.beginTransaction();
+ keyValueDatabase.beginTransaction();
try {
BackupRecordInputStream inputStream = new BackupRecordInputStream(is, passphrase);
- db.beginTransaction();
- keyValueDatabase.beginTransaction();
-
dropAllTables(db);
BackupFrame frame;
while (!(frame = inputStream.readFrame()).getEnd()) {
- if (count % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, count));
+ if (count % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, count, 0));
count++;
if (frame.hasVersion()) processVersion(db, frame.getVersion());
@@ -115,7 +128,7 @@ public static void importFile(@NonNull Context context, @NonNull AttachmentSecre
keyValueDatabase.endTransaction();
}
- EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, count));
+ EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, count, 0));
}
private static @NonNull InputStream getInputStream(@NonNull Context context, @NonNull Uri uri) throws IOException{
@@ -162,9 +175,8 @@ private static void processStatement(@NonNull SQLiteDatabase db, SqlStatement st
private static void processAttachment(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Attachment attachment, BackupRecordInputStream inputStream)
throws IOException
{
- File partsDirectory = context.getDir(AttachmentDatabase.DIRECTORY, Context.MODE_PRIVATE);
- File dataFile = File.createTempFile("part", ".mms", partsDirectory);
- Pair output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
+ File dataFile = AttachmentDatabase.newFile(context);
+ Pair output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
ContentValues contentValues = new ContentValues();
@@ -208,7 +220,7 @@ private static void processSticker(@NonNull Context context, @NonNull Attachment
private static void processAvatar(@NonNull Context context, @NonNull SQLiteDatabase db, @NonNull BackupProtos.Avatar avatar, @NonNull BackupRecordInputStream inputStream) throws IOException {
if (avatar.hasRecipientId()) {
RecipientId recipientId = RecipientId.from(avatar.getRecipientId());
- inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId), avatar.getLength());
+ inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId, false), avatar.getLength());
} else {
if (avatar.hasName() && SqlUtil.tableExists(db, "recipient_preferences")) {
Log.w(TAG, "Avatar is missing a recipientId. Clearing signal_profile_avatar (legacy) so it can be fetched later.");
@@ -251,6 +263,17 @@ private static void processKeyValue(BackupProtos.KeyValue keyValue) {
private static void processPreference(@NonNull Context context, SharedPreference preference) {
SharedPreferences preferences = context.getSharedPreferences(preference.getFile(), 0);
+ // Identity keys were moved from shared prefs into SignalStore. Need to handle importing backups made before the migration.
+ if ("SecureSMS-Preferences".equals(preference.getFile())) {
+ if ("pref_identity_public_v3".equals(preference.getKey()) && preference.hasValue()) {
+ SignalStore.account().restoreLegacyIdentityPublicKeyFromBackup(preference.getValue());
+ } else if ("pref_identity_private_v3".equals(preference.getKey()) && preference.hasValue()) {
+ SignalStore.account().restoreLegacyIdentityPrivateKeyFromBackup(preference.getValue());
+ }
+
+ return;
+ }
+
if (preference.hasValue()) {
preferences.edit().putString(preference.getKey(), preference.getValue()).commit();
} else if (preference.hasBooleanValue()) {
@@ -261,12 +284,17 @@ private static void processPreference(@NonNull Context context, SharedPreference
}
private static void dropAllTables(@NonNull SQLiteDatabase db) {
+ for (String name : TABLES_TO_DROP_FIRST) {
+ db.execSQL("DROP TABLE IF EXISTS " + name);
+ }
+
try (Cursor cursor = db.rawQuery("SELECT name, type FROM sqlite_master", null)) {
while (cursor != null && cursor.moveToNext()) {
String name = cursor.getString(0);
String type = cursor.getString(1);
if ("table".equals(type) && !name.startsWith("sqlite_")) {
+ Log.i(TAG, "Dropping table: " + name);
db.execSQL("DROP TABLE IF EXISTS " + name);
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeImageView.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeImageView.kt
new file mode 100644
index 00000000000..66d702f3c15
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeImageView.kt
@@ -0,0 +1,93 @@
+package org.thoughtcrime.securesms.badges
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.core.content.res.use
+import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
+import org.thoughtcrime.securesms.badges.models.Badge
+import org.thoughtcrime.securesms.mms.GlideApp
+import org.thoughtcrime.securesms.mms.GlideRequests
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.util.ThemeUtil
+
+class BadgeImageView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null
+) : AppCompatImageView(context, attrs) {
+
+ private var badgeSize: Int = 0
+
+ init {
+ context.obtainStyledAttributes(attrs, R.styleable.BadgeImageView).use {
+ badgeSize = it.getInt(R.styleable.BadgeImageView_badge_size, 0)
+ }
+
+ isClickable = false
+ }
+
+ override fun setOnClickListener(l: OnClickListener?) {
+ val wasClickable = isClickable
+ super.setOnClickListener(l)
+ this.isClickable = wasClickable
+ }
+
+ fun setBadgeFromRecipient(recipient: Recipient?) {
+ getGlideRequests()?.let {
+ setBadgeFromRecipient(recipient, it)
+ } ?: clearDrawable()
+ }
+
+ fun setBadgeFromRecipient(recipient: Recipient?, glideRequests: GlideRequests) {
+ if (recipient == null || recipient.badges.isEmpty()) {
+ setBadge(null, glideRequests)
+ } else if (recipient.isSelf) {
+ val badge = recipient.featuredBadge
+ if (badge == null || !badge.visible || badge.isExpired()) {
+ setBadge(null, glideRequests)
+ } else {
+ setBadge(badge, glideRequests)
+ }
+ } else {
+ setBadge(recipient.featuredBadge, glideRequests)
+ }
+ }
+
+ fun setBadge(badge: Badge?) {
+ getGlideRequests()?.let {
+ setBadge(badge, it)
+ } ?: clearDrawable()
+ }
+
+ fun setBadge(badge: Badge?, glideRequests: GlideRequests) {
+ if (badge != null) {
+ glideRequests
+ .load(badge)
+ .downsample(DownsampleStrategy.NONE)
+ .transform(BadgeSpriteTransformation(BadgeSpriteTransformation.Size.fromInteger(badgeSize), badge.imageDensity, ThemeUtil.isDarkTheme(context)))
+ .into(this)
+
+ isClickable = true
+ } else {
+ glideRequests
+ .clear(this)
+ clearDrawable()
+ }
+ }
+
+ private fun clearDrawable() {
+ setImageDrawable(null)
+ isClickable = false
+ }
+
+ private fun getGlideRequests(): GlideRequests? {
+ return try {
+ GlideApp.with(this)
+ } catch (e: IllegalArgumentException) {
+ // View not attached to an activity or activity destroyed
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeRepository.kt
new file mode 100644
index 00000000000..258ed47786b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeRepository.kt
@@ -0,0 +1,41 @@
+package org.thoughtcrime.securesms.badges
+
+import android.content.Context
+import io.reactivex.rxjava3.core.Completable
+import io.reactivex.rxjava3.schedulers.Schedulers
+import org.thoughtcrime.securesms.badges.models.Badge
+import org.thoughtcrime.securesms.database.RecipientDatabase
+import org.thoughtcrime.securesms.database.SignalDatabase
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.storage.StorageSyncHelper
+import org.thoughtcrime.securesms.util.ProfileUtil
+
+class BadgeRepository(context: Context) {
+
+ private val context = context.applicationContext
+
+ fun setVisibilityForAllBadges(
+ displayBadgesOnProfile: Boolean,
+ selfBadges: List = Recipient.self().badges
+ ): Completable = Completable.fromAction {
+ val recipientDatabase: RecipientDatabase = SignalDatabase.recipients
+ val badges = selfBadges.map { it.copy(visible = displayBadgesOnProfile) }
+
+ ProfileUtil.uploadProfileWithBadges(context, badges)
+ SignalStore.donationsValues().setDisplayBadgesOnProfile(displayBadgesOnProfile)
+ recipientDatabase.markNeedsSync(Recipient.self().id)
+ StorageSyncHelper.scheduleSyncForDataChange()
+
+ recipientDatabase.setBadges(Recipient.self().id, badges)
+ }.subscribeOn(Schedulers.io())
+
+ fun setFeaturedBadge(featuredBadge: Badge): Completable = Completable.fromAction {
+ val badges = Recipient.self().badges
+ val reOrderedBadges = listOf(featuredBadge.copy(visible = true)) + (badges.filterNot { it.id == featuredBadge.id })
+ ProfileUtil.uploadProfileWithBadges(context, reOrderedBadges)
+
+ val recipientDatabase: RecipientDatabase = SignalDatabase.recipients
+ recipientDatabase.setBadges(Recipient.self().id, reOrderedBadges)
+ }.subscribeOn(Schedulers.io())
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/Badges.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/Badges.kt
new file mode 100644
index 00000000000..98fb35fa02a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/Badges.kt
@@ -0,0 +1,128 @@
+package org.thoughtcrime.securesms.badges
+
+import android.content.Context
+import android.net.Uri
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.flexbox.AlignItems
+import com.google.android.flexbox.FlexDirection
+import com.google.android.flexbox.FlexboxLayoutManager
+import com.google.android.flexbox.JustifyContent
+import org.signal.core.util.DimensionUnit
+import org.signal.core.util.logging.Log
+import org.signal.libsignal.protocol.util.Pair
+import org.thoughtcrime.securesms.BuildConfig
+import org.thoughtcrime.securesms.badges.models.Badge
+import org.thoughtcrime.securesms.badges.models.Badge.Category.Companion.fromCode
+import org.thoughtcrime.securesms.components.settings.DSLConfiguration
+import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
+import org.thoughtcrime.securesms.util.ScreenDensity
+import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
+import java.math.BigDecimal
+import java.sql.Timestamp
+
+object Badges {
+
+ private val TAG: String = Log.tag(Badges::class.java)
+
+ fun DSLConfiguration.displayBadges(
+ context: Context,
+ badges: List,
+ selectedBadge: Badge? = null,
+ fadedBadgeId: String? = null
+ ) {
+ badges
+ .map {
+ Badge.Model(
+ badge = it,
+ isSelected = it == selectedBadge,
+ isFaded = it.id == fadedBadgeId
+ )
+ }
+ .forEach { customPref(it) }
+
+ val badgeSize = DimensionUnit.DP.toPixels(88f)
+ val windowWidth = context.resources.displayMetrics.widthPixels
+ val perRow = (windowWidth / badgeSize).toInt()
+
+ val empties = ((perRow - (badges.size % perRow)) % perRow)
+ repeat(empties) {
+ customPref(Badge.EmptyModel())
+ }
+ }
+
+ fun createLayoutManagerForGridWithBadges(context: Context): RecyclerView.LayoutManager {
+ val layoutManager = FlexboxLayoutManager(context)
+
+ layoutManager.flexDirection = FlexDirection.ROW
+ layoutManager.alignItems = AlignItems.CENTER
+ layoutManager.justifyContent = JustifyContent.CENTER
+
+ return layoutManager
+ }
+
+ private fun getBadgeImageUri(densityPath: String): Uri {
+ return Uri.parse(BuildConfig.BADGE_STATIC_ROOT).buildUpon()
+ .appendPath(densityPath)
+ .build()
+ }
+
+ private fun getBestBadgeImageUriForDevice(serviceBadge: SignalServiceProfile.Badge): Pair {
+ return when (ScreenDensity.getBestDensityBucketForDevice()) {
+ "ldpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[0]), "ldpi")
+ "mdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[1]), "mdpi")
+ "hdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[2]), "hdpi")
+ "xxhdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[4]), "xxhdpi")
+ "xxxhdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[5]), "xxxhdpi")
+ else -> Pair(getBadgeImageUri(serviceBadge.sprites6[3]), "xhdpi")
+ }.also {
+ Log.d(TAG, "Selected badge density ${it.second()}")
+ }
+ }
+
+ private fun getTimestamp(bigDecimal: BigDecimal): Long {
+ return Timestamp(bigDecimal.toLong() * 1000).time
+ }
+
+ @JvmStatic
+ fun fromDatabaseBadge(badge: BadgeList.Badge): Badge {
+ return Badge(
+ badge.id,
+ fromCode(badge.category),
+ badge.name,
+ badge.description,
+ Uri.parse(badge.imageUrl),
+ badge.imageDensity,
+ badge.expiration,
+ badge.visible
+ )
+ }
+
+ @JvmStatic
+ fun toDatabaseBadge(badge: Badge): BadgeList.Badge {
+ return BadgeList.Badge.newBuilder()
+ .setId(badge.id)
+ .setCategory(badge.category.code)
+ .setDescription(badge.description)
+ .setExpiration(badge.expirationTimestamp)
+ .setVisible(badge.visible)
+ .setName(badge.name)
+ .setImageUrl(badge.imageUrl.toString())
+ .setImageDensity(badge.imageDensity)
+ .build()
+ }
+
+ @JvmStatic
+ fun fromServiceBadge(serviceBadge: SignalServiceProfile.Badge): Badge {
+ val uriAndDensity: Pair = getBestBadgeImageUriForDevice(serviceBadge)
+ return Badge(
+ serviceBadge.id,
+ fromCode(serviceBadge.category),
+ serviceBadge.name,
+ serviceBadge.description,
+ uriAndDensity.first(),
+ uriAndDensity.second(),
+ serviceBadge.expiration?.let { getTimestamp(it) } ?: 0,
+ serviceBadge.isVisible
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/glide/BadgeSpriteTransformation.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/glide/BadgeSpriteTransformation.kt
new file mode 100644
index 00000000000..e6ade02d042
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/glide/BadgeSpriteTransformation.kt
@@ -0,0 +1,165 @@
+package org.thoughtcrime.securesms.badges.glide
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Rect
+import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
+import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
+import java.security.MessageDigest
+
+/**
+ * Cuts out the badge of the requested size from the sprite sheet.
+ */
+class BadgeSpriteTransformation(
+ private val size: Size,
+ private val density: String,
+ private val isDarkTheme: Boolean
+) : BitmapTransformation() {
+
+ private val id = "BadgeSpriteTransformation(${size.code},$density,$isDarkTheme).$VERSION"
+
+ override fun updateDiskCacheKey(messageDigest: MessageDigest) {
+ messageDigest.update(id.toByteArray(CHARSET))
+ }
+
+ override fun equals(other: Any?): Boolean {
+ return (other as? BadgeSpriteTransformation)?.id == id
+ }
+
+ override fun hashCode(): Int {
+ return id.hashCode()
+ }
+
+ override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
+ val outBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(outBitmap)
+ val inBounds = getInBounds(density, size, isDarkTheme)
+ val outBounds = Rect(0, 0, outWidth, outHeight)
+
+ canvas.drawBitmap(toTransform, inBounds, outBounds, null)
+
+ return outBitmap
+ }
+
+ enum class Size(val code: String, val frameMap: Map) {
+ SMALL(
+ "small",
+ mapOf(
+ Density.LDPI to FrameSet(Frame(124, 1, 12, 12), Frame(145, 31, 12, 12)),
+ Density.MDPI to FrameSet(Frame(163, 1, 16, 16), Frame(189, 39, 16, 16)),
+ Density.HDPI to FrameSet(Frame(244, 1, 24, 24), Frame(283, 58, 24, 24)),
+ Density.XHDPI to FrameSet(Frame(323, 1, 32, 32), Frame(373, 75, 32, 32)),
+ Density.XXHDPI to FrameSet(Frame(483, 1, 48, 48), Frame(557, 111, 48, 48)),
+ Density.XXXHDPI to FrameSet(Frame(643, 1, 64, 64), Frame(741, 147, 64, 64))
+ )
+ ),
+ MEDIUM(
+ "medium",
+ mapOf(
+ Density.LDPI to FrameSet(Frame(124, 16, 18, 18), Frame(160, 31, 18, 18)),
+ Density.MDPI to FrameSet(Frame(163, 19, 24, 24), Frame(207, 39, 24, 24)),
+ Density.HDPI to FrameSet(Frame(244, 28, 36, 36), Frame(310, 58, 36, 36)),
+ Density.XHDPI to FrameSet(Frame(323, 35, 48, 48), Frame(407, 75, 48, 48)),
+ Density.XXHDPI to FrameSet(Frame(483, 51, 72, 72), Frame(607, 111, 72, 72)),
+ Density.XXXHDPI to FrameSet(Frame(643, 67, 96, 96), Frame(807, 147, 96, 96))
+ )
+ ),
+ LARGE(
+ "large",
+ mapOf(
+ Density.LDPI to FrameSet(Frame(145, 1, 27, 27), Frame(124, 46, 27, 27)),
+ Density.MDPI to FrameSet(Frame(189, 1, 36, 36), Frame(163, 57, 36, 36)),
+ Density.HDPI to FrameSet(Frame(283, 1, 54, 54), Frame(244, 85, 54, 54)),
+ Density.XHDPI to FrameSet(Frame(373, 1, 72, 72), Frame(323, 109, 72, 72)),
+ Density.XXHDPI to FrameSet(Frame(557, 1, 108, 108), Frame(483, 161, 108, 108)),
+ Density.XXXHDPI to FrameSet(Frame(741, 1, 144, 144), Frame(643, 213, 144, 144))
+ )
+ ),
+ BADGE_64(
+ "badge_64",
+ mapOf(
+ Density.LDPI to FrameSet(Frame(124, 73, 48, 48), Frame(124, 73, 48, 48)),
+ Density.MDPI to FrameSet(Frame(163, 97, 64, 64), Frame(163, 97, 64, 64)),
+ Density.HDPI to FrameSet(Frame(244, 145, 96, 96), Frame(244, 145, 96, 96)),
+ Density.XHDPI to FrameSet(Frame(323, 193, 128, 128), Frame(323, 193, 128, 128)),
+ Density.XXHDPI to FrameSet(Frame(483, 289, 192, 192), Frame(483, 289, 192, 192)),
+ Density.XXXHDPI to FrameSet(Frame(643, 385, 256, 256), Frame(643, 385, 256, 256))
+ )
+ ),
+ BADGE_112(
+ "badge_112",
+ mapOf(
+ Density.LDPI to FrameSet(Frame(181, 1, 84, 84), Frame(181, 1, 84, 84)),
+ Density.MDPI to FrameSet(Frame(233, 1, 112, 112), Frame(233, 1, 112, 112)),
+ Density.HDPI to FrameSet(Frame(349, 1, 168, 168), Frame(349, 1, 168, 168)),
+ Density.XHDPI to FrameSet(Frame(457, 1, 224, 224), Frame(457, 1, 224, 224)),
+ Density.XXHDPI to FrameSet(Frame(681, 1, 336, 336), Frame(681, 1, 336, 336)),
+ Density.XXXHDPI to FrameSet(Frame(905, 1, 448, 448), Frame(905, 1, 448, 448))
+ )
+ ),
+ XLARGE(
+ "xlarge",
+ mapOf(
+ Density.LDPI to FrameSet(Frame(1, 1, 120, 120), Frame(1, 1, 120, 120)),
+ Density.MDPI to FrameSet(Frame(1, 1, 160, 160), Frame(1, 1, 160, 160)),
+ Density.HDPI to FrameSet(Frame(1, 1, 240, 240), Frame(1, 1, 240, 240)),
+ Density.XHDPI to FrameSet(Frame(1, 1, 320, 320), Frame(1, 1, 320, 320)),
+ Density.XXHDPI to FrameSet(Frame(1, 1, 480, 480), Frame(1, 1, 480, 480)),
+ Density.XXXHDPI to FrameSet(Frame(1, 1, 640, 640), Frame(1, 1, 640, 640))
+ )
+ );
+
+ companion object {
+ fun fromInteger(integer: Int): Size {
+ return when (integer) {
+ 0 -> SMALL
+ 1 -> MEDIUM
+ 2 -> LARGE
+ 3 -> XLARGE
+ 4 -> BADGE_64
+ 5 -> BADGE_112
+ else -> LARGE
+ }
+ }
+ }
+ }
+
+ enum class Density(val density: String) {
+ LDPI("ldpi"),
+ MDPI("mdpi"),
+ HDPI("hdpi"),
+ XHDPI("xhdpi"),
+ XXHDPI("xxhdpi"),
+ XXXHDPI("xxxhdpi")
+ }
+
+ data class FrameSet(val light: Frame, val dark: Frame)
+
+ data class Frame(
+ val x: Int,
+ val y: Int,
+ val width: Int,
+ val height: Int
+ ) {
+ fun toBounds(): Rect {
+ return Rect(x, y, x + width, y + height)
+ }
+ }
+
+ companion object {
+ private const val VERSION = 3
+
+ private fun getDensity(density: String): Density {
+ return Density.values().first { it.density == density }
+ }
+
+ private fun getFrame(size: Size, density: Density, isDarkTheme: Boolean): Frame {
+ val frameSet: FrameSet = size.frameMap[density]!!
+ return if (isDarkTheme) frameSet.dark else frameSet.light
+ }
+
+ private fun getInBounds(density: String, size: Size, isDarkTheme: Boolean): Rect {
+ return getFrame(size, getDensity(density), isDarkTheme).toBounds()
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt
new file mode 100644
index 00000000000..aa20dcbbe52
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt
@@ -0,0 +1,173 @@
+package org.thoughtcrime.securesms.badges.models
+
+import android.animation.ObjectAnimator
+import android.net.Uri
+import android.os.Parcelable
+import android.view.View
+import android.widget.ImageView
+import android.widget.TextView
+import com.bumptech.glide.load.Key
+import com.bumptech.glide.load.engine.DiskCacheStrategy
+import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
+import kotlinx.parcelize.Parcelize
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
+import org.thoughtcrime.securesms.components.settings.PreferenceModel
+import org.thoughtcrime.securesms.mms.GlideApp
+import org.thoughtcrime.securesms.util.ThemeUtil
+import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
+import java.security.MessageDigest
+
+typealias OnBadgeClicked = (Badge, Boolean, Boolean) -> Unit
+
+/**
+ * A Badge that can be collected and displayed by a user.
+ */
+@Parcelize
+data class Badge(
+ val id: String,
+ val category: Category,
+ val name: String,
+ val description: String,
+ val imageUrl: Uri,
+ val imageDensity: String,
+ val expirationTimestamp: Long,
+ val visible: Boolean,
+) : Parcelable, Key {
+
+ fun isExpired(): Boolean = expirationTimestamp < System.currentTimeMillis() && expirationTimestamp > 0
+ fun isBoost(): Boolean = id == BOOST_BADGE_ID
+
+ override fun updateDiskCacheKey(messageDigest: MessageDigest) {
+ messageDigest.update(id.toByteArray(Key.CHARSET))
+ messageDigest.update(imageUrl.toString().toByteArray(Key.CHARSET))
+ messageDigest.update(imageDensity.toByteArray(Key.CHARSET))
+ }
+
+ fun resolveDescription(shortName: String): String {
+ return description.replace("{short_name}", shortName)
+ }
+
+ class EmptyModel : PreferenceModel() {
+ override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
+ }
+
+ class EmptyViewHolder(itemView: View) : MappingViewHolder(itemView) {
+
+ private val name: TextView = itemView.findViewById(R.id.name)
+
+ init {
+ itemView.isEnabled = false
+ itemView.isFocusable = false
+ itemView.isClickable = false
+ itemView.visibility = View.INVISIBLE
+
+ name.text = " "
+ }
+
+ override fun bind(model: EmptyModel) = Unit
+ }
+
+ class Model(
+ val badge: Badge,
+ val isSelected: Boolean = false,
+ val isFaded: Boolean = false
+ ) : PreferenceModel() {
+ override fun areItemsTheSame(newItem: Model): Boolean {
+ return newItem.badge.id == badge.id
+ }
+
+ override fun areContentsTheSame(newItem: Model): Boolean {
+ return super.areContentsTheSame(newItem) &&
+ badge == newItem.badge &&
+ isSelected == newItem.isSelected &&
+ isFaded == newItem.isFaded
+ }
+
+ override fun getChangePayload(newItem: Model): Any? {
+ return if (badge == newItem.badge && isSelected != newItem.isSelected) {
+ SELECTION_CHANGED
+ } else {
+ null
+ }
+ }
+ }
+
+ class ViewHolder(itemView: View, private val onBadgeClicked: OnBadgeClicked) : MappingViewHolder(itemView) {
+
+ private val check: ImageView = itemView.findViewById(R.id.checkmark)
+ private val badge: ImageView = itemView.findViewById(R.id.badge)
+ private val name: TextView = itemView.findViewById(R.id.name)
+
+ private var checkAnimator: ObjectAnimator? = null
+
+ init {
+ check.isSelected = true
+ }
+
+ override fun bind(model: Model) {
+ itemView.setOnClickListener {
+ onBadgeClicked(model.badge, model.isSelected, model.isFaded)
+ }
+
+ checkAnimator?.cancel()
+ if (payload.isNotEmpty()) {
+ checkAnimator = if (model.isSelected) {
+ ObjectAnimator.ofFloat(check, "alpha", 1f)
+ } else {
+ ObjectAnimator.ofFloat(check, "alpha", 0f)
+ }
+ checkAnimator?.start()
+ return
+ }
+
+ badge.alpha = if (model.badge.isExpired() || model.isFaded) 0.5f else 1f
+
+ GlideApp.with(badge)
+ .load(model.badge)
+ .downsample(DownsampleStrategy.NONE)
+ .diskCacheStrategy(DiskCacheStrategy.NONE)
+ .transform(
+ BadgeSpriteTransformation(BadgeSpriteTransformation.Size.BADGE_64, model.badge.imageDensity, ThemeUtil.isDarkTheme(context)),
+ )
+ .into(badge)
+
+ if (model.isSelected) {
+ check.alpha = 1f
+ } else {
+ check.alpha = 0f
+ }
+
+ name.text = model.badge.name
+ }
+ }
+
+ enum class Category(val code: String) {
+ Donor("donor"),
+ Other("other"),
+ Testing("testing"); // Will be removed before final release
+
+ companion object {
+ fun fromCode(code: String): Category {
+ return when (code) {
+ "donor" -> Donor
+ "testing" -> Testing
+ else -> Other
+ }
+ }
+ }
+ }
+
+ companion object {
+ const val BOOST_BADGE_ID = "BOOST"
+
+ private val SELECTION_CHANGED = Any()
+
+ fun register(mappingAdapter: MappingAdapter, onBadgeClicked: OnBadgeClicked) {
+ mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onBadgeClicked) }, R.layout.badge_preference_view))
+ mappingAdapter.registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.badge_preference_view))
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt
new file mode 100644
index 00000000000..dd0ae03313b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt
@@ -0,0 +1,66 @@
+package org.thoughtcrime.securesms.badges.models
+
+import android.view.View
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.badges.BadgeImageView
+import org.thoughtcrime.securesms.components.AvatarImageView
+import org.thoughtcrime.securesms.components.settings.PreferenceModel
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
+
+object BadgePreview {
+
+ fun register(mappingAdapter: MappingAdapter) {
+ mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
+ mappingAdapter.registerFactory(SubscriptionModel::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.subscription_flow_badge_preview_preference))
+ }
+
+ abstract class BadgeModel> : PreferenceModel() {
+ abstract val badge: Badge?
+ }
+
+ data class Model(override val badge: Badge?) : BadgeModel() {
+ override fun areItemsTheSame(newItem: Model): Boolean {
+ return true
+ }
+
+ override fun areContentsTheSame(newItem: Model): Boolean {
+ return super.areContentsTheSame(newItem) && badge == newItem.badge
+ }
+
+ override fun getChangePayload(newItem: Model): Any? {
+ return Unit
+ }
+ }
+
+ data class SubscriptionModel(override val badge: Badge?) : BadgeModel() {
+ override fun areItemsTheSame(newItem: SubscriptionModel): Boolean {
+ return true
+ }
+
+ override fun areContentsTheSame(newItem: SubscriptionModel): Boolean {
+ return super.areContentsTheSame(newItem) && badge == newItem.badge
+ }
+
+ override fun getChangePayload(newItem: SubscriptionModel): Any? {
+ return Unit
+ }
+ }
+
+ class ViewHolder>(itemView: View) : MappingViewHolder(itemView) {
+
+ private val avatar: AvatarImageView = itemView.findViewById(R.id.avatar)
+ private val badge: BadgeImageView = itemView.findViewById(R.id.badge)
+
+ override fun bind(model: T) {
+ if (payload.isEmpty()) {
+ avatar.setRecipient(Recipient.self())
+ avatar.disableQuickContact()
+ }
+
+ badge.setBadge(model.badge)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/ExpiredBadge.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/ExpiredBadge.kt
new file mode 100644
index 00000000000..4e58a0218f7
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/ExpiredBadge.kt
@@ -0,0 +1,35 @@
+package org.thoughtcrime.securesms.badges.models
+
+import android.view.View
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.badges.BadgeImageView
+import org.thoughtcrime.securesms.components.settings.PreferenceModel
+import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
+
+object ExpiredBadge {
+
+ class Model(val badge: Badge) : PreferenceModel() {
+ override fun areItemsTheSame(newItem: Model): Boolean {
+ return newItem.badge.id == badge.id
+ }
+
+ override fun areContentsTheSame(newItem: Model): Boolean {
+ return super.areContentsTheSame(newItem) && newItem.badge == badge
+ }
+ }
+
+ class ViewHolder(itemView: View) : MappingViewHolder(itemView) {
+
+ private val badge: BadgeImageView = itemView.findViewById(R.id.expired_badge)
+
+ override fun bind(model: Model) {
+ badge.setBadge(model.badge)
+ }
+ }
+
+ fun register(adapter: MappingAdapter) {
+ adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.expired_badge_preference))
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/LargeBadge.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/LargeBadge.kt
new file mode 100644
index 00000000000..17c9ead3dee
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/LargeBadge.kt
@@ -0,0 +1,59 @@
+package org.thoughtcrime.securesms.badges.models
+
+import android.view.View
+import android.widget.TextView
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.badges.BadgeImageView
+import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
+
+data class LargeBadge(
+ val badge: Badge
+) {
+
+ class Model(val largeBadge: LargeBadge, val shortName: String, val maxLines: Int) : MappingModel {
+ override fun areItemsTheSame(newItem: Model): Boolean {
+ return newItem.largeBadge.badge.id == largeBadge.badge.id
+ }
+
+ override fun areContentsTheSame(newItem: Model): Boolean {
+ return newItem.largeBadge == largeBadge && newItem.shortName == shortName && newItem.maxLines == maxLines
+ }
+ }
+
+ class EmptyModel : MappingModel {
+ override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
+ override fun areContentsTheSame(newItem: EmptyModel): Boolean = true
+ }
+
+ class EmptyViewHolder(itemView: View) : MappingViewHolder(itemView) {
+ override fun bind(model: EmptyModel) {
+ }
+ }
+
+ class ViewHolder(itemView: View) : MappingViewHolder(itemView) {
+
+ private val badge: BadgeImageView = itemView.findViewById(R.id.badge)
+ private val name: TextView = itemView.findViewById(R.id.name)
+ private val description: TextView = itemView.findViewById(R.id.description)
+
+ override fun bind(model: Model) {
+ badge.setBadge(model.largeBadge.badge)
+
+ name.text = model.largeBadge.badge.name
+ description.text = model.largeBadge.badge.resolveDescription(model.shortName)
+ description.setLines(model.maxLines)
+ description.maxLines = model.maxLines
+ description.minLines = model.maxLines
+ }
+ }
+
+ companion object {
+ fun register(mappingAdapter: MappingAdapter) {
+ mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page))
+ mappingAdapter.registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page))
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/CantProcessSubscriptionPaymentBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/CantProcessSubscriptionPaymentBottomSheetDialogFragment.kt
new file mode 100644
index 00000000000..ca86348146b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/CantProcessSubscriptionPaymentBottomSheetDialogFragment.kt
@@ -0,0 +1,52 @@
+package org.thoughtcrime.securesms.badges.self.expired
+
+import androidx.core.content.ContextCompat
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.settings.DSLConfiguration
+import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
+import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
+import org.thoughtcrime.securesms.components.settings.DSLSettingsText
+import org.thoughtcrime.securesms.components.settings.configure
+import org.thoughtcrime.securesms.components.settings.models.SplashImage
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.util.CommunicationActions
+
+class CantProcessSubscriptionPaymentBottomSheetDialogFragment : DSLSettingsBottomSheetFragment() {
+ override fun bindAdapter(adapter: DSLSettingsAdapter) {
+ SplashImage.register(adapter)
+ adapter.submitList(getConfiguration().toMappingModelList())
+ }
+
+ private fun getConfiguration(): DSLConfiguration {
+ return configure {
+ customPref(SplashImage.Model(R.drawable.ic_card_process))
+
+ sectionHeaderPref(
+ title = DSLSettingsText.from(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__cant_process_subscription_payment, DSLSettingsText.CenterModifier)
+ )
+
+ textPref(
+ summary = DSLSettingsText.from(
+ requireContext().getString(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__were_having_trouble),
+ DSLSettingsText.LearnMoreModifier(ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
+ CommunicationActions.openBrowserLink(requireContext(), requireContext().getString(R.string.donation_decline_code_error_url))
+ },
+ DSLSettingsText.CenterModifier
+ )
+ )
+
+ primaryButton(
+ text = DSLSettingsText.from(android.R.string.ok)
+ ) {
+ dismissAllowingStateLoss()
+ }
+
+ secondaryButtonNoOutline(
+ text = DSLSettingsText.from(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__dont_show_this_again)
+ ) {
+ SignalStore.donationsValues().showCantProcessDialog = false
+ dismissAllowingStateLoss()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/ExpiredBadgeBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/ExpiredBadgeBottomSheetDialogFragment.kt
new file mode 100644
index 00000000000..06c5205240b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/ExpiredBadgeBottomSheetDialogFragment.kt
@@ -0,0 +1,127 @@
+package org.thoughtcrime.securesms.badges.self.expired
+
+import androidx.fragment.app.FragmentManager
+import org.signal.core.util.DimensionUnit
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.badges.models.Badge
+import org.thoughtcrime.securesms.badges.models.ExpiredBadge
+import org.thoughtcrime.securesms.components.settings.DSLConfiguration
+import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
+import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
+import org.thoughtcrime.securesms.components.settings.DSLSettingsText
+import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
+import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
+import org.thoughtcrime.securesms.components.settings.configure
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.util.BottomSheetUtil
+
+/**
+ * Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
+ */
+class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
+ peekHeightPercentage = 1f
+) {
+ override fun bindAdapter(adapter: DSLSettingsAdapter) {
+ ExpiredBadge.register(adapter)
+
+ adapter.submitList(getConfiguration().toMappingModelList())
+ }
+
+ private fun getConfiguration(): DSLConfiguration {
+ val args = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
+ val badge: Badge = args.badge
+ val cancellationReason: UnexpectedSubscriptionCancellation? = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason)
+ val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
+
+ val inactive = cancellationReason == UnexpectedSubscriptionCancellation.INACTIVE
+
+ return configure {
+ customPref(ExpiredBadge.Model(badge))
+
+ sectionHeaderPref(
+ DSLSettingsText.from(
+ if (badge.isBoost()) {
+ R.string.ExpiredBadgeBottomSheetDialogFragment__boost_badge_expired
+ } else {
+ R.string.ExpiredBadgeBottomSheetDialogFragment__monthly_donation_cancelled
+ },
+ DSLSettingsText.CenterModifier
+ )
+ )
+
+ space(DimensionUnit.DP.toPixels(4f).toInt())
+
+ noPadTextPref(
+ DSLSettingsText.from(
+ if (badge.isBoost()) {
+ getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired_and)
+ } else if (inactive) {
+ getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_automatically, badge.name)
+ } else {
+ getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled)
+ },
+ DSLSettingsText.CenterModifier
+ )
+ )
+
+ space(DimensionUnit.DP.toPixels(16f).toInt())
+
+ noPadTextPref(
+ DSLSettingsText.from(
+ if (badge.isBoost()) {
+ if (isLikelyASustainer) {
+ R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate
+ } else {
+ R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep
+ }
+ } else {
+ R.string.ExpiredBadgeBottomSheetDialogFragment__you_can
+ },
+ DSLSettingsText.CenterModifier
+ )
+ )
+
+ space(DimensionUnit.DP.toPixels(92f).toInt())
+
+ primaryButton(
+ text = DSLSettingsText.from(
+ if (badge.isBoost()) {
+ if (isLikelyASustainer) {
+ R.string.ExpiredBadgeBottomSheetDialogFragment__add_a_boost
+ } else {
+ R.string.ExpiredBadgeBottomSheetDialogFragment__become_a_sustainer
+ }
+ } else {
+ R.string.ExpiredBadgeBottomSheetDialogFragment__renew_subscription
+ }
+ ),
+ onClick = {
+ dismiss()
+ if (isLikelyASustainer) {
+ requireActivity().startActivity(AppSettingsActivity.boost(requireContext()))
+ } else {
+ requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
+ }
+ }
+ )
+
+ secondaryButtonNoOutline(
+ text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__not_now),
+ onClick = {
+ dismiss()
+ }
+ )
+ }
+ }
+
+ companion object {
+ @JvmStatic
+ fun show(badge: Badge, cancellationReason: UnexpectedSubscriptionCancellation?, fragmentManager: FragmentManager) {
+ val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status).build()
+ val fragment = ExpiredBadgeBottomSheetDialogFragment()
+ fragment.arguments = args.toBundle()
+
+ fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeEvent.kt
new file mode 100644
index 00000000000..0675bde72f6
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeEvent.kt
@@ -0,0 +1,7 @@
+package org.thoughtcrime.securesms.badges.self.featured
+
+enum class SelectFeaturedBadgeEvent {
+ NO_BADGE_SELECTED,
+ FAILED_TO_UPDATE_PROFILE,
+ SAVE_SUCCESSFUL
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt
new file mode 100644
index 00000000000..05b06dfabaa
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt
@@ -0,0 +1,93 @@
+package org.thoughtcrime.securesms.badges.self.featured
+
+import android.os.Bundle
+import android.view.View
+import android.widget.Toast
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.badges.BadgeRepository
+import org.thoughtcrime.securesms.badges.Badges
+import org.thoughtcrime.securesms.badges.Badges.displayBadges
+import org.thoughtcrime.securesms.badges.models.Badge
+import org.thoughtcrime.securesms.badges.models.BadgePreview
+import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
+import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
+import org.thoughtcrime.securesms.components.settings.DSLConfiguration
+import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
+import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
+import org.thoughtcrime.securesms.components.settings.configure
+import org.thoughtcrime.securesms.util.LifecycleDisposable
+
+/**
+ * Fragment which allows user to select one of their badges to be their "Featured" badge.
+ */
+class SelectFeaturedBadgeFragment : DSLSettingsFragment(
+ titleId = R.string.BadgesOverviewFragment__featured_badge,
+ layoutId = R.layout.select_featured_badge_fragment,
+ layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges
+) {
+
+ private val viewModel: SelectFeaturedBadgeViewModel by viewModels(factoryProducer = { SelectFeaturedBadgeViewModel.Factory(BadgeRepository(requireContext())) })
+
+ private val lifecycleDisposable = LifecycleDisposable()
+
+ private lateinit var scrollShadow: View
+ private lateinit var save: View
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ scrollShadow = view.findViewById(R.id.scroll_shadow)
+
+ super.onViewCreated(view, savedInstanceState)
+
+ save = view.findViewById(R.id.save)
+ save.setOnClickListener {
+ viewModel.save()
+ }
+ }
+
+ override fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper {
+ return ToolbarShadowAnimationHelper(scrollShadow)
+ }
+
+ override fun bindAdapter(adapter: DSLSettingsAdapter) {
+ Badge.register(adapter) { badge, isSelected, _ ->
+ if (!isSelected) {
+ viewModel.setSelectedBadge(badge)
+ }
+ }
+
+ val previewView: View = requireView().findViewById(R.id.preview)
+ val previewViewHolder = BadgePreview.ViewHolder(previewView)
+
+ lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
+ lifecycleDisposable += viewModel.events.subscribe { event: SelectFeaturedBadgeEvent ->
+ when (event) {
+ SelectFeaturedBadgeEvent.NO_BADGE_SELECTED -> Toast.makeText(requireContext(), R.string.SelectFeaturedBadgeFragment__you_must_select_a_badge, Toast.LENGTH_LONG).show()
+ SelectFeaturedBadgeEvent.FAILED_TO_UPDATE_PROFILE -> Toast.makeText(requireContext(), R.string.SelectFeaturedBadgeFragment__failed_to_update_profile, Toast.LENGTH_LONG).show()
+ SelectFeaturedBadgeEvent.SAVE_SUCCESSFUL -> findNavController().popBackStack()
+ }
+ }
+
+ var hasBoundPreview = false
+ viewModel.state.observe(viewLifecycleOwner) { state ->
+ save.isEnabled = state.stage == SelectFeaturedBadgeState.Stage.READY
+
+ if (hasBoundPreview) {
+ previewViewHolder.setPayload(listOf(Unit))
+ } else {
+ hasBoundPreview = true
+ }
+
+ previewViewHolder.bind(BadgePreview.Model(state.selectedBadge))
+ adapter.submitList(getConfiguration(state).toMappingModelList())
+ }
+ }
+
+ private fun getConfiguration(state: SelectFeaturedBadgeState): DSLConfiguration {
+ return configure {
+ sectionHeaderPref(R.string.SelectFeaturedBadgeFragment__select_a_badge)
+ displayBadges(requireContext(), state.allUnlockedBadges, state.selectedBadge)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeState.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeState.kt
new file mode 100644
index 00000000000..a1f17a79e26
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeState.kt
@@ -0,0 +1,15 @@
+package org.thoughtcrime.securesms.badges.self.featured
+
+import org.thoughtcrime.securesms.badges.models.Badge
+
+data class SelectFeaturedBadgeState(
+ val stage: Stage = Stage.INIT,
+ val selectedBadge: Badge? = null,
+ val allUnlockedBadges: List = listOf()
+) {
+ enum class Stage {
+ INIT,
+ READY,
+ SAVING
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeViewModel.kt
new file mode 100644
index 00000000000..90f5223e9fb
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeViewModel.kt
@@ -0,0 +1,73 @@
+package org.thoughtcrime.securesms.badges.self.featured
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.core.Observable
+import io.reactivex.rxjava3.disposables.CompositeDisposable
+import io.reactivex.rxjava3.kotlin.plusAssign
+import io.reactivex.rxjava3.kotlin.subscribeBy
+import io.reactivex.rxjava3.subjects.PublishSubject
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.badges.BadgeRepository
+import org.thoughtcrime.securesms.badges.models.Badge
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.util.livedata.Store
+
+private val TAG = Log.tag(SelectFeaturedBadgeViewModel::class.java)
+
+class SelectFeaturedBadgeViewModel(private val repository: BadgeRepository) : ViewModel() {
+
+ private val store = Store(SelectFeaturedBadgeState())
+ private val eventSubject = PublishSubject.create()
+
+ val state: LiveData = store.stateLiveData
+ val events: Observable = eventSubject.observeOn(AndroidSchedulers.mainThread())
+
+ private val disposables = CompositeDisposable()
+
+ init {
+ store.update(Recipient.live(Recipient.self().id).liveDataResolved) { recipient, state ->
+ val unexpiredBadges = recipient.badges.filterNot { it.isExpired() }
+ state.copy(
+ stage = if (state.stage == SelectFeaturedBadgeState.Stage.INIT) SelectFeaturedBadgeState.Stage.READY else state.stage,
+ selectedBadge = unexpiredBadges.firstOrNull(),
+ allUnlockedBadges = unexpiredBadges
+ )
+ }
+ }
+
+ fun setSelectedBadge(badge: Badge) {
+ store.update { it.copy(selectedBadge = badge) }
+ }
+
+ fun save() {
+ val snapshot = store.state
+ if (snapshot.selectedBadge == null) {
+ eventSubject.onNext(SelectFeaturedBadgeEvent.NO_BADGE_SELECTED)
+ return
+ }
+
+ store.update { it.copy(stage = SelectFeaturedBadgeState.Stage.SAVING) }
+ disposables += repository.setFeaturedBadge(snapshot.selectedBadge).subscribeBy(
+ onComplete = {
+ eventSubject.onNext(SelectFeaturedBadgeEvent.SAVE_SUCCESSFUL)
+ },
+ onError = { error ->
+ Log.e(TAG, "Failed to update profile.", error)
+ eventSubject.onNext(SelectFeaturedBadgeEvent.FAILED_TO_UPDATE_PROFILE)
+ }
+ )
+ }
+
+ override fun onCleared() {
+ disposables.clear()
+ }
+
+ class Factory(private val badgeRepository: BadgeRepository) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ return requireNotNull(modelClass.cast(SelectFeaturedBadgeViewModel(badgeRepository)))
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerFragment.kt
new file mode 100644
index 00000000000..f99ecfc1c5e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerFragment.kt
@@ -0,0 +1,78 @@
+package org.thoughtcrime.securesms.badges.self.none
+
+import android.content.Intent
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.viewModels
+import org.signal.core.util.DimensionUnit
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.badges.models.BadgePreview
+import org.thoughtcrime.securesms.components.settings.DSLConfiguration
+import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
+import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
+import org.thoughtcrime.securesms.components.settings.DSLSettingsText
+import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
+import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
+import org.thoughtcrime.securesms.components.settings.configure
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
+import org.thoughtcrime.securesms.util.BottomSheetUtil
+
+class BecomeASustainerFragment : DSLSettingsBottomSheetFragment() {
+
+ private val viewModel: BecomeASustainerViewModel by viewModels(
+ factoryProducer = {
+ BecomeASustainerViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
+ }
+ )
+
+ override fun bindAdapter(adapter: DSLSettingsAdapter) {
+ BadgePreview.register(adapter)
+
+ viewModel.state.observe(viewLifecycleOwner) {
+ adapter.submitList(getConfiguration(it).toMappingModelList())
+ }
+ }
+
+ private fun getConfiguration(state: BecomeASustainerState): DSLConfiguration {
+ return configure {
+ customPref(BadgePreview.Model(badge = state.badge))
+
+ sectionHeaderPref(
+ title = DSLSettingsText.from(
+ R.string.BecomeASustainerFragment__get_badges,
+ DSLSettingsText.CenterModifier,
+ DSLSettingsText.Title2BoldModifier
+ )
+ )
+
+ space(DimensionUnit.DP.toPixels(8f).toInt())
+
+ noPadTextPref(
+ title = DSLSettingsText.from(
+ R.string.BecomeASustainerFragment__signal_is_a_non_profit,
+ DSLSettingsText.CenterModifier
+ )
+ )
+
+ space(DimensionUnit.DP.toPixels(77f).toInt())
+
+ primaryButton(
+ text = DSLSettingsText.from(
+ R.string.BecomeASustainerMegaphone__become_a_sustainer
+ ),
+ onClick = {
+ requireActivity().finish()
+ requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP))
+ }
+ )
+
+ space(DimensionUnit.DP.toPixels(8f).toInt())
+ }
+ }
+
+ companion object {
+ @JvmStatic
+ fun show(fragmentManager: FragmentManager) {
+ BecomeASustainerFragment().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerState.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerState.kt
new file mode 100644
index 00000000000..3c40f4de055
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerState.kt
@@ -0,0 +1,7 @@
+package org.thoughtcrime.securesms.badges.self.none
+
+import org.thoughtcrime.securesms.badges.models.Badge
+
+data class BecomeASustainerState(
+ val badge: Badge? = null
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerViewModel.kt
new file mode 100644
index 00000000000..27d5a19f065
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerViewModel.kt
@@ -0,0 +1,45 @@
+package org.thoughtcrime.securesms.badges.self.none
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import io.reactivex.rxjava3.disposables.CompositeDisposable
+import io.reactivex.rxjava3.kotlin.plusAssign
+import io.reactivex.rxjava3.kotlin.subscribeBy
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
+import org.thoughtcrime.securesms.util.livedata.Store
+
+class BecomeASustainerViewModel(subscriptionsRepository: SubscriptionsRepository) : ViewModel() {
+
+ private val store = Store(BecomeASustainerState())
+
+ val state: LiveData = store.stateLiveData
+
+ private val disposables = CompositeDisposable()
+
+ init {
+ disposables += subscriptionsRepository.getSubscriptions().subscribeBy(
+ onError = { Log.w(TAG, "Could not load subscriptions.") },
+ onSuccess = { subscriptions ->
+ store.update {
+ it.copy(badge = subscriptions.firstOrNull()?.badge)
+ }
+ }
+ )
+ }
+
+ override fun onCleared() {
+ disposables.clear()
+ }
+
+ companion object {
+ private val TAG = Log.tag(BecomeASustainerViewModel::class.java)
+ }
+
+ class Factory(private val subscriptionsRepository: SubscriptionsRepository) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ return modelClass.cast(BecomeASustainerViewModel(subscriptionsRepository))!!
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewEvent.kt
new file mode 100644
index 00000000000..3b2f56caac0
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewEvent.kt
@@ -0,0 +1,5 @@
+package org.thoughtcrime.securesms.badges.self.overview
+
+enum class BadgesOverviewEvent {
+ FAILED_TO_UPDATE_PROFILE
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt
new file mode 100644
index 00000000000..9fd26fca554
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt
@@ -0,0 +1,92 @@
+package org.thoughtcrime.securesms.badges.self.overview
+
+import android.widget.Toast
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.badges.BadgeRepository
+import org.thoughtcrime.securesms.badges.Badges
+import org.thoughtcrime.securesms.badges.Badges.displayBadges
+import org.thoughtcrime.securesms.badges.models.Badge
+import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment
+import org.thoughtcrime.securesms.components.settings.DSLConfiguration
+import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
+import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
+import org.thoughtcrime.securesms.components.settings.DSLSettingsText
+import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
+import org.thoughtcrime.securesms.components.settings.configure
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.util.LifecycleDisposable
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+
+/**
+ * Fragment to allow user to manage options related to the badges they've unlocked.
+ */
+class BadgesOverviewFragment : DSLSettingsFragment(
+ titleId = R.string.ManageProfileFragment_badges,
+ layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges
+) {
+
+ private val lifecycleDisposable = LifecycleDisposable()
+ private val viewModel: BadgesOverviewViewModel by viewModels(
+ factoryProducer = {
+ BadgesOverviewViewModel.Factory(BadgeRepository(requireContext()), SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
+ }
+ )
+
+ override fun bindAdapter(adapter: DSLSettingsAdapter) {
+ Badge.register(adapter) { badge, _, isFaded ->
+ if (badge.isExpired() || isFaded) {
+ findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge, null))
+ } else {
+ ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
+ }
+ }
+
+ lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
+
+ viewModel.state.observe(viewLifecycleOwner) { state ->
+ adapter.submitList(getConfiguration(state).toMappingModelList())
+ }
+
+ lifecycleDisposable.add(
+ viewModel.events.subscribe { event: BadgesOverviewEvent ->
+ when (event) {
+ BadgesOverviewEvent.FAILED_TO_UPDATE_PROFILE -> Toast.makeText(requireContext(), R.string.BadgesOverviewFragment__failed_to_update_profile, Toast.LENGTH_LONG).show()
+ }
+ }
+ )
+ }
+
+ private fun getConfiguration(state: BadgesOverviewState): DSLConfiguration {
+ return configure {
+ sectionHeaderPref(R.string.BadgesOverviewFragment__my_badges)
+
+ displayBadges(
+ context = requireContext(),
+ badges = state.allUnlockedBadges,
+ fadedBadgeId = state.fadedBadgeId
+ )
+
+ asyncSwitchPref(
+ title = DSLSettingsText.from(R.string.BadgesOverviewFragment__display_badges_on_profile),
+ isChecked = state.displayBadgesOnProfile,
+ isEnabled = state.stage == BadgesOverviewState.Stage.READY && state.hasUnexpiredBadges && state.hasInternet,
+ isProcessing = state.stage == BadgesOverviewState.Stage.UPDATING_BADGE_DISPLAY_STATE,
+ onClick = {
+ viewModel.setDisplayBadgesOnProfile(!state.displayBadgesOnProfile)
+ }
+ )
+
+ clickPref(
+ title = DSLSettingsText.from(R.string.BadgesOverviewFragment__featured_badge),
+ summary = state.featuredBadge?.name?.let { DSLSettingsText.from(it) },
+ isEnabled = state.stage == BadgesOverviewState.Stage.READY && state.hasUnexpiredBadges && state.hasInternet,
+ onClick = {
+ findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToFeaturedBadgeFragment())
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewState.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewState.kt
new file mode 100644
index 00000000000..482f6036b74
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewState.kt
@@ -0,0 +1,21 @@
+package org.thoughtcrime.securesms.badges.self.overview
+
+import org.thoughtcrime.securesms.badges.models.Badge
+
+data class BadgesOverviewState(
+ val stage: Stage = Stage.INIT,
+ val allUnlockedBadges: List = listOf(),
+ val featuredBadge: Badge? = null,
+ val displayBadgesOnProfile: Boolean = false,
+ val fadedBadgeId: String? = null,
+ val hasInternet: Boolean = false
+) {
+
+ val hasUnexpiredBadges = allUnlockedBadges.any { it.expirationTimestamp > System.currentTimeMillis() }
+
+ enum class Stage {
+ INIT,
+ READY,
+ UPDATING_BADGE_DISPLAY_STATE
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt
new file mode 100644
index 00000000000..22efd790c54
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt
@@ -0,0 +1,102 @@
+package org.thoughtcrime.securesms.badges.self.overview
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.core.Observable
+import io.reactivex.rxjava3.core.Single
+import io.reactivex.rxjava3.disposables.CompositeDisposable
+import io.reactivex.rxjava3.kotlin.plusAssign
+import io.reactivex.rxjava3.kotlin.subscribeBy
+import io.reactivex.rxjava3.subjects.PublishSubject
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.badges.BadgeRepository
+import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.util.InternetConnectionObserver
+import org.thoughtcrime.securesms.util.livedata.Store
+import java.util.Optional
+
+private val TAG = Log.tag(BadgesOverviewViewModel::class.java)
+
+class BadgesOverviewViewModel(
+ private val badgeRepository: BadgeRepository,
+ private val subscriptionsRepository: SubscriptionsRepository
+) : ViewModel() {
+ private val store = Store(BadgesOverviewState())
+ private val eventSubject = PublishSubject.create()
+
+ val state: LiveData = store.stateLiveData
+ val events: Observable = eventSubject.observeOn(AndroidSchedulers.mainThread())
+
+ val disposables = CompositeDisposable()
+
+ init {
+ store.update(Recipient.live(Recipient.self().id).liveDataResolved) { recipient, state ->
+ state.copy(
+ stage = if (state.stage == BadgesOverviewState.Stage.INIT) BadgesOverviewState.Stage.READY else state.stage,
+ allUnlockedBadges = recipient.badges,
+ displayBadgesOnProfile = SignalStore.donationsValues().getDisplayBadgesOnProfile(),
+ featuredBadge = recipient.featuredBadge
+ )
+ }
+
+ disposables += InternetConnectionObserver.observe()
+ .distinctUntilChanged()
+ .subscribeBy { isConnected ->
+ store.update { it.copy(hasInternet = isConnected) }
+ }
+
+ disposables += Single.zip(
+ subscriptionsRepository.getActiveSubscription(),
+ subscriptionsRepository.getSubscriptions()
+ ) { active, all ->
+ if (!active.isActive && active.activeSubscription?.willCancelAtPeriodEnd() == true) {
+ Optional.ofNullable(all.firstOrNull { it.level == active.activeSubscription?.level }?.badge?.id)
+ } else {
+ Optional.empty()
+ }
+ }.subscribeBy(
+ onSuccess = { badgeId ->
+ store.update { it.copy(fadedBadgeId = badgeId.orElse(null)) }
+ },
+ onError = { throwable ->
+ Log.w(TAG, "Could not retrieve data from server", throwable)
+ }
+ )
+ }
+
+ fun setDisplayBadgesOnProfile(displayBadgesOnProfile: Boolean) {
+ store.update { it.copy(stage = BadgesOverviewState.Stage.UPDATING_BADGE_DISPLAY_STATE) }
+ disposables += badgeRepository.setVisibilityForAllBadges(displayBadgesOnProfile)
+ .subscribe(
+ {
+ store.update { it.copy(stage = BadgesOverviewState.Stage.READY) }
+ },
+ { error ->
+ Log.e(TAG, "Failed to update visibility.", error)
+ store.update { it.copy(stage = BadgesOverviewState.Stage.READY) }
+ eventSubject.onNext(BadgesOverviewEvent.FAILED_TO_UPDATE_PROFILE)
+ }
+ )
+ }
+
+ override fun onCleared() {
+ disposables.clear()
+ }
+
+ class Factory(
+ private val badgeRepository: BadgeRepository,
+ private val subscriptionsRepository: SubscriptionsRepository
+ ) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository, subscriptionsRepository)))
+ }
+ }
+
+ companion object {
+ private val TAG = Log.tag(BadgesOverviewViewModel::class.java)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeBottomSheetDialogFragment.kt
new file mode 100644
index 00000000000..f63723aed33
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeBottomSheetDialogFragment.kt
@@ -0,0 +1,160 @@
+package org.thoughtcrime.securesms.badges.view
+
+import android.graphics.Paint
+import android.graphics.Rect
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.viewModels
+import androidx.viewpager2.widget.ViewPager2
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.tabs.TabLayout
+import com.google.android.material.tabs.TabLayoutMediator
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.badges.BadgeRepository
+import org.thoughtcrime.securesms.badges.models.Badge
+import org.thoughtcrime.securesms.badges.models.LargeBadge
+import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
+import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.recipients.RecipientId
+import org.thoughtcrime.securesms.util.BottomSheetUtil
+import org.thoughtcrime.securesms.util.CommunicationActions
+import org.thoughtcrime.securesms.util.FeatureFlags
+import org.thoughtcrime.securesms.util.PlayServicesUtil
+import org.thoughtcrime.securesms.util.ViewUtil
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
+import org.thoughtcrime.securesms.util.visible
+import kotlin.math.ceil
+import kotlin.math.max
+
+class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
+
+ private val viewModel: ViewBadgeViewModel by viewModels(factoryProducer = { ViewBadgeViewModel.Factory(getStartBadge(), getRecipientId(), BadgeRepository(requireContext())) })
+
+ override val peekHeightPercentage: Float = 1f
+
+ private val textWidth: Float
+ get() = (resources.displayMetrics.widthPixels - ViewUtil.dpToPx(64)).toFloat()
+ private val textBounds: Rect = Rect()
+ private val textPaint: Paint = Paint().apply {
+ textSize = ViewUtil.spToPx(16f).toFloat()
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ return inflater.inflate(R.layout.view_badge_bottom_sheet_dialog_fragment, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ postponeEnterTransition()
+
+ val pager: ViewPager2 = view.findViewById(R.id.pager)
+ val tabs: TabLayout = view.findViewById(R.id.tab_layout)
+ val action: MaterialButton = view.findViewById(R.id.action)
+ val noSupport: View = view.findViewById(R.id.no_support)
+
+ if (getRecipientId() == Recipient.self().id) {
+ action.visible = false
+ }
+
+ @Suppress("CascadeIf")
+ if (PlayServicesUtil.getPlayServicesStatus(requireContext()) != PlayServicesUtil.PlayServicesStatus.SUCCESS) {
+ noSupport.visible = true
+ action.icon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_open_20)
+ action.setText(R.string.preferences__donate_to_signal)
+ action.setOnClickListener {
+ CommunicationActions.openBrowserLink(requireContext(), getString(R.string.donate_url))
+ }
+ } else if (
+ FeatureFlags.donorBadges() &&
+ Recipient.self().badges.none { it.category == Badge.Category.Donor && !it.isBoost() && !it.isExpired() }
+ ) {
+ action.setOnClickListener {
+ startActivity(AppSettingsActivity.subscriptions(requireContext()))
+ }
+ } else {
+ action.visible = false
+ }
+
+ val adapter = MappingAdapter()
+
+ LargeBadge.register(adapter)
+ pager.adapter = adapter
+ adapter.submitList(listOf(LargeBadge.EmptyModel()))
+
+ TabLayoutMediator(tabs, pager) { _, _ ->
+ }.attach()
+
+ pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
+ override fun onPageSelected(position: Int) {
+ if (adapter.getModel(position).map { it is LargeBadge.Model }.orElse(false)) {
+ viewModel.onPageSelected(position)
+ }
+ }
+ })
+
+ viewModel.state.observe(viewLifecycleOwner) { state ->
+ if (state.recipient == null || state.badgeLoadState == ViewBadgeState.LoadState.INIT) {
+ return@observe
+ }
+
+ if (state.allBadgesVisibleOnProfile.isEmpty()) {
+ dismissAllowingStateLoss()
+ }
+
+ tabs.visible = state.allBadgesVisibleOnProfile.size > 1
+
+ var maxLines = 3
+ state.allBadgesVisibleOnProfile.forEach { badge ->
+ val text = badge.resolveDescription(state.recipient.getShortDisplayName(requireContext()))
+ textPaint.getTextBounds(text, 0, text.length, textBounds)
+ val estimatedLines = ceil(textBounds.width().toFloat() / textWidth).toInt()
+ maxLines = max(maxLines, estimatedLines)
+ }
+
+ adapter.submitList(
+ state.allBadgesVisibleOnProfile.map {
+ LargeBadge.Model(LargeBadge(it), state.recipient.getShortDisplayName(requireContext()), maxLines + 1)
+ }
+ ) {
+ val stateSelectedIndex = state.allBadgesVisibleOnProfile.indexOf(state.selectedBadge)
+ if (state.selectedBadge != null && pager.currentItem != stateSelectedIndex) {
+ pager.currentItem = stateSelectedIndex
+ }
+ }
+ }
+ }
+
+ private fun getStartBadge(): Badge? = requireArguments().getParcelable(ARG_START_BADGE)
+
+ private fun getRecipientId(): RecipientId = requireNotNull(requireArguments().getParcelable(ARG_RECIPIENT_ID))
+
+ companion object {
+
+ private const val ARG_START_BADGE = "start_badge"
+ private const val ARG_RECIPIENT_ID = "recipient_id"
+
+ @JvmStatic
+ fun show(
+ fragmentManager: FragmentManager,
+ recipientId: RecipientId,
+ startBadge: Badge? = null
+ ) {
+ if (!FeatureFlags.displayDonorBadges() && recipientId != Recipient.self().id) {
+ return
+ }
+
+ ViewBadgeBottomSheetDialogFragment().apply {
+ arguments = Bundle().apply {
+ putParcelable(ARG_START_BADGE, startBadge)
+ putParcelable(ARG_RECIPIENT_ID, recipientId)
+ }
+
+ show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeState.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeState.kt
new file mode 100644
index 00000000000..819d9c50373
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeState.kt
@@ -0,0 +1,16 @@
+package org.thoughtcrime.securesms.badges.view
+
+import org.thoughtcrime.securesms.badges.models.Badge
+import org.thoughtcrime.securesms.recipients.Recipient
+
+data class ViewBadgeState(
+ val allBadgesVisibleOnProfile: List = listOf(),
+ val badgeLoadState: LoadState = LoadState.INIT,
+ val selectedBadge: Badge? = null,
+ val recipient: Recipient? = null
+) {
+ enum class LoadState {
+ INIT,
+ LOADED
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeViewModel.kt
new file mode 100644
index 00000000000..9ad63ade8f3
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeViewModel.kt
@@ -0,0 +1,59 @@
+package org.thoughtcrime.securesms.badges.view
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import io.reactivex.rxjava3.disposables.CompositeDisposable
+import org.thoughtcrime.securesms.badges.BadgeRepository
+import org.thoughtcrime.securesms.badges.models.Badge
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.recipients.RecipientId
+import org.thoughtcrime.securesms.util.livedata.Store
+
+class ViewBadgeViewModel(
+ private val startBadge: Badge?,
+ private val recipientId: RecipientId,
+ private val repository: BadgeRepository
+) : ViewModel() {
+
+ private val disposables = CompositeDisposable()
+
+ private val store = Store(ViewBadgeState())
+
+ val state: LiveData = store.stateLiveData
+
+ init {
+ store.update(Recipient.live(recipientId).liveData) { recipient, state ->
+ state.copy(
+ recipient = recipient,
+ allBadgesVisibleOnProfile = recipient.badges,
+ selectedBadge = startBadge ?: recipient.badges.firstOrNull(),
+ badgeLoadState = ViewBadgeState.LoadState.LOADED
+ )
+ }
+ }
+
+ override fun onCleared() {
+ disposables.clear()
+ }
+
+ fun onPageSelected(position: Int) {
+ if (position > store.state.allBadgesVisibleOnProfile.size - 1 || position < 0) {
+ return
+ }
+
+ store.update {
+ it.copy(selectedBadge = it.allBadgesVisibleOnProfile[position])
+ }
+ }
+
+ class Factory(
+ private val startBadge: Badge?,
+ private val recipientId: RecipientId,
+ private val repository: BadgeRepository
+ ) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ return requireNotNull(modelClass.cast(ViewBadgeViewModel(startBadge, recipientId, repository)))
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java
index ec4259a6543..b680262e214 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java
@@ -24,8 +24,8 @@
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
-import org.whispersystems.libsignal.util.guava.Optional;
+import java.util.Optional;
import java.util.function.Consumer;
public class BlockedUsersActivity extends PassphraseRequiredActivity implements BlockedUsersFragment.Listener, ContactSelectionListFragment.OnContactSelectedListener {
@@ -87,8 +87,8 @@ protected void onResume() {
}
@Override
- public void onBeforeContactSelected(Optional recipientId, String number, Consumer callback) {
- final String displayName = recipientId.transform(id -> Recipient.resolved(id).getDisplayName(this)).or(number);
+ public void onBeforeContactSelected(@NonNull Optional recipientId, String number, @NonNull Consumer callback) {
+ final String displayName = recipientId.map(id -> Recipient.resolved(id).getDisplayName(this)).orElse(number);
AlertDialog confirmationDialog = new MaterialAlertDialogBuilder(this)
.setTitle(R.string.BlockedUsersActivity__block_user)
@@ -116,7 +116,7 @@ public void onBeforeContactSelected(Optional recipientId, String nu
}
@Override
- public void onContactDeselected(Optional recipientId, String number) {
+ public void onContactDeselected(@NonNull Optional recipientId, String number) {
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersAdapter.java
index f5a2dd091f9..18482eb3508 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersAdapter.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersAdapter.java
@@ -13,7 +13,9 @@
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
+import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.recipients.Recipient;
+import org.whispersystems.signalservice.api.util.OptionalUtil;
import java.util.Objects;
@@ -63,7 +65,9 @@ public void bind(@NonNull Recipient recipient) {
displayName.setText(recipient.getDisplayName(itemView.getContext()));
if (recipient.hasAUserSetDisplayName(itemView.getContext())) {
- String identifier = recipient.getE164().or(recipient.getUsername()).orNull();
+ String identifier = OptionalUtil.or(recipient.getE164().map(PhoneNumberFormatter::prettyPrint),
+ recipient.getUsername())
+ .orElse(null);
if (identifier != null) {
numberOrUsername.setText(identifier);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersFragment.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersFragment.java
index eb7f7e7c22c..d231f657518 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersFragment.java
@@ -1,9 +1,6 @@
package org.thoughtcrime.securesms.blocked;
-import android.app.AlertDialog;
import android.content.Context;
-import android.content.DialogInterface;
-import android.graphics.Color;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
@@ -15,6 +12,7 @@
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.RecyclerView;
+import org.thoughtcrime.securesms.BlockUnblockDialog;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -74,24 +72,9 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
}
private void handleRecipientClicked(@NonNull Recipient recipient) {
- AlertDialog confirmationDialog = new AlertDialog.Builder(requireContext())
- .setTitle(R.string.BlockedUsersActivity__unblock_user)
- .setMessage(getString(R.string.BlockedUsersActivity__do_you_want_to_unblock_s, recipient.getDisplayName(requireContext())))
- .setPositiveButton(R.string.BlockedUsersActivity__unblock, (dialog, which) -> {
- viewModel.unblock(recipient.getId());
- dialog.dismiss();
- })
- .setNegativeButton(android.R.string.cancel, (dialog, which) -> {
- dialog.dismiss();
- })
- .setCancelable(true)
- .create();
-
- confirmationDialog.setOnShowListener(dialog -> {
- confirmationDialog.getButton(DialogInterface.BUTTON_POSITIVE).setTextColor(Color.RED);
+ BlockUnblockDialog.showUnblockFor(requireContext(), getViewLifecycleOwner().getLifecycle(), recipient, () -> {
+ viewModel.unblock(recipient.getId());
});
-
- confirmationDialog.show();
}
interface Listener {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersRepository.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersRepository.java
index f41ccc959fa..ac892702bdf 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersRepository.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersRepository.java
@@ -7,8 +7,8 @@
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
-import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.RecipientDatabase;
+import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -32,7 +32,7 @@ class BlockedUsersRepository {
void getBlocked(@NonNull Consumer> blockedUsers) {
SignalExecutors.BOUNDED.execute(() -> {
- RecipientDatabase db = DatabaseFactory.getRecipientDatabase(context);
+ RecipientDatabase db = SignalDatabase.recipients();
try (RecipientDatabase.RecipientReader reader = db.readerForBlocked(db.getBlocked())) {
int count = reader.getCount();
if (count == 0) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java
index 2fae839da94..c368a46a851 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java
@@ -119,6 +119,7 @@ public AudioView(Context context, AttributeSet attrs, int defStyleAttr) {
lottieDirection = REVERSE;
this.playPauseButton.setOnClickListener(new PlayPauseClickedListener());
+ this.playPauseButton.setOnLongClickListener(v -> performLongClick());
this.seekBar.setOnSeekBarChangeListener(new SeekBarModifiedListener());
setTint(typedArray.getColor(R.styleable.AudioView_foregroundTintColor, Color.WHITE));
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java
index 9ab63d0a439..f2878d651a5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java
@@ -44,6 +44,7 @@
import org.thoughtcrime.securesms.util.BlurTransformation;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.ArrayList;
import java.util.List;
@@ -94,7 +95,7 @@ public AvatarImageView(Context context, AttributeSet attrs) {
initialize(context, attrs);
}
- private void initialize(@NonNull Context context, @Nullable AttributeSet attrs) {
+ public void initialize(@NonNull Context context, @Nullable AttributeSet attrs) {
setScaleType(ScaleType.CENTER_CROP);
if (attrs != null) {
@@ -207,8 +208,8 @@ private void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipien
this.chatColors = chatColors;
recipientContactPhoto = photo;
- Drawable fallbackContactPhotoDrawable = size == SIZE_SMALL ? photo.recipient.getSmallFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider)
- : photo.recipient.getFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider);
+ Drawable fallbackContactPhotoDrawable = size == SIZE_SMALL ? photo.recipient.getSmallFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider, ViewUtil.getWidth(this))
+ : photo.recipient.getFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider, ViewUtil.getWidth(this));
if (fixedSizeTarget != null) {
requestManager.clear(fixedSizeTarget);
@@ -224,6 +225,7 @@ private void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipien
blurred = shouldBlur;
GlideRequest request = requestManager.load(photo.contactPhoto)
+ .dontAnimate()
.fallback(fallbackContactPhotoDrawable)
.error(fallbackContactPhotoDrawable)
.diskCacheStrategy(DiskCacheStrategy.ALL)
@@ -289,6 +291,7 @@ public void setImageBytesForGroup(@Nullable byte[] avatarBytes,
GlideApp.with(this)
.load(avatarBytes)
+ .dontAnimate()
.fallback(fallback)
.error(fallback)
.diskCacheStrategy(DiskCacheStrategy.ALL)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ButtonStripItemView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ButtonStripItemView.kt
index 9f94331cb92..33fef7c6e22 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ButtonStripItemView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ButtonStripItemView.kt
@@ -1,9 +1,11 @@
package org.thoughtcrime.securesms.components
import android.content.Context
+import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.widget.ImageView
import android.widget.TextView
+import androidx.appcompat.content.res.AppCompatResources
import androidx.constraintlayout.widget.ConstraintLayout
import org.thoughtcrime.securesms.R
@@ -24,7 +26,9 @@ class ButtonStripItemView @JvmOverloads constructor(
val array = context.obtainStyledAttributes(attrs, R.styleable.ButtonStripItemView)
- val icon = array.getDrawable(R.styleable.ButtonStripItemView_bsiv_icon)
+ val iconId = array.getResourceId(R.styleable.ButtonStripItemView_bsiv_icon, -1)
+ val icon: Drawable? = if (iconId > 0) AppCompatResources.getDrawable(context, iconId) else null
+
val contentDescription = array.getString(R.styleable.ButtonStripItemView_bsiv_icon_contentDescription)
val label = array.getString(R.styleable.ButtonStripItemView_bsiv_label)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java
index d3a572dda35..83bc5cc999b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java
@@ -37,7 +37,7 @@
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.RecipientId;
-import org.thoughtcrime.securesms.util.StringUtil;
+import org.signal.core.util.StringUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.List;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java
index 92b22a9b6d6..11a0ba671f8 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java
@@ -15,6 +15,7 @@
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.widget.TextViewCompat;
@@ -101,7 +102,6 @@ public void afterTextChanged(Editable s) {
expandTapArea(toggleContainer, dialpadToggle);
applyAttributes(searchText, context, attrs, defStyleAttr);
- searchText.requestFocus();
}
private void applyAttributes(@NonNull EditText searchText,
@@ -121,6 +121,16 @@ private void applyAttributes(@NonNull EditText searchText,
if (!attributes.getBoolean(R.styleable.ContactFilterToolbar_showDialpad, true)) {
dialpadToggle.setVisibility(GONE);
}
+
+ if (attributes.getBoolean(R.styleable.ContactFilterToolbar_cfv_autoFocus, true)) {
+ searchText.requestFocus();
+ }
+
+ int backgroundRes = attributes.getResourceId(R.styleable.ContactFilterToolbar_cfv_background, -1);
+ if (backgroundRes != -1) {
+ findViewById(R.id.background_holder).setBackgroundResource(backgroundRes);
+ }
+
attributes.recycle();
}
@@ -137,6 +147,10 @@ public void setOnFilterChangedListener(OnFilterChangedListener listener) {
this.listener = listener;
}
+ public void setOnSearchInputFocusChangedListener(@Nullable OnFocusChangeListener listener) {
+ searchText.setOnFocusChangeListener(listener);
+ }
+
public void setHint(@StringRes int hint) {
searchText.setHint(hint);
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java
index 19a47db2795..72c4a82372a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java
@@ -10,6 +10,7 @@
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.View;
+import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
@@ -27,7 +28,7 @@
import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
-import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
@@ -40,10 +41,10 @@
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat;
import org.thoughtcrime.securesms.util.dualsim.SubscriptionManagerCompat;
-import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Locale;
import java.util.Objects;
+import java.util.Optional;
import java.util.concurrent.TimeUnit;
public class ConversationItemFooter extends ConstraintLayout {
@@ -207,14 +208,18 @@ public void disableBubbleBackground() {
setBackground(null);
}
- public @Nullable Projection getProjection() {
+ public @Nullable Projection getProjection(@NonNull ViewGroup coordinateRoot) {
if (getVisibility() == VISIBLE) {
- return Projection.relativeToViewRoot(this, new Projection.Corners(ViewUtil.dpToPx(11)));
+ return Projection.relativeToParent(coordinateRoot, this, new Projection.Corners(ViewUtil.dpToPx(11)));
} else {
return null;
}
}
+ public TextView getDateView() {
+ return dateView;
+ }
+
private void notifyTouchDelegateChanged(@NonNull Rect rect, @NonNull View touchDelegate) {
if (onTouchDelegateChangedListener != null) {
onTouchDelegateChangedListener.onTouchDelegateChanged(rect, touchDelegate);
@@ -241,7 +246,7 @@ public void onAnimationEnd(Animator animation) {
});
if (isOutgoing) {
- dateView.setMaxWidth(ViewUtil.dpToPx(28));
+ dateView.setMaxWidth(ViewUtil.dpToPx(32));
} else {
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone(this);
@@ -355,8 +360,11 @@ private void presentTimer(@NonNull final MessageRecord messageRecord) {
long id = messageRecord.getId();
boolean mms = messageRecord.isMms();
- if (mms) DatabaseFactory.getMmsDatabase(getContext()).markExpireStarted(id);
- else DatabaseFactory.getSmsDatabase(getContext()).markExpireStarted(id);
+ if (mms) {
+ SignalDatabase.mms().markExpireStarted(id);
+ } else {
+ SignalDatabase.sms().markExpireStarted(id);
+ }
expirationManager.scheduleDeletion(id, mms, messageRecord.getExpiresIn());
});
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java
index 6aac3726abf..0969b79fb6b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java
@@ -11,14 +11,11 @@
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
-import com.annimon.stream.Stream;
-
import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.badges.BadgeImageView;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
-import org.whispersystems.libsignal.util.Pair;
-import java.util.LinkedList;
import java.util.List;
public class ConversationTypingView extends ConstraintLayout {
@@ -26,6 +23,9 @@ public class ConversationTypingView extends ConstraintLayout {
private AvatarImageView avatar1;
private AvatarImageView avatar2;
private AvatarImageView avatar3;
+ private BadgeImageView badge1;
+ private BadgeImageView badge2;
+ private BadgeImageView badge3;
private View bubble;
private TypingIndicatorView indicator;
private TextView typistCount;
@@ -41,6 +41,9 @@ protected void onFinishInflate() {
avatar1 = findViewById(R.id.typing_avatar_1);
avatar2 = findViewById(R.id.typing_avatar_2);
avatar3 = findViewById(R.id.typing_avatar_3);
+ badge1 = findViewById(R.id.typing_badge_1);
+ badge2 = findViewById(R.id.typing_badge_2);
+ badge3 = findViewById(R.id.typing_badge_3);
typistCount = findViewById(R.id.typing_count);
bubble = findViewById(R.id.typing_bubble);
indicator = findViewById(R.id.typing_indicator);
@@ -55,6 +58,9 @@ public void setTypists(@NonNull GlideRequests glideRequests, @NonNull List typists) {
avatar1.setAvatar(glideRequests, typists.get(0), typists.size() == 1);
avatar1.setVisibility(VISIBLE);
+ badge1.setBadgeFromRecipient(typists.get(0), glideRequests);
+ badge1.setVisibility(VISIBLE);
if (typists.size() > 1) {
avatar2.setAvatar(glideRequests, typists.get(1), false);
avatar2.setVisibility(VISIBLE);
+ badge2.setBadgeFromRecipient(typists.get(1), glideRequests);
+ badge2.setVisibility(VISIBLE);
}
if (typists.size() == 3) {
avatar3.setAvatar(glideRequests, typists.get(2), false);
avatar3.setVisibility(VISIBLE);
+ badge3.setBadgeFromRecipient(typists.get(2), glideRequests);
+ badge3.setVisibility(VISIBLE);
}
if (typists.size() > 3) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java b/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java
index da6601d9c43..b8362c65d7f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java
@@ -7,11 +7,9 @@
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.RectF;
-import android.graphics.drawable.shapes.RoundRectShape;
import android.view.View;
import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
public class CornerMask {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java b/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java
index c430e4988d6..585a8960778 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java
@@ -17,19 +17,11 @@ public class DeliveryStatusView extends FrameLayout {
private static final String TAG = Log.tag(DeliveryStatusView.class);
- private static final RotateAnimation ROTATION_ANIMATION = new RotateAnimation(0, 360f,
- Animation.RELATIVE_TO_SELF, 0.5f,
- Animation.RELATIVE_TO_SELF, 0.5f);
- static {
- ROTATION_ANIMATION.setInterpolator(new LinearInterpolator());
- ROTATION_ANIMATION.setDuration(1500);
- ROTATION_ANIMATION.setRepeatCount(Animation.INFINITE);
- }
-
- private final ImageView pendingIndicator;
- private final ImageView sentIndicator;
- private final ImageView deliveredIndicator;
- private final ImageView readIndicator;
+ private final RotateAnimation rotationAnimation;
+ private final ImageView pendingIndicator;
+ private final ImageView sentIndicator;
+ private final ImageView deliveredIndicator;
+ private final ImageView readIndicator;
public DeliveryStatusView(Context context) {
this(context, null);
@@ -44,10 +36,17 @@ public DeliveryStatusView(final Context context, AttributeSet attrs, int defStyl
inflate(context, R.layout.delivery_status_view, this);
- this.deliveredIndicator = findViewById(R.id.delivered_indicator);
- this.sentIndicator = findViewById(R.id.sent_indicator);
- this.pendingIndicator = findViewById(R.id.pending_indicator);
- this.readIndicator = findViewById(R.id.read_indicator);
+ this.deliveredIndicator = findViewById(R.id.delivered_indicator);
+ this.sentIndicator = findViewById(R.id.sent_indicator);
+ this.pendingIndicator = findViewById(R.id.pending_indicator);
+ this.readIndicator = findViewById(R.id.read_indicator);
+
+ rotationAnimation = new RotateAnimation(0, 360f,
+ Animation.RELATIVE_TO_SELF, 0.5f,
+ Animation.RELATIVE_TO_SELF, 0.5f);
+ rotationAnimation.setInterpolator(new LinearInterpolator());
+ rotationAnimation.setDuration(1500);
+ rotationAnimation.setRepeatCount(Animation.INFINITE);
if (attrs != null) {
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.DeliveryStatusView, 0, 0);
@@ -67,7 +66,7 @@ public boolean isPending() {
public void setPending() {
this.setVisibility(View.VISIBLE);
pendingIndicator.setVisibility(View.VISIBLE);
- pendingIndicator.startAnimation(ROTATION_ANIMATION);
+ pendingIndicator.startAnimation(rotationAnimation);
sentIndicator.setVisibility(View.GONE);
deliveredIndicator.setVisibility(View.GONE);
readIndicator.setVisibility(View.GONE);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java b/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java
index 35dfd11728c..ff66b08a426 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java
@@ -27,6 +27,7 @@
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.util.Util;
+import org.whispersystems.signalservice.api.util.OptionalUtil;
public class DocumentView extends FrameLayout {
@@ -105,11 +106,11 @@ public void setDocument(final @NonNull Slide documentSlide,
this.documentSlide = documentSlide;
- this.fileName.setText(documentSlide.getFileName()
- .or(documentSlide.getCaption())
- .or(getContext().getString(R.string.DocumentView_unnamed_file)));
+ this.fileName.setText(OptionalUtil.or(documentSlide.getFileName(),
+ documentSlide.getCaption())
+ .orElse(getContext().getString(R.string.DocumentView_unnamed_file)));
this.fileSize.setText(Util.getPrettyFileSize(documentSlide.getFileSize()));
- this.document.setText(documentSlide.getFileType(getContext()).or("").toLowerCase());
+ this.document.setText(documentSlide.getFileType(getContext()).orElse("").toLowerCase());
this.setOnClickListener(new OpenClickedListener(documentSlide));
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FixedRoundedCornerBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/FixedRoundedCornerBottomSheetDialogFragment.kt
new file mode 100644
index 00000000000..a230ebeb18d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/FixedRoundedCornerBottomSheetDialogFragment.kt
@@ -0,0 +1,67 @@
+package org.thoughtcrime.securesms.components
+
+import android.app.Dialog
+import android.graphics.Color
+import android.os.Bundle
+import android.view.ContextThemeWrapper
+import android.view.View
+import androidx.annotation.ColorInt
+import androidx.annotation.StyleRes
+import androidx.core.view.ViewCompat
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.google.android.material.shape.CornerFamily
+import com.google.android.material.shape.MaterialShapeDrawable
+import com.google.android.material.shape.ShapeAppearanceModel
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.util.ThemeUtil
+import org.thoughtcrime.securesms.util.ViewUtil
+
+/**
+ * Forces rounded corners on BottomSheet
+ */
+abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFragment() {
+
+ protected open val peekHeightPercentage: Float = 0.5f
+
+ @StyleRes
+ protected open val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners
+
+ @ColorInt
+ protected var backgroundColor: Int = Color.TRANSPARENT
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setStyle(STYLE_NORMAL, themeResId)
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
+
+ dialog.behavior.peekHeight = (resources.displayMetrics.heightPixels * peekHeightPercentage).toInt()
+
+ val shapeAppearanceModel = ShapeAppearanceModel.builder()
+ .setTopLeftCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 18).toFloat())
+ .setTopRightCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 18).toFloat())
+ .build()
+
+ val dialogBackground = MaterialShapeDrawable(shapeAppearanceModel)
+
+ val bottomSheetStyle = ThemeUtil.getThemedResourceId(ContextThemeWrapper(requireContext(), themeResId), R.attr.bottomSheetStyle)
+ backgroundColor = ThemeUtil.getThemedColor(ContextThemeWrapper(requireContext(), bottomSheetStyle), R.attr.backgroundTint)
+ dialogBackground.setTint(backgroundColor)
+
+ dialog.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
+ override fun onStateChanged(bottomSheet: View, newState: Int) {
+ if (bottomSheet.background !== dialogBackground) {
+ ViewCompat.setBackground(bottomSheet, dialogBackground)
+ }
+ }
+
+ override fun onSlide(bottomSheet: View, slideOffset: Float) {}
+ })
+
+ return dialog
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FragmentWrapperActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/FragmentWrapperActivity.kt
new file mode 100644
index 00000000000..44bc86425d3
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/FragmentWrapperActivity.kt
@@ -0,0 +1,35 @@
+package org.thoughtcrime.securesms.components
+
+import android.os.Bundle
+import androidx.fragment.app.Fragment
+import org.thoughtcrime.securesms.PassphraseRequiredActivity
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
+import org.thoughtcrime.securesms.util.DynamicTheme
+
+/**
+ * Activity that wraps a given fragment
+ */
+abstract class FragmentWrapperActivity : PassphraseRequiredActivity() {
+
+ protected open val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()
+
+ override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
+ super.onCreate(savedInstanceState, ready)
+ setContentView(R.layout.fragment_container)
+ dynamicTheme.onCreate(this)
+
+ if (savedInstanceState == null) {
+ supportFragmentManager.beginTransaction()
+ .replace(R.id.fragment_container, getFragment())
+ .commit()
+ }
+ }
+
+ abstract fun getFragment(): Fragment
+
+ override fun onResume() {
+ super.onResume()
+ dynamicTheme.onResume(this)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java
index a0475e709e9..bd8fb40214f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java
@@ -3,12 +3,11 @@
import android.content.Context;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
-import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
-import android.text.style.StyleSpan;
+import android.text.style.CharacterStyle;
import android.util.AttributeSet;
import androidx.annotation.Nullable;
@@ -16,13 +15,15 @@
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
-import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
+import org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView;
import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.ContextUtil;
+import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Objects;
-public class FromTextView extends EmojiTextView {
+public class FromTextView extends SimpleEmojiTextView {
private static final String TAG = Log.tag(FromTextView.class);
@@ -43,22 +44,13 @@ public void setText(Recipient recipient, boolean read) {
}
public void setText(Recipient recipient, boolean read, @Nullable String suffix) {
- String fromString = recipient.getDisplayName(getContext());
-
- int typeface;
-
- if (!read) {
- typeface = Typeface.BOLD;
- } else {
- typeface = Typeface.NORMAL;
- }
-
- SpannableStringBuilder builder = new SpannableStringBuilder();
-
- SpannableString fromSpan = new SpannableString(fromString);
- fromSpan.setSpan(new StyleSpan(typeface), 0, builder.length(),
- Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
+ setText(recipient, recipient.getDisplayNameOrUsername(getContext()), read, suffix);
+ }
+ public void setText(Recipient recipient, @Nullable CharSequence fromString, boolean read, @Nullable String suffix) {
+ SpannableStringBuilder builder = new SpannableStringBuilder();
+ SpannableString fromSpan = new SpannableString(fromString);
+ fromSpan.setSpan(getFontSpan(!read), 0, fromSpan.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
if (recipient.isSelf()) {
builder.append(getContext().getString(R.string.note_to_self));
@@ -70,11 +62,19 @@ public void setText(Recipient recipient, boolean read, @Nullable String suffix)
builder.append(suffix);
}
+ if (recipient.showVerified()) {
+ Drawable official = ContextUtil.requireDrawable(getContext(), R.drawable.ic_official_20);
+ official.setBounds(0, 0, ViewUtil.dpToPx(20), ViewUtil.dpToPx(20));
+
+ builder.append(" ")
+ .append(SpanUtil.buildCenteredImageSpan(official));
+ }
+
setText(builder);
if (recipient.isBlocked()) setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0);
else if (recipient.isMuted()) setCompoundDrawablesRelativeWithIntrinsicBounds(getMuted(), null, null, null);
- else setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
+ else setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0);
}
private Drawable getMuted() {
@@ -85,4 +85,8 @@ private Drawable getMuted() {
return mutedDrawable;
}
+
+ private CharacterStyle getFontSpan(boolean isBold) {
+ return isBold ? SpanUtil.getBoldSpan() : SpanUtil.getNormalSpan();
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java
index c0dd74c226d..036667433bb 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java
@@ -13,7 +13,6 @@
import androidx.fragment.app.DialogFragment;
import org.thoughtcrime.securesms.R;
-import org.thoughtcrime.securesms.util.ThemeUtil;
/**
* Base dialog fragment for rendering as a full screen dialog with animation
@@ -35,7 +34,11 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.full_screen_dialog_fragment, container, false);
inflater.inflate(getDialogLayoutResource(), view.findViewById(R.id.full_screen_dialog_content), true);
toolbar = view.findViewById(R.id.full_screen_dialog_toolbar);
- toolbar.setTitle(getTitle());
+
+ if (getTitle() != -1) {
+ toolbar.setTitle(getTitle());
+ }
+
toolbar.setNavigationOnClickListener(v -> onNavigateUp());
return view;
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java
index 9188b3323cb..27e14dc5fa9 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java
@@ -53,11 +53,11 @@
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
-import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
+import java.util.Optional;
import java.util.concurrent.TimeUnit;
public class InputPanel extends LinearLayout
@@ -79,7 +79,8 @@ public class InputPanel extends LinearLayout
private ComposeText composeText;
private View quickCameraToggle;
private View quickAudioToggle;
- private View buttonToggle;
+ private AnimatingToggle buttonToggle;
+ private SendButton sendButton;
private View recordingContainer;
private View recordLockCancel;
private ViewGroup composeContainer;
@@ -96,6 +97,7 @@ public class InputPanel extends LinearLayout
private boolean hideForGroupState;
private boolean hideForBlockedState;
private boolean hideForSearch;
+ private boolean hideForSelection;
private ConversationStickerSuggestionAdapter stickerSuggestionAdapter;
@@ -126,6 +128,7 @@ public void onFinishInflate() {
this.quickCameraToggle = findViewById(R.id.quick_camera_toggle);
this.quickAudioToggle = findViewById(R.id.quick_audio_toggle);
this.buttonToggle = findViewById(R.id.button_toggle);
+ this.sendButton = findViewById(R.id.send_button);
this.recordingContainer = findViewById(R.id.recording_container);
this.recordLockCancel = findViewById(R.id.record_cancel);
this.voiceNoteDraftView = findViewById(R.id.voice_note_draft_view);
@@ -178,13 +181,19 @@ public void setQuote(@NonNull GlideRequests glideRequests,
@NonNull CharSequence body,
@NonNull SlideDeck attachments)
{
- this.quoteView.setQuote(glideRequests, id, author, body, false, attachments, null);
+ this.quoteView.setQuote(glideRequests, id, author, body, false, attachments, null, null);
int originalHeight = this.quoteView.getVisibility() == VISIBLE ? this.quoteView.getMeasuredHeight()
: 0;
this.quoteView.setVisibility(VISIBLE);
- this.quoteView.measure(0, 0);
+
+ int maxWidth = composeContainer.getWidth();
+ if (quoteView.getLayoutParams() instanceof MarginLayoutParams) {
+ MarginLayoutParams layoutParams = (MarginLayoutParams) quoteView.getLayoutParams();
+ maxWidth -= layoutParams.leftMargin + layoutParams.rightMargin;
+ }
+ this.quoteView.measure(MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST), 0);
if (quoteAnimator != null) {
quoteAnimator.cancel();
@@ -249,7 +258,7 @@ public Optional getQuote() {
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions()));
} else {
- return Optional.absent();
+ return Optional.empty();
}
}
@@ -336,6 +345,11 @@ public void setHideForSearch(boolean hideForSearch) {
updateVisibility();
}
+ public void setHideForSelection(boolean hideForSelection) {
+ this.hideForSelection = hideForSelection;
+ updateVisibility();
+ }
+
@Override
public void onRecordPermissionRequired() {
if (listener != null) listener.onRecorderPermissionRequired();
@@ -348,13 +362,13 @@ public void onRecordPressed() {
slideToCancel.display();
if (emojiVisible) {
- ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE);
+ fadeOut(mediaKeyboard);
}
- ViewUtil.fadeOut(composeText, FADE_TIME, View.INVISIBLE);
- ViewUtil.fadeOut(quickCameraToggle, FADE_TIME, View.INVISIBLE);
- ViewUtil.fadeOut(quickAudioToggle, FADE_TIME, View.INVISIBLE);
- buttonToggle.animate().alpha(0).setDuration(FADE_TIME).start();
+ fadeOut(composeText);
+ fadeOut(quickCameraToggle);
+ fadeOut(quickAudioToggle);
+ fadeOut(buttonToggle);
}
@Override
@@ -395,7 +409,7 @@ public void onRecordCanceled() {
public void onRecordLocked() {
slideToCancel.hide();
recordLockCancel.setVisibility(View.VISIBLE);
- buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
+ fadeIn(buttonToggle);
if (listener != null) listener.onRecorderLocked();
}
@@ -469,6 +483,7 @@ public void setVoiceNoteDraft(@Nullable DraftDatabase.Draft voiceNoteDraft) {
voiceNoteDraftView.setDraft(voiceNoteDraft);
voiceNoteDraftView.setVisibility(VISIBLE);
hideNormalComposeViews();
+ buttonToggle.displayQuick(sendButton);
} else {
voiceNoteDraftView.clearDraft();
ViewUtil.fadeOut(voiceNoteDraftView, FADE_TIME);
@@ -482,40 +497,37 @@ public void setVoiceNoteDraft(@Nullable DraftDatabase.Draft voiceNoteDraft) {
private void hideNormalComposeViews() {
if (emojiVisible) {
- Animation animation = mediaKeyboard.getAnimation();
- if (animation != null) {
- animation.cancel();
- }
-
- mediaKeyboard.setVisibility(View.INVISIBLE);
+ mediaKeyboard.animate().cancel();
+ mediaKeyboard.setAlpha(0f);
}
- for (Animation animation : Arrays.asList(composeText.getAnimation(), quickCameraToggle.getAnimation(), quickAudioToggle.getAnimation())) {
- if (animation != null) {
- animation.cancel();
- }
+ for (View view : Arrays.asList(composeText, quickCameraToggle, quickAudioToggle)) {
+ view.animate().cancel();
+ view.setAlpha(0f);
}
-
- buttonToggle.animate().cancel();
-
- composeText.setVisibility(View.INVISIBLE);
- quickCameraToggle.setVisibility(View.INVISIBLE);
- quickAudioToggle.setVisibility(View.INVISIBLE);
}
private void fadeInNormalComposeViews() {
if (emojiVisible) {
- ViewUtil.fadeIn(mediaKeyboard, FADE_TIME);
+ fadeIn(mediaKeyboard);
}
- ViewUtil.fadeIn(composeText, FADE_TIME);
- ViewUtil.fadeIn(quickCameraToggle, FADE_TIME);
- ViewUtil.fadeIn(quickAudioToggle, FADE_TIME);
- buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
+ fadeIn(composeText);
+ fadeIn(quickCameraToggle);
+ fadeIn(quickAudioToggle);
+ fadeIn(buttonToggle);
+ }
+
+ private void fadeIn(@NonNull View v) {
+ v.animate().alpha(1).setDuration(FADE_TIME).start();
+ }
+
+ private void fadeOut(@NonNull View v) {
+ v.animate().alpha(0).setDuration(FADE_TIME).start();
}
private void updateVisibility() {
- if (hideForGroupState || hideForBlockedState || hideForSearch) {
+ if (hideForGroupState || hideForBlockedState || hideForSearch || hideForSelection) {
setVisibility(GONE);
} else {
setVisibility(VISIBLE);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.java
index cc93a41a99b..40e9c6ba6b4 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.java
@@ -13,7 +13,6 @@
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.Guideline;
-import org.signal.glide.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardEntryDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardEntryDialogFragment.kt
new file mode 100644
index 00000000000..62ada1e9edf
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardEntryDialogFragment.kt
@@ -0,0 +1,65 @@
+package org.thoughtcrime.securesms.components
+
+import android.app.Dialog
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import androidx.annotation.LayoutRes
+import androidx.fragment.app.DialogFragment
+import org.thoughtcrime.securesms.R
+
+/**
+ * Fullscreen Dialog Fragment which will dismiss itself when the keyboard is closed
+ */
+abstract class KeyboardEntryDialogFragment(@LayoutRes contentLayoutId: Int) :
+ DialogFragment(contentLayoutId),
+ KeyboardAwareLinearLayout.OnKeyboardShownListener,
+ KeyboardAwareLinearLayout.OnKeyboardHiddenListener {
+
+ private var hasShown = false
+
+ protected open val withDim: Boolean = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setStyle(STYLE_NORMAL, R.style.Theme_Signal_RoundedBottomSheet)
+ super.onCreate(savedInstanceState)
+ }
+
+ @Suppress("DEPRECATION")
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val dialog = super.onCreateDialog(savedInstanceState)
+
+ if (!withDim) {
+ dialog.window?.setDimAmount(0f)
+ }
+
+ dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
+
+ return dialog
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ hasShown = false
+
+ val view = super.onCreateView(inflater, container, savedInstanceState)
+ return if (view is KeyboardAwareLinearLayout) {
+ view.addOnKeyboardShownListener(this)
+ view.addOnKeyboardHiddenListener(this)
+ view
+ } else {
+ throw IllegalStateException("Expected parent of view hierarchy to be keyboard aware.")
+ }
+ }
+
+ override fun onKeyboardShown() {
+ hasShown = true
+ }
+
+ override fun onKeyboardHidden() {
+ if (hasShown) {
+ dismissAllowingStateLoss()
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java b/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java
index 5f19d3ff23c..384bd233571 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java
@@ -22,13 +22,13 @@
import org.thoughtcrime.securesms.mms.ImageSlide;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.ViewUtil;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Locale;
import okhttp3.HttpUrl;
-import org.thoughtcrime.securesms.util.ViewUtil;
/**
* The view shown in the compose box or conversation that represents the state of the link preview.
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/MaskView.java b/app/src/main/java/org/thoughtcrime/securesms/components/MaskView.java
deleted file mode 100644
index 69ba95ce2c5..00000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/components/MaskView.java
+++ /dev/null
@@ -1,152 +0,0 @@
-package org.thoughtcrime.securesms.components;
-
-import android.content.Context;
-import android.graphics.Bitmap;
-import android.graphics.Canvas;
-import android.graphics.Paint;
-import android.graphics.PorterDuff;
-import android.graphics.PorterDuffXfermode;
-import android.graphics.Rect;
-import android.util.AttributeSet;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.ViewTreeObserver;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-
-public class MaskView extends View {
-
- private MaskTarget maskTarget;
- private ViewGroup activityContentView;
- private Paint maskPaint;
- private Rect drawingRect = new Rect();
- private float targetParentTranslationY;
-
- private final ViewTreeObserver.OnDrawListener onDrawListener = this::invalidate;
-
- public MaskView(@NonNull Context context) {
- super(context);
- }
-
- public MaskView(@NonNull Context context, @Nullable AttributeSet attributeSet) {
- super(context, attributeSet);
- }
-
- @Override
- protected void onFinishInflate() {
- super.onFinishInflate();
-
- maskPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
-
- setLayerType(LAYER_TYPE_HARDWARE, maskPaint);
-
- maskPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT));
- }
-
- @Override
- protected void onAttachedToWindow() {
- super.onAttachedToWindow();
- activityContentView = getRootView().findViewById(android.R.id.content);
- }
-
- public void setTarget(@Nullable MaskTarget maskTarget) {
- if (this.maskTarget != null) {
- removeOnDrawListener(this.maskTarget, onDrawListener);
- }
-
- this.maskTarget = maskTarget;
-
- if (this.maskTarget != null) {
- addOnDrawListener(maskTarget, onDrawListener);
- }
-
- invalidate();
- }
-
- public void setTargetParentTranslationY(float targetParentTranslationY) {
- this.targetParentTranslationY = targetParentTranslationY;
- }
-
- @Override
- protected void onDraw(@NonNull Canvas canvas) {
- super.onDraw(canvas);
-
- if (nothingToMask(maskTarget)) {
- return;
- }
-
- maskTarget.getPrimaryTarget().getDrawingRect(drawingRect);
- activityContentView.offsetDescendantRectToMyCoords(maskTarget.getPrimaryTarget(), drawingRect);
-
- drawingRect.top += targetParentTranslationY;
- drawingRect.bottom += targetParentTranslationY;
-
- Bitmap mask = Bitmap.createBitmap(maskTarget.getPrimaryTarget().getWidth(), drawingRect.height(), Bitmap.Config.ARGB_8888);
- Canvas maskCanvas = new Canvas(mask);
-
- maskTarget.draw(maskCanvas);
-
- canvas.clipRect(drawingRect.left, Math.max(drawingRect.top, getTop() + getPaddingTop()), drawingRect.right, Math.min(drawingRect.bottom, getBottom() - getPaddingBottom()));
-
- ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) maskTarget.getPrimaryTarget().getLayoutParams();
- canvas.drawBitmap(mask, params.leftMargin, drawingRect.top, maskPaint);
-
- mask.recycle();
- }
-
- private static void removeOnDrawListener(@NonNull MaskTarget maskTarget, @NonNull ViewTreeObserver.OnDrawListener onDrawListener) {
- for (View view : maskTarget.getAllTargets()) {
- if (view != null) {
- view.getViewTreeObserver().removeOnDrawListener(onDrawListener);
- }
- }
- }
-
- private static void addOnDrawListener(@NonNull MaskTarget maskTarget, @NonNull ViewTreeObserver.OnDrawListener onDrawListener) {
- for (View view : maskTarget.getAllTargets()) {
- if (view != null) {
- view.getViewTreeObserver().addOnDrawListener(onDrawListener);
- }
- }
- }
-
- private static boolean nothingToMask(@Nullable MaskTarget maskTarget) {
- if (maskTarget == null) {
- return true;
- }
-
- for (View view : maskTarget.getAllTargets()) {
- if (view == null || !view.isAttachedToWindow()) {
- return true;
- }
- }
-
- return false;
- }
-
- public static class MaskTarget {
-
- private final View primaryTarget;
-
- public MaskTarget(@NonNull View primaryTarget) {
- this.primaryTarget = primaryTarget;
- }
-
- final @NonNull View getPrimaryTarget() {
- return primaryTarget;
- }
-
- protected @NonNull List getAllTargets() {
- return Collections.singletonList(primaryTarget);
- }
-
- protected void draw(@NonNull Canvas canvas) {
- primaryTarget.draw(canvas);
- }
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/MicrophoneRecorderView.java b/app/src/main/java/org/thoughtcrime/securesms/components/MicrophoneRecorderView.java
index 320304bb56a..c73d8120fb5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/MicrophoneRecorderView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/MicrophoneRecorderView.java
@@ -19,7 +19,6 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import androidx.core.view.ViewCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.permissions.Permissions;
@@ -101,7 +100,7 @@ public boolean onTouch(View v, final MotionEvent event) {
case MotionEvent.ACTION_DOWN:
if (!Permissions.hasAll(getContext(), Manifest.permission.RECORD_AUDIO)) {
if (listener != null) listener.onRecordPermissionRequired();
- } else {
+ } else if (state == State.NOT_RUNNING) {
state = State.RUNNING_HELD;
floatingRecordButton.display(event.getX(), event.getY());
lockDropTarget.display();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java
index 407222ae5c6..54c4920d216 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java
@@ -29,13 +29,17 @@ private void init(@Nullable AttributeSet attrs) {
cornerMask = new CornerMask(this);
outliner = new Outliner();
- outliner.setColor(ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_20));
+ int defaultOutlinerColor = ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_20);
+ outliner.setColor(defaultOutlinerColor);
int radius = 0;
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.OutlinedThumbnailView, 0, 0);
radius = typedArray.getDimensionPixelOffset(R.styleable.OutlinedThumbnailView_otv_cornerRadius, 0);
+
+ outliner.setStrokeWidth(typedArray.getDimensionPixelSize(R.styleable.OutlinedThumbnailView_otv_strokeWidth, 1));
+ outliner.setColor(typedArray.getColor(R.styleable.OutlinedThumbnailView_otv_strokeColor, defaultOutlinerColor));
}
setRadius(radius);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/PushRecipientsPanel.java b/app/src/main/java/org/thoughtcrime/securesms/components/PushRecipientsPanel.java
deleted file mode 100644
index 7a320ce8380..00000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/components/PushRecipientsPanel.java
+++ /dev/null
@@ -1,174 +0,0 @@
-/**
- * Copyright (C) 2011 Whisper Systems
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.thoughtcrime.securesms.components;
-
-import android.content.Context;
-import android.text.TextUtils;
-import android.util.AttributeSet;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.widget.AdapterView;
-import android.widget.RelativeLayout;
-
-import androidx.annotation.NonNull;
-
-import com.annimon.stream.Stream;
-
-import org.signal.core.util.logging.Log;
-import org.thoughtcrime.securesms.R;
-import org.thoughtcrime.securesms.contacts.RecipientsAdapter;
-import org.thoughtcrime.securesms.contacts.RecipientsEditor;
-import org.thoughtcrime.securesms.recipients.Recipient;
-import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
-
-import java.util.LinkedList;
-import java.util.List;
-import java.util.StringTokenizer;
-
-/**
- * Panel component combining both an editable field with a button for
- * a list-based contact selector.
- *
- * @author Moxie Marlinspike
- */
-public class PushRecipientsPanel extends RelativeLayout implements RecipientForeverObserver {
- private final String TAG = Log.tag(PushRecipientsPanel.class);
- private RecipientsPanelChangedListener panelChangeListener;
-
- private RecipientsEditor recipientsText;
- private View panel;
-
- private static final int RECIPIENTS_MAX_LENGTH = 312;
-
- public PushRecipientsPanel(Context context) {
- super(context);
- initialize();
- }
-
- public PushRecipientsPanel(Context context, AttributeSet attrs) {
- super(context, attrs);
- initialize();
- }
-
- public PushRecipientsPanel(Context context, AttributeSet attrs, int defStyle) {
- super(context, attrs, defStyle);
- initialize();
- }
-
- @Override
- protected void onDetachedFromWindow() {
- super.onDetachedFromWindow();
- Stream.of(getRecipients()).map(Recipient::live).forEach(r -> r.removeForeverObserver(this));
- }
-
- public List getRecipients() {
- String rawText = recipientsText.getText().toString();
- return getRecipientsFromString(getContext(), rawText);
- }
-
- public void disable() {
- recipientsText.setText("");
- panel.setVisibility(View.GONE);
- }
-
- public void setPanelChangeListener(RecipientsPanelChangedListener panelChangeListener) {
- this.panelChangeListener = panelChangeListener;
- }
-
- private void initialize() {
- LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
- inflater.inflate(R.layout.push_recipients_panel, this, true);
-
- View imageButton = findViewById(R.id.contacts_button);
- ((MarginLayoutParams) imageButton.getLayoutParams()).topMargin = 0;
-
- panel = findViewById(R.id.recipients_panel);
- initRecipientsEditor();
- }
-
- private void initRecipientsEditor() {
-
- this.recipientsText = (RecipientsEditor)findViewById(R.id.recipients_text);
-
- List recipients = getRecipients();
-
- Stream.of(recipients).map(Recipient::live).forEach(r -> r.observeForever(this));
-
- recipientsText.setAdapter(new RecipientsAdapter(this.getContext()));
- recipientsText.populate(recipients);
-
- recipientsText.setOnFocusChangeListener(new FocusChangedListener());
- recipientsText.setOnItemClickListener(new AdapterView.OnItemClickListener() {
- @Override
- public void onItemClick(AdapterView> adapterView, View view, int i, long l) {
- if (panelChangeListener != null) {
- panelChangeListener.onRecipientsPanelUpdate(getRecipients());
- }
- recipientsText.setText("");
- }
- });
- }
-
- private @NonNull List getRecipientsFromString(Context context, @NonNull String rawText) {
- StringTokenizer tokenizer = new StringTokenizer(rawText, ",");
- List recipients = new LinkedList<>();
-
- while (tokenizer.hasMoreTokens()) {
- String token = tokenizer.nextToken().trim();
-
- if (!TextUtils.isEmpty(token)) {
- if (hasBracketedNumber(token)) recipients.add(Recipient.external(context, parseBracketedNumber(token)));
- else recipients.add(Recipient.external(context, token));
- }
- }
-
- return recipients;
- }
-
- private boolean hasBracketedNumber(String recipient) {
- int openBracketIndex = recipient.indexOf('<');
-
- return (openBracketIndex != -1) &&
- (recipient.indexOf('>', openBracketIndex) != -1);
- }
-
- private String parseBracketedNumber(String recipient) {
- int begin = recipient.indexOf('<');
- int end = recipient.indexOf('>', begin);
- String value = recipient.substring(begin + 1, end);
-
- return value;
- }
-
- @Override
- public void onRecipientChanged(@NonNull Recipient recipient) {
- recipientsText.populate(getRecipients());
- }
-
- private class FocusChangedListener implements View.OnFocusChangeListener {
- public void onFocusChange(View v, boolean hasFocus) {
- if (!hasFocus && (panelChangeListener != null)) {
- panelChangeListener.onRecipientsPanelUpdate(getRecipients());
- }
- }
- }
-
- public interface RecipientsPanelChangedListener {
- public void onRecipientsPanelUpdate(List recipients);
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java
index c1df0734946..0109702b770 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java
@@ -19,12 +19,12 @@
import androidx.annotation.RequiresApi;
import androidx.core.content.ContextCompat;
-import com.annimon.stream.Stream;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
+import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.database.model.Mention;
@@ -35,29 +35,57 @@
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
+import org.thoughtcrime.securesms.stories.StoryTextPostModel;
+import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.ThemeUtil;
+import org.thoughtcrime.securesms.util.Util;
+import java.io.IOException;
import java.util.List;
public class QuoteView extends FrameLayout implements RecipientForeverObserver {
private static final String TAG = Log.tag(QuoteView.class);
- private static final int MESSAGE_TYPE_PREVIEW = 0;
- private static final int MESSAGE_TYPE_OUTGOING = 1;
- private static final int MESSAGE_TYPE_INCOMING = 2;
-
- private ViewGroup mainView;
- private ViewGroup footerView;
- private TextView authorView;
- private TextView bodyView;
- private View quoteBarView;
- private ImageView thumbnailView;
- private View attachmentVideoOverlayView;
- private ViewGroup attachmentContainerView;
- private TextView attachmentNameView;
- private ImageView dismissView;
+ public enum MessageType {
+ // These codes must match the values for the QuoteView_message_type XML attribute.
+ PREVIEW(0),
+ OUTGOING(1),
+ INCOMING(2),
+ STORY_REPLY_OUTGOING(3),
+ STORY_REPLY_INCOMING(4),
+ STORY_REPLY_PREVIEW(5);
+
+ private final int code;
+
+ MessageType(int code) {
+ this.code = code;
+ }
+
+ private static @NonNull MessageType fromCode(int code) {
+ for (MessageType value : values()) {
+ if (value.code == code) {
+ return value;
+ }
+ }
+
+ throw new IllegalArgumentException("Unsupported code " + code);
+ }
+ }
+
+ private ViewGroup mainView;
+ private ViewGroup footerView;
+ private TextView authorView;
+ private TextView bodyView;
+ private View quoteBarView;
+ private ImageView thumbnailView;
+ private View attachmentVideoOverlayView;
+ private ViewGroup attachmentContainerView;
+ private TextView attachmentNameView;
+ private ImageView dismissView;
+ private EmojiImageView missingStoryReaction;
+ private EmojiImageView storyReactionEmoji;
private long id;
private LiveRecipient author;
@@ -65,11 +93,13 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
private TextView mediaDescriptionText;
private TextView missingLinkText;
private SlideDeck attachments;
- private int messageType;
+ private MessageType messageType;
private int largeCornerRadius;
private int smallCornerRadius;
private CornerMask cornerMask;
+ private int thumbHeight;
+ private int thumbWidth;
public QuoteView(Context context) {
super(context);
@@ -107,34 +137,31 @@ private void initialize(@Nullable AttributeSet attrs) {
this.dismissView = findViewById(R.id.quote_dismiss);
this.mediaDescriptionText = findViewById(R.id.media_type);
this.missingLinkText = findViewById(R.id.quote_missing_text);
+ this.missingStoryReaction = findViewById(R.id.quote_missing_story_reaction_emoji);
+ this.storyReactionEmoji = findViewById(R.id.quote_story_reaction_emoji);
this.largeCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_large);
this.smallCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_bottom);
cornerMask = new CornerMask(this);
- cornerMask.setRadii(largeCornerRadius, largeCornerRadius, smallCornerRadius, smallCornerRadius);
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.QuoteView, 0, 0);
int primaryColor = typedArray.getColor(R.styleable.QuoteView_quote_colorPrimary, Color.BLACK);
int secondaryColor = typedArray.getColor(R.styleable.QuoteView_quote_colorSecondary, Color.BLACK);
- messageType = typedArray.getInt(R.styleable.QuoteView_message_type, 0);
+ messageType = MessageType.fromCode(typedArray.getInt(R.styleable.QuoteView_message_type, 0));
typedArray.recycle();
- dismissView.setVisibility(messageType == MESSAGE_TYPE_PREVIEW ? VISIBLE : GONE);
+ dismissView.setVisibility(messageType == MessageType.PREVIEW ? VISIBLE : GONE);
authorView.setTextColor(primaryColor);
bodyView.setTextColor(primaryColor);
attachmentNameView.setTextColor(primaryColor);
mediaDescriptionText.setTextColor(secondaryColor);
missingLinkText.setTextColor(primaryColor);
-
- if (messageType == MESSAGE_TYPE_PREVIEW) {
- int radius = getResources().getDimensionPixelOffset(R.dimen.quote_corner_radius_preview);
- cornerMask.setTopLeftRadius(radius);
- cornerMask.setTopRightRadius(radius);
- }
}
+ setMessageType(messageType);
+
dismissView.setOnClickListener(view -> setVisibility(GONE));
}
@@ -150,13 +177,36 @@ protected void onDetachedFromWindow() {
if (author != null) author.removeForeverObserver(this);
}
+ public void setMessageType(@NonNull MessageType messageType) {
+ this.messageType = messageType;
+
+ cornerMask.setRadii(largeCornerRadius, largeCornerRadius, smallCornerRadius, smallCornerRadius);
+ thumbWidth = thumbHeight = getResources().getDimensionPixelSize(R.dimen.quote_thumb_size);
+
+ if (messageType == MessageType.PREVIEW) {
+ int radius = getResources().getDimensionPixelOffset(R.dimen.quote_corner_radius_preview);
+ cornerMask.setTopLeftRadius(radius);
+ cornerMask.setTopRightRadius(radius);
+ } else if (isStoryReply()) {
+ thumbWidth = getResources().getDimensionPixelOffset(R.dimen.quote_story_thumb_width);
+ thumbHeight = getResources().getDimensionPixelOffset(R.dimen.quote_story_thumb_height);
+ }
+
+ ViewGroup.LayoutParams params = thumbnailView.getLayoutParams();
+ params.height = thumbHeight;
+ params.width = thumbWidth;
+
+ thumbnailView.setLayoutParams(params);
+ }
+
public void setQuote(GlideRequests glideRequests,
long id,
@NonNull Recipient author,
@Nullable CharSequence body,
boolean originalMissing,
@NonNull SlideDeck attachments,
- @Nullable ChatColors chatColors)
+ @Nullable ChatColors chatColors,
+ @Nullable String storyReaction)
{
if (this.author != null) this.author.removeForeverObserver(this);
@@ -167,11 +217,11 @@ public void setQuote(GlideRequests glideRequests,
this.author.observeForever(this);
setQuoteAuthor(author);
- setQuoteText(body, attachments);
- setQuoteAttachment(glideRequests, attachments);
+ setQuoteText(body, attachments, originalMissing, storyReaction);
+ setQuoteAttachment(glideRequests, body, attachments, originalMissing);
setQuoteMissingFooter(originalMissing);
- if (Build.VERSION.SDK_INT < 21 && messageType == MESSAGE_TYPE_INCOMING && chatColors != null) {
+ if (Build.VERSION.SDK_INT < 21 && messageType == MessageType.INCOMING && chatColors != null) {
this.setBackgroundColor(chatColors.asSingleColor());
} else {
this.setBackground(null);
@@ -207,20 +257,81 @@ public void onRecipientChanged(@NonNull Recipient recipient) {
}
private void setQuoteAuthor(@NonNull Recipient author) {
- boolean outgoing = messageType != MESSAGE_TYPE_INCOMING;
- boolean preview = messageType == MESSAGE_TYPE_PREVIEW;
+ boolean outgoing = messageType != MessageType.INCOMING && messageType != MessageType.STORY_REPLY_INCOMING;
+ boolean preview = messageType == MessageType.PREVIEW || messageType == MessageType.STORY_REPLY_PREVIEW;
+
+ if (isStoryReply()) {
+ authorView.setText(author.isSelf() ? getContext().getString(R.string.QuoteView_your_story)
+ : getContext().getString(R.string.QuoteView_s_story, author.getDisplayName(getContext())));
+ } else {
+ authorView.setText(author.isSelf() ? getContext().getString(R.string.QuoteView_you)
+ : author.getDisplayName(getContext()));
+ }
- authorView.setText(author.isSelf() ? getContext().getString(R.string.QuoteView_you)
- : author.getDisplayName(getContext()));
+ quoteBarView.setBackgroundColor(ContextCompat.getColor(getContext(), outgoing || isStoryReply() ? R.color.core_white : android.R.color.transparent));
+
+ int mainViewColor;
+ if (preview) {
+ mainViewColor = R.color.quote_preview_background;
+ } else if (!outgoing && isStoryReply()) {
+ mainViewColor = R.color.quote_incoming_story_background;
+ } else {
+ mainViewColor = R.color.quote_view_background;
+ }
+
+ mainView.setBackgroundColor(ContextCompat.getColor(getContext(), mainViewColor));
+ }
- quoteBarView.setBackgroundColor(ContextCompat.getColor(getContext(), outgoing ? R.color.core_white : android.R.color.transparent));
- mainView.setBackgroundColor(ContextCompat.getColor(getContext(), preview ? R.color.quote_preview_background : R.color.quote_view_background));
+ private boolean isStoryReply() {
+ return messageType == MessageType.STORY_REPLY_OUTGOING ||
+ messageType == MessageType.STORY_REPLY_INCOMING ||
+ messageType == MessageType.STORY_REPLY_PREVIEW;
}
- private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attachments) {
+ private void setQuoteText(@Nullable CharSequence body,
+ @NonNull SlideDeck attachments,
+ boolean originalMissing,
+ @Nullable String storyReaction)
+ {
+ if (originalMissing && isStoryReply()) {
+ bodyView.setVisibility(GONE);
+ storyReactionEmoji.setVisibility(View.GONE);
+ mediaDescriptionText.setVisibility(VISIBLE);
+
+ mediaDescriptionText.setText(R.string.QuoteView_no_longer_available);
+ if (storyReaction != null) {
+ missingStoryReaction.setVisibility(View.VISIBLE);
+ missingStoryReaction.setImageEmoji(body);
+ } else {
+ missingStoryReaction.setVisibility(View.GONE);
+ }
+ return;
+ }
+
+ if (storyReaction != null) {
+ storyReactionEmoji.setImageEmoji(storyReaction);
+ storyReactionEmoji.setVisibility(View.VISIBLE);
+ missingStoryReaction.setVisibility(View.INVISIBLE);
+ } else {
+ storyReactionEmoji.setVisibility(View.GONE);
+ missingStoryReaction.setVisibility(View.GONE);
+ }
+
+ boolean isTextStory = !attachments.containsMediaSlide() && isStoryReply();
+
if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) {
+ if (isTextStory && body != null) {
+ try {
+ bodyView.setText(getStoryTextPost(body).getText());
+ } catch (Exception e) {
+ Log.w(TAG, "Could not parse body of text post.", e);
+ bodyView.setText("");
+ }
+ } else {
+ bodyView.setText(body == null ? "" : body);
+ }
+
bodyView.setVisibility(VISIBLE);
- bodyView.setText(body == null ? "" : body);
mediaDescriptionText.setVisibility(GONE);
return;
}
@@ -228,55 +339,78 @@ private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attach
bodyView.setVisibility(GONE);
mediaDescriptionText.setVisibility(VISIBLE);
- List audioSlides = Stream.of(attachments.getSlides()).filter(Slide::hasAudio).limit(1).toList();
- List documentSlides = Stream.of(attachments.getSlides()).filter(Slide::hasDocument).limit(1).toList();
- List imageSlides = Stream.of(attachments.getSlides()).filter(Slide::hasImage).limit(1).toList();
- List videoSlides = Stream.of(attachments.getSlides()).filter(Slide::hasVideo).limit(1).toList();
- List stickerSlides = Stream.of(attachments.getSlides()).filter(Slide::hasSticker).limit(1).toList();
- List viewOnceSlides = Stream.of(attachments.getSlides()).filter(Slide::hasViewOnce).limit(1).toList();
+ Slide audioSlide = attachments.getSlides().stream().filter(Slide::hasAudio).findFirst().orElse(null);
+ Slide documentSlide = attachments.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
+ Slide imageSlide = attachments.getSlides().stream().filter(Slide::hasImage).findFirst().orElse(null);
+ Slide videoSlide = attachments.getSlides().stream().filter(Slide::hasVideo).findFirst().orElse(null);
+ Slide stickerSlide = attachments.getSlides().stream().filter(Slide::hasSticker).findFirst().orElse(null);
+ Slide viewOnceSlide = attachments.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null);
// Given that most types have images, we specifically check images last
- if (!viewOnceSlides.isEmpty()) {
+ if (viewOnceSlide != null) {
mediaDescriptionText.setText(R.string.QuoteView_view_once_media);
- } else if (!audioSlides.isEmpty()) {
+ } else if (audioSlide != null) {
mediaDescriptionText.setText(R.string.QuoteView_audio);
- } else if (!documentSlides.isEmpty()) {
+ } else if (documentSlide != null) {
mediaDescriptionText.setVisibility(GONE);
- } else if (!videoSlides.isEmpty()) {
- mediaDescriptionText.setText(R.string.QuoteView_video);
- } else if (!stickerSlides.isEmpty()) {
+ } else if (videoSlide != null) {
+ if (videoSlide.isVideoGif()) {
+ mediaDescriptionText.setText(R.string.QuoteView_gif);
+ } else {
+ mediaDescriptionText.setText(R.string.QuoteView_video);
+ }
+ } else if (stickerSlide != null) {
mediaDescriptionText.setText(R.string.QuoteView_sticker);
- } else if (!imageSlides.isEmpty()) {
- mediaDescriptionText.setText(R.string.QuoteView_photo);
+ } else if (imageSlide != null) {
+ if (MediaUtil.isGif(imageSlide.getContentType())) {
+ mediaDescriptionText.setText(R.string.QuoteView_gif);
+ } else {
+ mediaDescriptionText.setText(R.string.QuoteView_photo);
+ }
}
}
- private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull SlideDeck slideDeck) {
- List imageVideoSlides = Stream.of(slideDeck.getSlides()).filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).limit(1).toList();
- List documentSlides = Stream.of(attachments.getSlides()).filter(Slide::hasDocument).limit(1).toList();
- List viewOnceSlides = Stream.of(attachments.getSlides()).filter(Slide::hasViewOnce).limit(1).toList();
+ private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull CharSequence body, @NonNull SlideDeck slideDeck, boolean originalMissing) {
+ mainView.setMinimumHeight(isStoryReply() && originalMissing ? 0 : thumbHeight);
+
+ if (!attachments.containsMediaSlide() && isStoryReply()) {
+ StoryTextPostModel model = getStoryTextPost(body);
+ attachmentVideoOverlayView.setVisibility(GONE);
+ attachmentContainerView.setVisibility(GONE);
+ thumbnailView.setVisibility(VISIBLE);
+ glideRequests.load(model)
+ .centerCrop()
+ .override(thumbWidth, thumbHeight)
+ .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
+ .into(thumbnailView);
+ return;
+ }
+
+ Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
+ Slide documentSlide = slideDeck.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
+ Slide viewOnceSlide = slideDeck.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null);
attachmentVideoOverlayView.setVisibility(GONE);
- if (!viewOnceSlides.isEmpty()) {
+ if (viewOnceSlide != null) {
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(GONE);
- } else if (!imageVideoSlides.isEmpty() && imageVideoSlides.get(0).getUri() != null) {
+ } else if (imageVideoSlide != null && imageVideoSlide.getUri() != null) {
thumbnailView.setVisibility(VISIBLE);
attachmentContainerView.setVisibility(GONE);
dismissView.setBackgroundResource(R.drawable.dismiss_background);
- if (imageVideoSlides.get(0).hasVideo()) {
+ if (imageVideoSlide.hasVideo() && !imageVideoSlide.isVideoGif()) {
attachmentVideoOverlayView.setVisibility(VISIBLE);
}
- glideRequests.load(new DecryptableUri(imageVideoSlides.get(0).getUri()))
+ glideRequests.load(new DecryptableUri(imageVideoSlide.getUri()))
.centerCrop()
- .override(getContext().getResources().getDimensionPixelSize(R.dimen.quote_thumb_size))
+ .override(thumbWidth, thumbHeight)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.into(thumbnailView);
- } else if (!documentSlides.isEmpty()){
+ } else if (documentSlide != null){
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(VISIBLE);
- attachmentNameView.setText(documentSlides.get(0).getFileName().or(""));
+ attachmentNameView.setText(documentSlide.getFileName().orElse(""));
} else {
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(GONE);
@@ -289,10 +423,22 @@ private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull S
}
private void setQuoteMissingFooter(boolean missing) {
- footerView.setVisibility(missing ? VISIBLE : GONE);
+ footerView.setVisibility(missing && !isStoryReply() ? VISIBLE : GONE);
footerView.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.quote_view_background));
}
+ private @Nullable StoryTextPostModel getStoryTextPost(@Nullable CharSequence body) {
+ if (Util.isEmpty(body)) {
+ return null;
+ }
+
+ try {
+ return StoryTextPostModel.parseFrom(body.toString(), id, author.getId());
+ } catch (IOException ioException) {
+ return null;
+ }
+ }
+
public void setTextSize(int unit, float size) {
bodyView.setTextSize(unit, size);
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RatingManager.java b/app/src/main/java/org/thoughtcrime/securesms/components/RatingManager.java
index 5c4be4b1c81..30ec67e88e3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/RatingManager.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/RatingManager.java
@@ -1,11 +1,7 @@
package org.thoughtcrime.securesms.components;
-import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.DialogInterface;
-import android.content.Intent;
-import android.net.Uri;
-import android.widget.Toast;
import androidx.appcompat.app.AlertDialog;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RotatableGradientDrawable.java b/app/src/main/java/org/thoughtcrime/securesms/components/RotatableGradientDrawable.java
index 60fb3e26480..b5160e4fe33 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/RotatableGradientDrawable.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/RotatableGradientDrawable.java
@@ -8,6 +8,7 @@
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.Shader;
+import android.graphics.Xfermode;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
@@ -15,7 +16,6 @@
import java.util.Arrays;
-import kotlin.jvm.functions.Function1;
import kotlin.jvm.functions.Function2;
/**
@@ -100,6 +100,10 @@ public void setBounds(int left, int top, int right, int bottom) {
fillPaint.setShader(new LinearGradient(fillRect.left, fillRect.top, fillRect.right, fillRect.bottom, colors, positions, Shader.TileMode.CLAMP));
}
+ public void setXfermode(@NonNull Xfermode xfermode) {
+ fillPaint.setXfermode(xfermode);
+ }
+
private static Point cornerPrime(@NonNull Point origin, @NonNull Point corner, float degrees) {
return new Point(xPrime(origin, corner, Math.toRadians(degrees)), yPrime(origin, corner, Math.toRadians(degrees)));
}
@@ -116,7 +120,11 @@ private static int yPrime(@NonNull Point origin, @NonNull Point corner, double t
public void draw(Canvas canvas) {
int save = canvas.save();
canvas.rotate(degrees, getBounds().width() / 2f, getBounds().height() / 2f);
- canvas.drawRect(fillRect, fillPaint);
+
+ int height = fillRect.height();
+ int width = fillRect.width();
+ canvas.drawRect(fillRect.left - width, fillRect.top - height, fillRect.right + width, fillRect.bottom + height, fillPaint);
+
canvas.restoreToCount(save);
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java b/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java
index 75bbbf9ab5c..94470c7f7d3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java
@@ -58,6 +58,7 @@ private void initialize() {
EditText searchText = searchView.findViewById(R.id.search_src_text);
searchView.setSubmitButtonEnabled(false);
+ searchView.setMaxWidth(Integer.MAX_VALUE);
if (searchText != null) searchText.setHint(R.string.SearchToolbar_search);
else searchView.setQueryHint(getResources().getString(R.string.SearchToolbar_search));
@@ -65,7 +66,9 @@ private void initialize() {
searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
@Override
public boolean onQueryTextSubmit(String query) {
- if (listener != null) listener.onSearchTextChange(query);
+ if (listener != null) {
+ listener.onSearchTextChange(query);
+ }
return true;
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SearchView.java b/app/src/main/java/org/thoughtcrime/securesms/components/SearchView.java
index c2ad5f94f0f..3981c3a1b0a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/SearchView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/SearchView.java
@@ -50,7 +50,7 @@ private InputFilter[] appendEmojiFilter(@NonNull TextView view) {
result = new InputFilter[1];
}
- result[0] = new EmojiFilter(view);
+ result[0] = new EmojiFilter(view, false);
return result;
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SendButton.java b/app/src/main/java/org/thoughtcrime/securesms/components/SendButton.java
index ea85921fcf7..6e325c9e7e7 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/SendButton.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/SendButton.java
@@ -12,7 +12,9 @@
import org.thoughtcrime.securesms.TransportOptions.OnTransportChangedListener;
import org.thoughtcrime.securesms.TransportOptionsPopup;
import org.thoughtcrime.securesms.util.ViewUtil;
-import org.whispersystems.libsignal.util.guava.Optional;
+
+import java.util.Optional;
+
public class SendButton extends AppCompatImageButton
implements TransportOptions.OnTransportChangedListener,
@@ -22,7 +24,7 @@ public class SendButton extends AppCompatImageButton
private final TransportOptions transportOptions;
- private Optional transportOptionsPopup = Optional.absent();
+ private Optional transportOptionsPopup = Optional.empty();
@SuppressWarnings("unused")
public SendButton(Context context) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java b/app/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java
index 20055c05993..9c182499253 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java
@@ -23,9 +23,12 @@ private enum ShapeType {
private final Paint eraser;
private final ShapeType shape;
private final float radius;
+ private final int canvasColor;
private Bitmap scrim;
private Canvas scrimCanvas;
+ private int scrimWidth;
+ private int scrimHeight;
public ShapeScrim(Context context) {
this(context, null);
@@ -57,13 +60,30 @@ public ShapeScrim(Context context, AttributeSet attrs, int defStyleAttr) {
this.eraser = new Paint();
this.eraser.setColor(0xFFFFFFFF);
this.eraser.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
+
+ this.canvasColor = Color.parseColor("#55BDBDBD");
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ super.onLayout(changed, l, t, r, b);
+ int shortDimension = Math.min(getWidth(), getHeight());
+ float drawRadius = shortDimension * radius;
+
+ float left = (getMeasuredWidth() / 2 ) - drawRadius;
+ float top = (getMeasuredHeight() / 2) - drawRadius;
+ float right = left + (drawRadius * 2);
+ float bottom = top + (drawRadius * 2);
+
+ scrimWidth = (int) (right - left);
+ scrimHeight = (int) (bottom - top);
}
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
- int shortDimension = getWidth() < getHeight() ? getWidth() : getHeight();
+ int shortDimension = Math.min(getWidth(), getHeight());
float drawRadius = shortDimension * radius;
if (scrimCanvas == null) {
@@ -72,7 +92,7 @@ public void onDraw(Canvas canvas) {
}
scrim.eraseColor(Color.TRANSPARENT);
- scrimCanvas.drawColor(Color.parseColor("#55BDBDBD"));
+ scrimCanvas.drawColor(canvasColor);
if (shape == ShapeType.CIRCLE) drawCircle(scrimCanvas, drawRadius, eraser);
else drawSquare(scrimCanvas, drawRadius, eraser);
@@ -104,4 +124,12 @@ private void drawSquare(Canvas canvas, float radius, Paint eraser) {
canvas.drawRoundRect(square, 25, 25, eraser);
}
+
+ public int getScrimWidth() {
+ return scrimWidth;
+ }
+
+ public int getScrimHeight() {
+ return scrimHeight;
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java
index 753604dce19..62fdf5a25c7 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java
@@ -5,11 +5,8 @@
import android.graphics.Bitmap;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
-import android.graphics.drawable.Drawable;
-import android.graphics.drawable.ShapeDrawable;
-import android.graphics.drawable.shapes.RoundRectShape;
-import android.graphics.drawable.shapes.Shape;
import android.net.Uri;
+import android.os.Build;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
@@ -18,7 +15,6 @@
import androidx.annotation.NonNull;
import androidx.annotation.UiThread;
-import androidx.core.content.ContextCompat;
import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
@@ -32,26 +28,23 @@
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.blurhash.BlurHash;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
-import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequest;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
+import org.thoughtcrime.securesms.stories.StoryTextPostModel;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
-import org.thoughtcrime.securesms.util.views.Stub;
-import org.thoughtcrime.securesms.video.VideoPlayer;
-import org.whispersystems.libsignal.util.guava.Optional;
-import java.util.Arrays;
import java.util.Collections;
import java.util.Locale;
import java.util.Objects;
+import java.util.Optional;
import java.util.concurrent.ExecutionException;
import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade;
@@ -76,7 +69,7 @@ public class ThumbnailView extends FrameLayout {
private final int[] bounds = new int[4];
private final int[] measureDimens = new int[2];
- private Optional transferControls = Optional.absent();
+ private Optional transferControls = Optional.empty();
private SlideClickListener thumbnailClickListener = null;
private SlidesClickedListener downloadClickListener = null;
private Slide slide = null;
@@ -399,6 +392,32 @@ public ListenableFuture setImageResource(@NonNull GlideRequests glideRe
return future;
}
+ public ListenableFuture setImageResource(@NonNull GlideRequests glideRequests, @NonNull StoryTextPostModel model, int width, int height) {
+ SettableFuture future = new SettableFuture<>();
+
+ if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE);
+
+ GlideRequest request = glideRequests.load(model)
+ .diskCacheStrategy(DiskCacheStrategy.NONE)
+ .placeholder(model.getPlaceholder())
+ .transition(withCrossFade());
+
+ if (width > 0 && height > 0) {
+ request = request.override(width, height);
+ }
+
+ if (radius > 0) {
+ request = request.transforms(new CenterCrop(), new RoundedCorners(radius));
+ } else {
+ request = request.transforms(new CenterCrop());
+ }
+
+ request.into(new GlideDrawableListeningTarget(image, future));
+ blurhash.setImageDrawable(null);
+
+ return future;
+ }
+
public void setThumbnailClickListener(SlideClickListener listener) {
this.thumbnailClickListener = listener;
}
@@ -409,11 +428,15 @@ public void setDownloadClickListener(SlidesClickedListener listener) {
public void clear(GlideRequests glideRequests) {
glideRequests.clear(image);
+ image.setImageDrawable(null);
if (transferControls.isPresent()) {
getTransferControls().clear();
}
+ glideRequests.clear(blurhash);
+ blurhash.setImageDrawable(null);
+
slide = null;
}
@@ -438,8 +461,10 @@ private GlideRequest buildThumbnailGlideRequest(@NonNull GlideRequests glideRequ
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.transition(withCrossFade()), fit);
- if (slide.isInProgress()) return request;
- else return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture));
+ boolean doNotShowMissingThumbnailImage = Build.VERSION.SDK_INT < 23;
+
+ if (slide.isInProgress() || doNotShowMissingThumbnailImage) return request;
+ else return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture));
}
private RequestBuilder buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java
index 13b63be883b..6b6408d40fa 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java
@@ -2,14 +2,20 @@
import android.annotation.SuppressLint;
import android.content.Context;
+import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.util.AttributeSet;
import android.view.View;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.exifinterface.media.ExifInterface;
+import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
+import com.bumptech.glide.load.engine.GlideException;
+import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.target.Target;
import com.davemorrissey.labs.subscaleview.ImageSource;
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView;
@@ -23,11 +29,12 @@
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.PartAuthority;
+import org.thoughtcrime.securesms.util.ActionRequestListener;
import org.thoughtcrime.securesms.util.BitmapDecodingException;
import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
-import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
+import org.signal.core.util.concurrent.SimpleTask;
import java.io.IOException;
import java.io.InputStream;
@@ -66,8 +73,6 @@ public ZoomingImageView(Context context, AttributeSet attrs, int defStyleAttr) {
this.photoView = findViewById(R.id.image_view);
this.subsamplingImageView = findViewById(R.id.subsampling_image_view);
- this.subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_USE_EXIF);
-
this.photoView.setZoomTransitionDuration(ZOOM_TRANSITION_DURATION);
this.photoView.setScaleLevels(ZOOM_LEVEL_MIN, SMALL_IMAGES_ZOOM_LEVEL_MID, SMALL_IMAGES_ZOOM_LEVEL_MAX);
@@ -80,7 +85,7 @@ public ZoomingImageView(Context context, AttributeSet attrs, int defStyleAttr) {
}
@SuppressLint("StaticFieldLeak")
- public void setImageUri(@NonNull GlideRequests glideRequests, @NonNull Uri uri, @NonNull String contentType)
+ public void setImageUri(@NonNull GlideRequests glideRequests, @NonNull Uri uri, @NonNull String contentType, @NonNull Runnable onMediaReady)
{
final Context context = getContext();
final int maxTextureSize = BitmapUtil.getMaxTextureSize();
@@ -102,15 +107,16 @@ public void setImageUri(@NonNull GlideRequests glideRequests, @NonNull Uri uri,
if (dimensions == null || (dimensions.first <= maxTextureSize && dimensions.second <= maxTextureSize)) {
Log.i(TAG, "Loading in standard image view...");
- setImageViewUri(glideRequests, uri);
+ setImageViewUri(glideRequests, uri, onMediaReady);
} else {
Log.i(TAG, "Loading in subsampling image view...");
setSubsamplingImageViewUri(uri);
+ onMediaReady.run();
}
});
}
- private void setImageViewUri(@NonNull GlideRequests glideRequests, @NonNull Uri uri) {
+ private void setImageViewUri(@NonNull GlideRequests glideRequests, @NonNull Uri uri, @NonNull Runnable onMediaReady) {
photoView.setVisibility(View.VISIBLE);
subsamplingImageView.setVisibility(View.GONE);
@@ -118,6 +124,7 @@ private void setImageViewUri(@NonNull GlideRequests glideRequests, @NonNull Uri
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontTransform()
.override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
+ .addListener(ActionRequestListener.onEither(onMediaReady))
.into(photoView);
}
@@ -128,6 +135,26 @@ private void setSubsamplingImageViewUri(@NonNull Uri uri) {
subsamplingImageView.setVisibility(View.VISIBLE);
photoView.setVisibility(View.GONE);
+ // We manually set the orientation ourselves because using
+ // SubsamplingScaleImageView.ORIENTATION_USE_EXIF is unreliable:
+ // https://github.com/signalapp/Signal-Android/issues/11732#issuecomment-963203545
+ try {
+ final InputStream inputStream = PartAuthority.getAttachmentStream(getContext(), uri);
+ final int orientation = BitmapUtil.getExifOrientation(new ExifInterface(inputStream));
+ inputStream.close();
+ if (orientation == ExifInterface.ORIENTATION_ROTATE_90) {
+ subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_90);
+ } else if (orientation == ExifInterface.ORIENTATION_ROTATE_180) {
+ subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_180);
+ } else if (orientation == ExifInterface.ORIENTATION_ROTATE_270) {
+ subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_270);
+ } else {
+ subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_0);
+ }
+ } catch (IOException e) {
+ Log.w(TAG, e);
+ }
+
subsamplingImageView.setImage(ImageSource.uri(uri));
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java
index fda03605a5b..a7d84a2e690 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java
@@ -42,12 +42,12 @@ Portions Copyright (C) 2007 The Android Open Source Project
import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
-import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
+import java.util.Optional;
@SuppressWarnings("deprecation")
public class CameraView extends ViewGroup {
@@ -56,8 +56,8 @@ public class CameraView extends ViewGroup {
private final CameraSurfaceView surface;
private final OnOrientationChange onOrientationChange;
- private volatile Optional camera = Optional.absent();
- private volatile int cameraId = CameraInfo.CAMERA_FACING_BACK;
+ private volatile Optional camera = Optional.empty();
+ private volatile int cameraId = CameraInfo.CAMERA_FACING_BACK;
private volatile int displayOrientation = -1;
private @NonNull State state = State.PAUSED;
@@ -104,7 +104,7 @@ public void onResume() {
Void onRunBackground() {
try {
long openStartMillis = System.currentTimeMillis();
- camera = Optional.fromNullable(Camera.open(cameraId));
+ camera = Optional.ofNullable(Camera.open(cameraId));
Log.i(TAG, "camera.open() -> " + (System.currentTimeMillis() - openStartMillis) + "ms");
synchronized (CameraView.this) {
CameraView.this.notifyAll();
@@ -145,7 +145,7 @@ public void onPause() {
@Override
protected void onPreMain() {
cameraToDestroy = camera;
- camera = Optional.absent();
+ camera = Optional.empty();
}
@Override
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/Emoji.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/Emoji.java
index 155d5415944..6935bf17abf 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/Emoji.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/Emoji.java
@@ -1,18 +1,27 @@
package org.thoughtcrime.securesms.components.emoji;
+import androidx.annotation.Nullable;
+
import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
public class Emoji {
private final List variations;
+ private final List rawVariations;
public Emoji(String... variations) {
- this.variations = Arrays.asList(variations);
+ this(Arrays.asList(variations), Collections.emptyList());
}
public Emoji(List variations) {
+ this(variations, Collections.emptyList());
+ }
+
+ public Emoji(List variations, List rawVariations) {
this.variations = variations;
+ this.rawVariations = rawVariations;
}
public String getValue() {
@@ -26,4 +35,11 @@ public List getVariations() {
public boolean hasMultipleVariations() {
return variations.size() > 1;
}
+
+ public @Nullable String getRawVariation(int variationIndex) {
+ if (rawVariations != null && variationIndex >= 0 && variationIndex < rawVariations.size()) {
+ return rawVariations.get(variationIndex);
+ }
+ return null;
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiEditText.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiEditText.java
index cefdb115b3d..3ab7894e7e2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiEditText.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiEditText.java
@@ -14,7 +14,6 @@
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
-import org.thoughtcrime.securesms.util.TextSecurePreferences;
public class EmojiEditText extends AppCompatEditText {
@@ -33,10 +32,11 @@ public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr) {
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
boolean forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
+ boolean jumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
a.recycle();
if (!isInEditMode() && (forceCustom || !SignalStore.settings().isPreferSystemEmoji())) {
- setFilters(appendEmojiFilter(this.getFilters()));
+ setFilters(appendEmojiFilter(this.getFilters(), jumboEmoji));
}
}
@@ -54,7 +54,7 @@ public void invalidateDrawable(@NonNull Drawable drawable) {
else super.invalidateDrawable(drawable);
}
- private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters) {
+ private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters, boolean jumboEmoji) {
InputFilter[] result;
if (originalFilters != null) {
@@ -64,7 +64,7 @@ private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters)
result = new InputFilter[1];
}
- result[0] = new EmojiFilter(this);
+ result[0] = new EmojiFilter(this, jumboEmoji);
return result;
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiFilter.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiFilter.java
index 32333b484fd..b60650dbee5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiFilter.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiFilter.java
@@ -8,9 +8,11 @@
public class EmojiFilter implements InputFilter {
private TextView view;
+ private boolean jumboEmoji;
- public EmojiFilter(TextView view) {
- this.view = view;
+ public EmojiFilter(TextView view, boolean jumboEmoji) {
+ this.view = view;
+ this.jumboEmoji = jumboEmoji;
}
@Override
@@ -19,7 +21,7 @@ public CharSequence filter(CharSequence source, int start, int end, Spanned dest
char[] v = new char[end - start];
TextUtils.getChars(source, start, end, v, 0);
- Spannable emojified = EmojiProvider.emojify(new String(v), view);
+ Spannable emojified = EmojiProvider.emojify(new String(v), view, jumboEmoji);
if (source instanceof Spanned && emojified != null) {
TextUtils.copySpansFrom((Spanned) source, start, end, null, emojified, 0);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiImageView.java
index a96ad3556c0..827413e6af0 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiImageView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiImageView.java
@@ -1,26 +1,39 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
+import android.content.res.TypedArray;
import android.util.AttributeSet;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatImageView;
import org.thoughtcrime.securesms.R;
public class EmojiImageView extends AppCompatImageView {
+
+ private final boolean forceJumboEmoji;
+
public EmojiImageView(Context context) {
- super(context);
+ this(context, null);
}
public EmojiImageView(Context context, AttributeSet attrs) {
- super(context, attrs);
+ this(context, attrs, 0);
+ }
+
+ public EmojiImageView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiImageView, 0, 0);
+ forceJumboEmoji = a.getBoolean(R.styleable.EmojiImageView_forceJumbo, false);
+ a.recycle();
}
public void setImageEmoji(CharSequence emoji) {
if (isInEditMode()) {
setImageResource(R.drawable.ic_emoji);
} else {
- setImageDrawable(EmojiProvider.getEmojiDrawable(getContext(), emoji));
+ setImageDrawable(EmojiProvider.getEmojiDrawable(getContext(), emoji, forceJumboEmoji));
}
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageView.java
index cd8fdab3e7d..edeb1f83299 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageView.java
@@ -21,8 +21,8 @@
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DrawableUtil;
-import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.ViewUtil;
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
import java.util.List;
import java.util.Optional;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageViewGridAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageViewGridAdapter.java
index 7ad18c760bd..551355fe582 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageViewGridAdapter.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPageViewGridAdapter.java
@@ -10,9 +10,10 @@
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
-import org.thoughtcrime.securesms.util.MappingAdapter;
-import org.thoughtcrime.securesms.util.MappingModel;
-import org.thoughtcrime.securesms.util.MappingViewHolder;
+import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory;
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter;
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder;
public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWindow.OnDismissListener {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiProvider.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiProvider.java
index be44e9dd127..b617522d407 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiProvider.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiProvider.java
@@ -10,6 +10,7 @@
import android.graphics.drawable.Drawable;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
+import android.text.TextUtils;
import android.widget.TextView;
import androidx.annotation.NonNull;
@@ -21,39 +22,43 @@
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.emoji.EmojiPageCache;
import org.thoughtcrime.securesms.emoji.EmojiSource;
+import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.FutureTaskListener;
-import org.thoughtcrime.securesms.util.ListenableFutureTask;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
-class EmojiProvider {
+public class EmojiProvider {
private static final String TAG = Log.tag(EmojiProvider.class);
private static final Paint PAINT = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG);
- static @Nullable EmojiParser.CandidateList getCandidates(@Nullable CharSequence text) {
+ public static @Nullable EmojiParser.CandidateList getCandidates(@Nullable CharSequence text) {
if (text == null) return null;
return new EmojiParser(EmojiSource.getLatest().getEmojiTree()).findCandidates(text);
}
- static @Nullable Spannable emojify(@Nullable CharSequence text, @NonNull TextView tv) {
+ static @Nullable Spannable emojify(@Nullable CharSequence text, @NonNull TextView tv, boolean jumboEmoji) {
if (tv.isInEditMode()) {
return null;
} else {
- return emojify(getCandidates(text), text, tv);
+ return emojify(getCandidates(text), text, tv, jumboEmoji);
}
}
static @Nullable Spannable emojify(@Nullable EmojiParser.CandidateList matches,
@Nullable CharSequence text,
- @NonNull TextView tv)
+ @NonNull TextView tv,
+ boolean jumboEmoji)
{
if (matches == null || text == null || tv.isInEditMode()) return null;
SpannableStringBuilder builder = new SpannableStringBuilder(text);
for (EmojiParser.Candidate candidate : matches) {
- Drawable drawable = getEmojiDrawable(tv.getContext(), candidate.getDrawInfo(), tv::requestLayout);
+ Drawable drawable = getEmojiDrawable(tv.getContext(), candidate.getDrawInfo(), tv::requestLayout, jumboEmoji);
if (drawable != null) {
builder.setSpan(new EmojiSpan(drawable, tv), candidate.getStartIndex(), candidate.getEndIndex(),
@@ -64,9 +69,44 @@ class EmojiProvider {
return builder;
}
+ public static @Nullable Spannable emojify(@NonNull Context context,
+ @Nullable EmojiParser.CandidateList matches,
+ @Nullable CharSequence text,
+ @NonNull Paint paint,
+ boolean synchronous,
+ boolean jumboEmoji)
+ {
+ if (matches == null || text == null) return null;
+ SpannableStringBuilder builder = new SpannableStringBuilder(text);
+
+ for (EmojiParser.Candidate candidate : matches) {
+ Drawable drawable;
+ if (synchronous) {
+ drawable = getEmojiDrawableSync(context, candidate.getDrawInfo(), jumboEmoji);
+ } else {
+ drawable = getEmojiDrawable(context, candidate.getDrawInfo(), null, jumboEmoji);
+ }
+
+ if (drawable != null) {
+ builder.setSpan(new EmojiSpan(context, drawable, paint), candidate.getStartIndex(), candidate.getEndIndex(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+
+ return builder;
+ }
+
static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable CharSequence emoji) {
+ return getEmojiDrawable(context, emoji, false);
+ }
+
+ static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable CharSequence emoji, boolean jumboEmoji) {
+ if (TextUtils.isEmpty(emoji)) {
+ return null;
+ }
+
EmojiDrawInfo drawInfo = EmojiSource.getLatest().getEmojiTree().getEmoji(emoji, 0, emoji.length());
- return getEmojiDrawable(context, drawInfo, null);
+ return getEmojiDrawable(context, drawInfo, null, jumboEmoji);
}
/**
@@ -76,7 +116,7 @@ class EmojiProvider {
* @param drawInfo Information about the emoji being displayed
* @param onEmojiLoaded Runnable which will trigger when an emoji is loaded from disk
*/
- private static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo, @Nullable Runnable onEmojiLoaded) {
+ private static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo, @Nullable Runnable onEmojiLoaded, boolean jumboEmoji) {
if (drawInfo == null) {
return null;
}
@@ -84,6 +124,7 @@ class EmojiProvider {
final int lowMemoryDecodeScale = DeviceProperties.isLowMemoryDevice(context) ? 2 : 1;
final EmojiSource source = EmojiSource.getLatest();
final EmojiDrawable drawable = new EmojiDrawable(source, drawInfo, lowMemoryDecodeScale);
+ final AtomicBoolean jumboLoaded = new AtomicBoolean(false);
EmojiPageCache.LoadResult loadResult = EmojiPageCache.INSTANCE.load(context, drawInfo.getPage(), lowMemoryDecodeScale);
@@ -94,9 +135,11 @@ class EmojiProvider {
@Override
public void onSuccess(Bitmap result) {
ThreadUtil.runOnMain(() -> {
- drawable.setBitmap(result);
- if (onEmojiLoaded != null) {
- onEmojiLoaded.run();
+ if (!jumboLoaded.get()) {
+ drawable.setBitmap(result);
+ if (onEmojiLoaded != null) {
+ onEmojiLoaded.run();
+ }
}
});
}
@@ -110,6 +153,102 @@ public void onFailure(ExecutionException exception) {
throw new IllegalStateException("Unexpected subclass " + loadResult.getClass());
}
+ if (jumboEmoji && drawInfo.getJumboSheet() != null) {
+ JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo);
+ if (result instanceof JumboEmoji.LoadResult.Immediate) {
+ ThreadUtil.runOnMain(() -> {
+ jumboLoaded.set(true);
+ drawable.setSingleBitmap(((JumboEmoji.LoadResult.Immediate) result).getBitmap());
+ });
+ } else if (result instanceof JumboEmoji.LoadResult.Async) {
+ ((JumboEmoji.LoadResult.Async) result).getTask().addListener(new FutureTaskListener() {
+ @Override
+ public void onSuccess(Bitmap result) {
+ ThreadUtil.runOnMain(() -> {
+ jumboLoaded.set(true);
+ drawable.setSingleBitmap(result);
+ if (onEmojiLoaded != null) {
+ onEmojiLoaded.run();
+ }
+ });
+ }
+
+ @Override
+ public void onFailure(ExecutionException exception) {
+ if (exception.getCause() instanceof JumboEmoji.CannotAutoDownload) {
+ Log.i(TAG, "Download restrictions are preventing jumbomoji use");
+ } else {
+ Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception);
+ }
+ }
+ });
+ }
+
+ return drawable;
+ }
+
+ return drawable;
+ }
+
+ /**
+ * Gets an EmojiDrawable from the Page Cache synchronously
+ *
+ * @param context Context object used in reading and writing from disk
+ * @param drawInfo Information about the emoji being displayed
+ */
+ private static @Nullable Drawable getEmojiDrawableSync(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo, boolean jumboEmoji) {
+ ThreadUtil.assertNotMainThread();
+ if (drawInfo == null) {
+ return null;
+ }
+
+ final int lowMemoryDecodeScale = DeviceProperties.isLowMemoryDevice(context) ? 2 : 1;
+ final EmojiSource source = EmojiSource.getLatest();
+ final EmojiDrawable drawable = new EmojiDrawable(source, drawInfo, lowMemoryDecodeScale);
+
+ Bitmap bitmap = null;
+
+ if (jumboEmoji && drawInfo.getJumboSheet() != null) {
+ JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo);
+ if (result instanceof JumboEmoji.LoadResult.Immediate) {
+ bitmap = ((JumboEmoji.LoadResult.Immediate) result).getBitmap();
+ } else if (result instanceof JumboEmoji.LoadResult.Async) {
+ try {
+ bitmap = ((JumboEmoji.LoadResult.Async) result).getTask().get(10, TimeUnit.SECONDS);
+ } catch (InterruptedException | ExecutionException | TimeoutException exception) {
+ if (exception.getCause() instanceof JumboEmoji.CannotAutoDownload) {
+ Log.i(TAG, "Download restrictions are preventing jumbomoji use");
+ } else {
+ Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception);
+ }
+ }
+ }
+
+ if (bitmap != null) {
+ drawable.setSingleBitmap(bitmap);
+ }
+ }
+
+ if (!jumboEmoji || bitmap == null) {
+ EmojiPageCache.LoadResult loadResult = EmojiPageCache.INSTANCE.load(context, drawInfo.getPage(), lowMemoryDecodeScale);
+
+ if (loadResult instanceof EmojiPageCache.LoadResult.Immediate) {
+ Log.d(TAG, "Cached emoji page: " + drawInfo.getPage().getUri().toString());
+ bitmap = ((EmojiPageCache.LoadResult.Immediate) loadResult).getBitmap();
+ } else if (loadResult instanceof EmojiPageCache.LoadResult.Async) {
+ Log.d(TAG, "Loading emoji page: " + drawInfo.getPage().getUri().toString());
+ try {
+ bitmap = ((EmojiPageCache.LoadResult.Async) loadResult).getTask().get(2, TimeUnit.SECONDS);
+ } catch (InterruptedException | ExecutionException | TimeoutException exception) {
+ Log.d(TAG, "Failed to load emoji bitmap resource", exception);
+ }
+ } else {
+ throw new IllegalStateException("Unexpected subclass " + loadResult.getClass());
+ }
+
+ drawable.setBitmap(bitmap);
+ }
+
return drawable;
}
@@ -118,7 +257,8 @@ static final class EmojiDrawable extends Drawable {
private final float intrinsicHeight;
private final Rect emojiBounds;
- private Bitmap bmp;
+ private Bitmap bmp;
+ private boolean isSingleBitmap;
@Override
public int getIntrinsicWidth() {
@@ -154,13 +294,21 @@ public void draw(@NonNull Canvas canvas) {
}
canvas.drawBitmap(bmp,
- emojiBounds,
+ isSingleBitmap ? null : emojiBounds,
getBounds(),
PAINT);
}
public void setBitmap(Bitmap bitmap) {
- ThreadUtil.assertMainThread();
+ setBitmap(bitmap, false);
+ }
+
+ public void setSingleBitmap(Bitmap bitmap) {
+ setBitmap(bitmap, true);
+ }
+
+ private void setBitmap(Bitmap bitmap, boolean isSingleBitmap) {
+ this.isSingleBitmap = isSingleBitmap;
if (bmp == null || !bmp.sameAs(bitmap)) {
bmp = bitmap;
invalidateSelf();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiSpan.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiSpan.java
index a0e34e476bc..1be73d30d2a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiSpan.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiSpan.java
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.emoji;
+import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Paint.FontMetricsInt;
@@ -25,6 +26,15 @@ public EmojiSpan(@NonNull Drawable drawable, @NonNull TextView tv) {
getDrawable().setBounds(0, 0, size, size);
}
+ public EmojiSpan(@NonNull Context context, @NonNull Drawable drawable, @NonNull Paint paint) {
+ super(drawable, null);
+ fontMetrics = paint.getFontMetricsInt();
+ size = fontMetrics != null ? Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent)
+ : context.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.fontMetrics != null) {
@@ -48,6 +58,7 @@ public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end,
int height = bottom - top;
int centeringMargin = (height - size) / 2;
int adjustedMargin = (int) (centeringMargin * SHIFT_FACTOR);
+ int adjustedBottom = bottom - adjustedMargin;
super.draw(canvas, text, start, end, x, top, y, bottom - adjustedMargin, paint);
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java
index 568ac8961c5..a4363cba571 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java
@@ -4,10 +4,16 @@
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
+import android.os.Build;
import android.text.Annotation;
+import android.text.Layout;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
+import android.text.TextDirectionHeuristic;
+import android.text.TextDirectionHeuristics;
import android.text.TextUtils;
+import android.text.method.TransformationMethod;
+import android.text.style.CharacterStyle;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.ViewGroup;
@@ -17,35 +23,47 @@
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatTextView;
import androidx.core.content.ContextCompat;
+import androidx.core.view.ViewKt;
import androidx.core.widget.TextViewCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
+import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.Util;
-import org.whispersystems.libsignal.util.guava.Optional;
+import java.util.Arrays;
import java.util.List;
+import java.util.Optional;
+
+import kotlin.Unit;
public class EmojiTextView extends AppCompatTextView {
private final boolean scaleEmojis;
- private static final char ELLIPSIS = '…';
-
- private boolean forceCustom;
- private CharSequence previousText;
- private BufferType previousBufferType;
- private float originalFontSize;
- private boolean useSystemEmoji;
- private boolean sizeChangeInProgress;
- private int maxLength;
- private CharSequence overflowText;
- private CharSequence previousOverflowText;
- private boolean renderMentions;
+ private static final char ELLIPSIS = '…';
+ private static final float JUMBOMOJI_SCALE = 0.8f;
+
+ private boolean forceCustom;
+ private CharSequence previousText;
+ private BufferType previousBufferType;
+ private TransformationMethod previousTransformationMethod;
+ private float originalFontSize;
+ private boolean useSystemEmoji;
+ private boolean sizeChangeInProgress;
+ private int maxLength;
+ private CharSequence overflowText;
+ private CharSequence previousOverflowText;
+ private boolean renderMentions;
+ private boolean measureLastLine;
+ private int lastLineWidth = -1;
+ private TextDirectionHeuristic textDirection;
+ private boolean isJumbomoji;
+ private boolean forceJumboEmoji;
private MentionRendererDelegate mentionRendererDelegate;
@@ -61,10 +79,12 @@ public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
- scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
- maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
- forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
- renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true);
+ scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
+ maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
+ forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
+ renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true);
+ measureLastLine = a.getBoolean(R.styleable.EmojiTextView_measureLastLine, false);
+ forceJumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
a.recycle();
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize});
@@ -74,6 +94,8 @@ public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) {
if (renderMentions) {
mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.transparent_black_20));
}
+
+ textDirection = getLayoutDirection() == LAYOUT_DIRECTION_LTR ? TextDirectionHeuristics.FIRSTSTRONG_RTL : TextDirectionHeuristics.ANYRTL_LTR;
}
@Override
@@ -94,17 +116,18 @@ protected void onDraw(Canvas canvas) {
public void setText(@Nullable CharSequence text, BufferType type) {
EmojiParser.CandidateList candidates = isInEditMode() ? null : EmojiProvider.getCandidates(text);
- if (scaleEmojis && candidates != null && candidates.allEmojis) {
+ if (scaleEmojis && candidates != null && candidates.allEmojis && (candidates.hasJumboForAll() || JumboEmoji.canDownloadJumbo(getContext()))) {
int emojis = candidates.size();
float scale = 1.0f;
- if (emojis <= 8) scale += 0.25f;
- if (emojis <= 6) scale += 0.25f;
- if (emojis <= 4) scale += 0.25f;
- if (emojis <= 2) scale += 0.25f;
+ if (emojis <= 5) scale += JUMBOMOJI_SCALE;
+ if (emojis <= 4) scale += JUMBOMOJI_SCALE;
+ if (emojis <= 2) scale += JUMBOMOJI_SCALE;
+ isJumbomoji = scale > 1.0f;
super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize * scale);
} else if (scaleEmojis) {
+ isJumbomoji = false;
super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize);
}
@@ -112,21 +135,22 @@ public void setText(@Nullable CharSequence text, BufferType type) {
return;
}
- previousText = text;
- previousOverflowText = overflowText;
- previousBufferType = type;
- useSystemEmoji = useSystemEmoji();
+ previousText = text;
+ previousOverflowText = overflowText;
+ previousBufferType = type;
+ useSystemEmoji = useSystemEmoji();
+ previousTransformationMethod = getTransformationMethod();
if (useSystemEmoji || candidates == null || candidates.size() == 0) {
- super.setText(new SpannableStringBuilder(Optional.fromNullable(text).or("")), BufferType.NORMAL);
+ super.setText(new SpannableStringBuilder(Optional.ofNullable(text).orElse("")), BufferType.SPANNABLE);
} else {
- CharSequence emojified = EmojiProvider.emojify(candidates, text, this);
+ CharSequence emojified = EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji);
super.setText(new SpannableStringBuilder(emojified), BufferType.SPANNABLE);
}
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
// We ellipsize them ourselves by manually truncating the appropriate section.
- if (getText() != null && getText().length() > 0 && getEllipsize() == TextUtils.TruncateAt.END) {
+ if (getText() != null && getText().length() > 0 && isEllipsizedAtEnd()) {
if (maxLength > 0) {
ellipsizeAnyTextForMaxLength();
} else if (getMaxLines() > 0) {
@@ -139,6 +163,102 @@ public void setText(@Nullable CharSequence text, BufferType type) {
}
}
+ /**
+ * Used to determine whether to apply custom ellipsizing logic without necessarily having the
+ * ellipsize property set. This allows us to work around implementations of Layout which apply an
+ * ellipsis even when maxLines is not set.
+ */
+ private boolean isEllipsizedAtEnd() {
+ return getEllipsize() == TextUtils.TruncateAt.END ||
+ (getMaxLines() > 0 && getMaxLines() < Integer.MAX_VALUE) ||
+ maxLength > 0;
+ }
+
+ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ widthMeasureSpec = applyWidthMeasureRoundingFix(widthMeasureSpec);
+
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ CharSequence text = getText();
+ if (getLayout() == null || !measureLastLine || text == null || text.length() == 0) {
+ lastLineWidth = -1;
+ } else {
+ Layout layout = getLayout();
+ text = layout.getText();
+
+ int lines = layout.getLineCount();
+ int start = layout.getLineStart(lines - 1);
+
+ if ((getLayoutDirection() == LAYOUT_DIRECTION_LTR && textDirection.isRtl(text, 0, text.length())) ||
+ (getLayoutDirection() == LAYOUT_DIRECTION_RTL && !textDirection.isRtl(text, 0, text.length()))) {
+ lastLineWidth = getMeasuredWidth();
+ } else {
+ lastLineWidth = (int) getPaint().measureText(text, start, text.length());
+ }
+ }
+ }
+
+ /**
+ * Starting from API 30, there can be a rounding error in text layout when a non-zero letter
+ * spacing is used. This causes a line break to be inserted where there shouldn't be one. Force
+ * the width to be larger to work around this problem.
+ * https://issuetracker.google.com/issues/173574230
+ *
+ * @param widthMeasureSpec the original measure spec passed to {@link #onMeasure(int, int)}
+ * @return the measure spec with the workaround, or the original one.
+ */
+ private int applyWidthMeasureRoundingFix(int widthMeasureSpec) {
+ if (Build.VERSION.SDK_INT >= 30 && getLetterSpacing() > 0) {
+ CharSequence text = getText();
+ if (text != null) {
+ int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
+ int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
+
+ float measuredTextWidth = hasMetricAffectingSpan(text) ? Layout.getDesiredWidth(text, getPaint()) : getLongestLineWidth(text);
+ int desiredWidth = (int) measuredTextWidth + getPaddingLeft() + getPaddingRight();
+
+ if (widthSpecMode == MeasureSpec.AT_MOST && desiredWidth < widthSpecSize) {
+ return MeasureSpec.makeMeasureSpec(desiredWidth + 1, MeasureSpec.EXACTLY);
+ }
+ }
+ }
+
+ return widthMeasureSpec;
+ }
+
+ private boolean hasMetricAffectingSpan(@NonNull CharSequence text) {
+ if (!(text instanceof Spanned)) {
+ return false;
+ }
+
+ return ((Spanned) text).nextSpanTransition(-1, text.length(), CharacterStyle.class) != text.length();
+ }
+
+ private float getLongestLineWidth(@NonNull CharSequence text) {
+ if (TextUtils.isEmpty(text)) {
+ return 0f;
+ }
+
+ long maxLines = getMaxLines() > 0 ? getMaxLines() : Long.MAX_VALUE;
+
+ return Arrays.stream(text.toString().split("\n"))
+ .limit(maxLines)
+ .map(s -> getPaint().measureText(s, 0, s.length()))
+ .max(Float::compare)
+ .orElse(0f);
+ }
+
+ public int getLastLineWidth() {
+ return lastLineWidth;
+ }
+
+ public boolean isSingleLine() {
+ return getLayout() != null && getLayout().getLineCount() == 1;
+ }
+
+ public boolean isJumbomoji() {
+ return isJumbomoji;
+ }
+
public void setOverflowText(@Nullable CharSequence overflowText) {
this.overflowText = overflowText;
setText(previousText, BufferType.SPANNABLE);
@@ -171,21 +291,16 @@ private void ellipsizeAnyTextForMaxLength() {
EmojiParser.CandidateList newCandidates = isInEditMode() ? null : EmojiProvider.getCandidates(newContent);
if (useSystemEmoji || newCandidates == null || newCandidates.size() == 0) {
- super.setText(newContent, BufferType.NORMAL);
+ super.setText(newContent, BufferType.SPANNABLE);
} else {
- CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this);
+ CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
super.setText(emojified, BufferType.SPANNABLE);
}
}
}
private void ellipsizeEmojiTextForMaxLines() {
- post(() -> {
- if (getLayout() == null) {
- ellipsizeEmojiTextForMaxLines();
- return;
- }
-
+ Runnable ellipsize = () -> {
int maxLines = TextViewCompat.getMaxLines(EmojiTextView.this);
if (maxLines <= 0 && maxLength < 0) {
return;
@@ -193,22 +308,32 @@ private void ellipsizeEmojiTextForMaxLines() {
int lineCount = getLineCount();
if (lineCount > maxLines) {
- int overflowStart = getLayout().getLineStart(maxLines - 1);
- CharSequence overflow = getText().subSequence(overflowStart, getText().length());
- float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f;
- CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END);
+ int overflowStart = getLayout().getLineStart(maxLines - 1);
+ int overflowEnd = getLayout().getLineEnd(maxLines - 1);
+ CharSequence overflow = getText().subSequence(overflowStart, overflowEnd);
+ float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f;
+ CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END);
SpannableStringBuilder newContent = new SpannableStringBuilder();
newContent.append(getText().subSequence(0, overflowStart))
.append(ellipsized.subSequence(0, ellipsized.length()))
- .append(Optional.fromNullable(overflowText).or(""));
+ .append(Optional.ofNullable(overflowText).orElse(""));
EmojiParser.CandidateList newCandidates = isInEditMode() ? null : EmojiProvider.getCandidates(newContent);
- CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this);
+ CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
super.setText(emojified, BufferType.SPANNABLE);
}
- });
+ };
+
+ if (getLayout() != null) {
+ ellipsize.run();
+ } else {
+ ViewKt.doOnPreDraw(this, view -> {
+ ellipsize.run();
+ return Unit.INSTANCE;
+ });
+ }
}
private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) {
@@ -216,7 +341,8 @@ private boolean unchanged(CharSequence text, CharSequence overflowText, BufferTy
Util.equals(previousOverflowText, overflowText) &&
Util.equals(previousBufferType, bufferType) &&
useSystemEmoji == useSystemEmoji() &&
- !sizeChangeInProgress;
+ !sizeChangeInProgress &&
+ previousTransformationMethod == getTransformationMethod();
}
private boolean useSystemEmoji() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java
index 77bccd899b6..acb236e16dd 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java
@@ -1,10 +1,12 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
+import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatImageButton;
import org.thoughtcrime.securesms.R;
@@ -24,17 +26,17 @@ public class EmojiToggle extends AppCompatImageButton implements MediaKeyboard.M
public EmojiToggle(Context context) {
super(context);
- initialize();
+ initialize(null);
}
public EmojiToggle(Context context, AttributeSet attrs) {
super(context, attrs);
- initialize();
+ initialize(attrs);
}
public EmojiToggle(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
- initialize();
+ initialize(attrs);
}
public void setToMedia() {
@@ -45,11 +47,18 @@ public void setToIme() {
setImageDrawable(imeToggle);
}
- private void initialize() {
- this.emojiToggle = ContextUtil.requireDrawable(getContext(), R.drawable.ic_emoji);
+ private void initialize(@Nullable AttributeSet attrs) {
+ boolean forceOutline = false;
+ if (attrs != null) {
+ TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiToggle, 0, 0);
+ forceOutline = typedArray.getBoolean(R.styleable.EmojiToggle_force_outline, false);
+ typedArray.recycle();
+ }
+
+ this.emojiToggle = ContextUtil.requireDrawable(getContext(), forceOutline ? R.drawable.ic_emoji_outline : R.drawable.ic_emoji);
this.stickerToggle = ContextUtil.requireDrawable(getContext(), R.drawable.ic_sticker_24);
this.gifToggle = ContextUtil.requireDrawable(getContext(), R.drawable.ic_gif_24);
- this.imeToggle = ContextUtil.requireDrawable(getContext(), R.drawable.ic_keyboard_24);
+ this.imeToggle = ContextUtil.requireDrawable(getContext(), forceOutline ? R.drawable.ic_keyboard_outline_24 : R.drawable.ic_keyboard_24);
this.mediaToggle = emojiToggle;
setToMedia();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java
index 067f2392aa9..6eccac37bf1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java
@@ -9,7 +9,7 @@
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.emoji.ObsoleteEmoji;
-import org.thoughtcrime.securesms.util.StringUtil;
+import org.signal.core.util.StringUtil;
import org.thoughtcrime.securesms.util.Util;
import java.util.HashSet;
@@ -18,6 +18,7 @@
public final class EmojiUtil {
private static final Pattern EMOJI_PATTERN = Pattern.compile("^(?:(?:[\u00a9\u00ae\u203c\u2049\u2122\u2139\u2194-\u2199\u21a9-\u21aa\u231a-\u231b\u2328\u23cf\u23e9-\u23f3\u23f8-\u23fa\u24c2\u25aa-\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614-\u2615\u2618\u261d\u2620\u2622-\u2623\u2626\u262a\u262e-\u262f\u2638-\u263a\u2648-\u2653\u2660\u2663\u2665-\u2666\u2668\u267b\u267f\u2692-\u2694\u2696-\u2697\u2699\u269b-\u269c\u26a0-\u26a1\u26aa-\u26ab\u26b0-\u26b1\u26bd-\u26be\u26c4-\u26c5\u26c8\u26ce-\u26cf\u26d1\u26d3-\u26d4\u26e9-\u26ea\u26f0-\u26f5\u26f7-\u26fa\u26fd\u2702\u2705\u2708-\u270d\u270f\u2712\u2714\u2716\u271d\u2721\u2728\u2733-\u2734\u2744\u2747\u274c\u274e\u2753-\u2755\u2757\u2763-\u2764\u2795-\u2797\u27a1\u27b0\u27bf\u2934-\u2935\u2b05-\u2b07\u2b1b-\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299\ud83c\udc04\ud83c\udccf\ud83c\udd70-\ud83c\udd71\ud83c\udd7e-\ud83c\udd7f\ud83c\udd8e\ud83c\udd91-\ud83c\udd9a\ud83c\ude01-\ud83c\ude02\ud83c\ude1a\ud83c\ude2f\ud83c\ude32-\ud83c\ude3a\ud83c\ude50-\ud83c\ude51\u200d\ud83c\udf00-\ud83d\uddff\ud83d\ude00-\ud83d\ude4f\ud83d\ude80-\ud83d\udeff\ud83e\udd00-\ud83e\uddff\udb40\udc20-\udb40\udc7f]|\u200d[\u2640\u2642]|[\ud83c\udde6-\ud83c\uddff]{2}|.[\u20e0\u20e3\ufe0f]+)+)+$");
+ private static final String EMOJI_REGEX = "[^\\p{L}\\p{M}\\p{N}\\p{P}\\p{Z}\\p{Cf}\\p{Cs}\\s]";
private EmojiUtil() {}
@@ -84,4 +85,12 @@ public static boolean isEmoji(@Nullable String text) {
return (candidates != null && candidates.size() > 0) || EMOJI_PATTERN.matcher(text).matches();
}
+
+ public static String stripEmoji(@Nullable String text) {
+ if (text == null) {
+ return text;
+ }
+
+ return text.replaceAll(EMOJI_REGEX, "");
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java
index fab9bdbd6ed..3babe8658b2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java
@@ -1,7 +1,9 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
+import android.content.res.TypedArray;
import android.util.AttributeSet;
+import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import android.widget.FrameLayout;
@@ -19,6 +21,7 @@
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment;
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment;
+import org.thoughtcrime.securesms.util.ThemedFragment;
public class MediaKeyboard extends FrameLayout implements InputView {
@@ -31,6 +34,7 @@ public class MediaKeyboard extends FrameLayout implements InputView {
private State keyboardState;
private KeyboardPagerFragment keyboardPagerFragment;
private FragmentManager fragmentManager;
+ private int mediaKeyboardTheme;
public MediaKeyboard(Context context) {
this(context, null);
@@ -38,6 +42,16 @@ public MediaKeyboard(Context context) {
public MediaKeyboard(Context context, AttributeSet attrs) {
super(context, attrs);
+
+ if (attrs != null) {
+ TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.MediaKeyboard);
+ mediaKeyboardTheme = array.getResourceId(R.styleable.MediaKeyboard_media_keyboard_theme, -1);
+ array.recycle();
+ }
+ }
+
+ public void setFragmentManager(@NonNull FragmentManager fragmentManager) {
+ this.fragmentManager = fragmentManager;
}
public void setKeyboardListener(@Nullable MediaKeyboardListener listener) {
@@ -63,6 +77,10 @@ public void show(int height, boolean immediate) {
show();
}
+ public boolean isInitialised() {
+ return isInitialised;
+ }
+
public void show() {
if (!isInitialised) initView();
@@ -115,23 +133,55 @@ public void onOpenEmojiSearch() {
keyboardState = State.EMOJI_SEARCH;
+ EmojiSearchFragment emojiSearchFragment = new EmojiSearchFragment();
+ if (mediaKeyboardTheme != -1) {
+ ThemedFragment.withTheme(emojiSearchFragment, mediaKeyboardTheme);
+ }
+
fragmentManager.beginTransaction()
.hide(keyboardPagerFragment)
- .add(R.id.media_keyboard_fragment_container, new EmojiSearchFragment(), EMOJI_SEARCH)
+ .add(R.id.media_keyboard_fragment_container, emojiSearchFragment, EMOJI_SEARCH)
.runOnCommit(() -> show(latestKeyboardHeight, true))
.setCustomAnimations(R.anim.fade_in, R.anim.fade_out)
.commitAllowingStateLoss();
}
+ public boolean isEmojiSearchMode() {
+ return keyboardState == State.EMOJI_SEARCH;
+ }
+
private void initView() {
if (!isInitialised) {
+
LayoutInflater.from(getContext()).inflate(R.layout.media_keyboard, this, true);
+ if (fragmentManager == null) {
+ FragmentActivity activity = resolveActivity(getContext());
+ fragmentManager = activity.getSupportFragmentManager();
+ }
+
+ keyboardPagerFragment = new KeyboardPagerFragment();
+ if (mediaKeyboardTheme != -1) {
+ ThemedFragment.withTheme(keyboardPagerFragment, mediaKeyboardTheme);
+ }
+
+ fragmentManager.beginTransaction()
+ .replace(R.id.media_keyboard_fragment_container, keyboardPagerFragment)
+ .commitNowAllowingStateLoss();
+
keyboardState = State.NORMAL;
latestKeyboardHeight = -1;
isInitialised = true;
- fragmentManager = ((FragmentActivity) getContext()).getSupportFragmentManager();
- keyboardPagerFragment = (KeyboardPagerFragment) fragmentManager.findFragmentById(R.id.media_keyboard_fragment_container);
+ }
+ }
+
+ private static FragmentActivity resolveActivity(@Nullable Context context) {
+ if (context instanceof FragmentActivity) {
+ return (FragmentActivity) context;
+ } else if (context instanceof ContextThemeWrapper) {
+ return resolveActivity(((ContextThemeWrapper) context).getBaseContext());
+ } else {
+ throw new IllegalStateException("Could not locate FragmentActivity");
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SimpleEmojiTextView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SimpleEmojiTextView.kt
new file mode 100644
index 00000000000..3e3656d0e0f
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SimpleEmojiTextView.kt
@@ -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.thoughtcrime.securesms.util.ThrottledDebouncer
+import java.util.Optional
+
+open class SimpleEmojiTextView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : AppCompatTextView(context, attrs, defStyleAttr) {
+
+ private var bufferType: BufferType? = null
+ private val sizeChangeDebouncer: ThrottledDebouncer = ThrottledDebouncer(200)
+
+ 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.ofNullable(text).orElse(""), type)
+ } else {
+ val startDrawableSize: Int = compoundDrawables[0]?.let { it.intrinsicWidth + compoundDrawablePadding } ?: 0
+ val endDrawableSize: Int = compoundDrawables[1]?.let { it.intrinsicWidth + compoundDrawablePadding } ?: 0
+ val adjustedWidth: Int = width - startDrawableSize - endDrawableSize
+
+ val newCandidates = if (isInEditMode) null else EmojiProvider.getCandidates(text)
+ val newText = if (newCandidates == null || newCandidates.size() == 0) {
+ text
+ } else {
+ EmojiProvider.emojify(newCandidates, text, this, false)
+ }
+
+ val newContent = if (width == 0 || maxLines == -1) {
+ newText
+ } else {
+ TextUtils.ellipsize(newText, paint, (adjustedWidth * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
+ }
+ bufferType = BufferType.SPANNABLE
+ super.setText(newContent, type)
+ }
+ }
+
+ override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
+ super.onSizeChanged(width, height, oldWidth, oldHeight)
+ sizeChangeDebouncer.publish {
+ if (width > 0 && oldWidth != width) {
+ setText(text, bufferType ?: BufferType.SPANNABLE)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SingleLineEmojiTextView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SingleLineEmojiTextView.kt
deleted file mode 100644
index cf833c6ef27..00000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SingleLineEmojiTextView.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-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)
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiDrawInfo.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiDrawInfo.java
deleted file mode 100644
index c3d5114bd59..00000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiDrawInfo.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package org.thoughtcrime.securesms.components.emoji.parsing;
-
-
-import androidx.annotation.NonNull;
-
-import org.thoughtcrime.securesms.emoji.EmojiPage;
-
-public class EmojiDrawInfo {
-
- private final EmojiPage page;
- private final int index;
-
- public EmojiDrawInfo(final @NonNull EmojiPage page, final int index) {
- this.page = page;
- this.index = index;
- }
-
- public @NonNull EmojiPage getPage() {
- return page;
- }
-
- public int getIndex() {
- return index;
- }
-
- @Override
- public @NonNull String toString() {
- return "DrawInfo{" +
- "page=" + page +
- ", index=" + index +
- '}';
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiDrawInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiDrawInfo.kt
new file mode 100644
index 00000000000..28de3aca78b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiDrawInfo.kt
@@ -0,0 +1,5 @@
+package org.thoughtcrime.securesms.components.emoji.parsing
+
+import org.thoughtcrime.securesms.emoji.EmojiPage
+
+data class EmojiDrawInfo(val page: EmojiPage, val index: Int, private val emoji: String, val rawEmoji: String?, val jumboSheet: String?)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiParser.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiParser.java
index 91450e102b1..ced9178683a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiParser.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiParser.java
@@ -24,6 +24,8 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import org.thoughtcrime.securesms.emoji.JumboEmoji;
+
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
@@ -127,6 +129,15 @@ public int size() {
return list.size();
}
+ public boolean hasJumboForAll() {
+ for (Candidate candidate : list) {
+ if (!JumboEmoji.hasJumboEmoji(candidate.drawInfo)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
@Override
public @NonNull Iterator iterator() {
return list.iterator();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/identity/UntrustedSendDialog.java b/app/src/main/java/org/thoughtcrime/securesms/components/identity/UntrustedSendDialog.java
index e8019bf664e..435a1ba6f02 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/identity/UntrustedSendDialog.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/identity/UntrustedSendDialog.java
@@ -9,10 +9,10 @@
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
-import org.thoughtcrime.securesms.database.DatabaseFactory;
-import org.thoughtcrime.securesms.database.IdentityDatabase;
-import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
-import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
+import org.thoughtcrime.securesms.crypto.storage.SignalIdentityKeyStore;
+import org.thoughtcrime.securesms.database.model.IdentityRecord;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.signal.core.util.concurrent.SimpleTask;
import org.whispersystems.signalservice.api.SignalSessionLock;
import java.util.List;
@@ -40,12 +40,12 @@ public UntrustedSendDialog(@NonNull Context context,
@Override
public void onClick(DialogInterface dialog, int which) {
- final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(getContext());
+ final SignalIdentityKeyStore identityStore = ApplicationDependencies.getProtocolStore().aci().identities();
SimpleTask.run(() -> {
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
for (IdentityRecord identityRecord : untrustedRecords) {
- identityDatabase.setApproval(identityRecord.getRecipientId(), true);
+ identityStore.setApproval(identityRecord.getRecipientId(), true);
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedBannerView.java b/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedBannerView.java
index b19098d7311..5ff794fac3e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedBannerView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedBannerView.java
@@ -16,7 +16,7 @@
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
-import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
+import org.thoughtcrime.securesms.database.model.IdentityRecord;
import java.util.List;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedSendDialog.java b/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedSendDialog.java
index 2a182c2c03e..caa7cfe8ee4 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedSendDialog.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedSendDialog.java
@@ -2,16 +2,16 @@
import android.content.Context;
import android.content.DialogInterface;
-import android.os.AsyncTask;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
-import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.IdentityDatabase;
-import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
+import org.thoughtcrime.securesms.database.model.IdentityRecord;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.signal.core.util.concurrent.SimpleTask;
import org.whispersystems.signalservice.api.SignalSessionLock;
import java.util.List;
@@ -39,27 +39,16 @@ public UnverifiedSendDialog(@NonNull Context context,
@Override
public void onClick(DialogInterface dialog, int which) {
- final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(getContext());
-
- new AsyncTask() {
- @Override
- protected Void doInBackground(Void... params) {
- try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
- for (IdentityRecord identityRecord : untrustedRecords) {
- identityDatabase.setVerified(identityRecord.getRecipientId(),
- identityRecord.getIdentityKey(),
- IdentityDatabase.VerifiedStatus.DEFAULT);
- }
+ SimpleTask.run(() -> {
+ try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
+ for (IdentityRecord identityRecord : untrustedRecords) {
+ ApplicationDependencies.getProtocolStore().aci().identities().setVerified(identityRecord.getRecipientId(),
+ identityRecord.getIdentityKey(),
+ IdentityDatabase.VerifiedStatus.DEFAULT);
}
-
- return null;
- }
-
- @Override
- protected void onPostExecute(Void result) {
- resendListener.onResendMessage();
}
- }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ return null;
+ }, nothing -> resendListener.onResendMessage());
}
public interface ResendListener {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt
new file mode 100644
index 00000000000..c13d1415900
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt
@@ -0,0 +1,12 @@
+package org.thoughtcrime.securesms.components.menu
+
+import androidx.annotation.DrawableRes
+
+/**
+ * Represents an action to be rendered via [SignalContextMenu] or [SignalBottomActionBar]
+ */
+data class ActionItem(
+ @DrawableRes val iconRes: Int,
+ val title: CharSequence,
+ val action: Runnable
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt
new file mode 100644
index 00000000000..44d94571cc6
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt
@@ -0,0 +1,91 @@
+package org.thoughtcrime.securesms.components.menu
+
+import android.os.Build
+import android.view.View
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
+
+/**
+ * Handles the setup and display of actions shown in a context menu.
+ */
+class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
+
+ private val mappingAdapter = MappingAdapter().apply {
+ registerFactory(DisplayItem::class.java, LayoutFactory({ ItemViewHolder(it, onItemClick) }, R.layout.signal_context_menu_item))
+ }
+
+ init {
+ recyclerView.apply {
+ adapter = mappingAdapter
+ layoutManager = LinearLayoutManager(context)
+ itemAnimator = null
+ }
+ }
+
+ fun setItems(items: List) {
+ mappingAdapter.submitList(items.toAdapterItems())
+ }
+
+ private fun List.toAdapterItems(): List {
+ return this.mapIndexed { index, item ->
+ val displayType: DisplayType = when {
+ this.size == 1 -> DisplayType.ONLY
+ index == 0 -> DisplayType.TOP
+ index == this.size - 1 -> DisplayType.BOTTOM
+ else -> DisplayType.MIDDLE
+ }
+
+ DisplayItem(item, displayType)
+ }
+ }
+
+ private data class DisplayItem(
+ val item: ActionItem,
+ val displayType: DisplayType
+ ) : MappingModel {
+ override fun areItemsTheSame(newItem: DisplayItem): Boolean {
+ return this == newItem
+ }
+
+ override fun areContentsTheSame(newItem: DisplayItem): Boolean {
+ return this == newItem
+ }
+ }
+
+ private enum class DisplayType {
+ TOP, BOTTOM, MIDDLE, ONLY
+ }
+
+ private class ItemViewHolder(
+ itemView: View,
+ private val onItemClick: () -> Unit,
+ ) : MappingViewHolder(itemView) {
+ val icon: ImageView = itemView.findViewById(R.id.signal_context_menu_item_icon)
+ val title: TextView = itemView.findViewById(R.id.signal_context_menu_item_title)
+
+ override fun bind(model: DisplayItem) {
+ icon.setImageResource(model.item.iconRes)
+ title.text = model.item.title
+ itemView.setOnClickListener {
+ model.item.action.run()
+ onItemClick()
+ }
+
+ if (Build.VERSION.SDK_INT >= 21) {
+ when (model.displayType) {
+ DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_top)
+ DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_bottom)
+ DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_middle)
+ DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_only)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalBottomActionBar.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalBottomActionBar.kt
new file mode 100644
index 00000000000..6e588f7ada6
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalBottomActionBar.kt
@@ -0,0 +1,124 @@
+package org.thoughtcrime.securesms.components.menu
+
+import android.content.Context
+import android.os.Build
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.animation.Animation
+import android.view.animation.AnimationUtils
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.interpolator.view.animation.FastOutSlowInInterpolator
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.util.ViewUtil
+
+/**
+ * A bar that displays a set of action buttons. Intended as a replacement for ActionModes, this gives you a simple interface to add a bunch of actions, and
+ * the bar itself will handle putting things in the overflow and whatnot.
+ *
+ * Overflow items are rendered in a [SignalContextMenu].
+ */
+class SignalBottomActionBar(context: Context, attributeSet: AttributeSet) : LinearLayout(context, attributeSet) {
+
+ val items: MutableList = mutableListOf()
+
+ val enterAnimation: Animation by lazy {
+ AnimationUtils.loadAnimation(context, R.anim.slide_fade_from_bottom).apply {
+ duration = 250
+ interpolator = FastOutSlowInInterpolator()
+ }
+ }
+
+ val exitAnimation: Animation by lazy {
+ AnimationUtils.loadAnimation(context, R.anim.slide_fade_to_bottom).apply {
+ duration = 250
+ interpolator = FastOutSlowInInterpolator()
+ }
+ }
+
+ init {
+ orientation = HORIZONTAL
+ setBackgroundResource(R.drawable.signal_bottom_action_bar_background)
+
+ if (Build.VERSION.SDK_INT >= 21) {
+ elevation = 20f
+ }
+ }
+
+ fun setItems(items: List) {
+ this.items.clear()
+ this.items.addAll(items)
+ present(this.items)
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+
+ if (w != oldw) {
+ present(items)
+ }
+ }
+
+ private fun present(items: List) {
+ if (width == 0) {
+ return
+ }
+
+ val wasLayoutRequested = isLayoutRequested
+
+ val widthDp: Float = ViewUtil.pxToDp(width.toFloat())
+ val minButtonWidthDp = 80
+ val maxButtons: Int = (widthDp / minButtonWidthDp).toInt()
+ val usableButtonCount = when {
+ items.size <= maxButtons -> items.size
+ else -> maxButtons - 1
+ }
+
+ val renderableItems: List = items.subList(0, usableButtonCount)
+ val overflowItems: List = if (renderableItems.size < items.size) items.subList(usableButtonCount, items.size) else emptyList()
+
+ removeAllViews()
+
+ renderableItems.forEach { item ->
+ val view: View = LayoutInflater.from(context).inflate(R.layout.signal_bottom_action_bar_item, this, false)
+ addView(view)
+ bindItem(view, item)
+ }
+
+ if (overflowItems.isNotEmpty()) {
+ val view: View = LayoutInflater.from(context).inflate(R.layout.signal_bottom_action_bar_item, this, false)
+ addView(view)
+ bindItem(
+ view,
+ ActionItem(
+ iconRes = R.drawable.ic_more_horiz_24,
+ title = context.getString(R.string.SignalBottomActionBar_more),
+ action = {
+ SignalContextMenu.Builder(view, parent as ViewGroup)
+ .preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.END)
+ .offsetY(ViewUtil.dpToPx(8))
+ .show(overflowItems)
+ }
+ )
+ )
+ }
+
+ if (wasLayoutRequested) {
+ post {
+ requestLayout()
+ }
+ }
+ }
+
+ private fun bindItem(view: View, item: ActionItem) {
+ val icon: ImageView = view.findViewById(R.id.signal_bottom_action_bar_item_icon)
+ val title: TextView = view.findViewById(R.id.signal_bottom_action_bar_item_title)
+
+ icon.setImageResource(item.iconRes)
+ title.text = item.title
+ view.setOnClickListener { item.action.run() }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalContextMenu.kt
new file mode 100644
index 00000000000..eef27f773ea
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalContextMenu.kt
@@ -0,0 +1,164 @@
+package org.thoughtcrime.securesms.components.menu
+
+import android.content.Context
+import android.graphics.Rect
+import android.os.Build
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.PopupWindow
+import androidx.core.content.ContextCompat
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.util.ViewUtil
+
+/**
+ * A custom context menu that will show next to an anchor view and display several options. Basically a PopupMenu with custom UI and positioning rules.
+ *
+ * This will prefer showing the menu underneath the anchor, but if there's not enough space in the container, it will show it above the anchor and reverse the
+ * order of the menu items. If there's not enough room for either, it'll show it centered above the anchor. If there's not enough room then, it'll center it,
+ * chop off the part that doesn't fit, and make the menu scrollable.
+ */
+class SignalContextMenu private constructor(
+ val anchor: View,
+ val container: ViewGroup,
+ val items: List,
+ val baseOffsetX: Int = 0,
+ val baseOffsetY: Int = 0,
+ val horizontalPosition: HorizontalPosition = HorizontalPosition.START,
+ val onDismiss: Runnable? = null
+) : PopupWindow(
+ LayoutInflater.from(anchor.context).inflate(R.layout.signal_context_menu, null),
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+) {
+
+ val context: Context = anchor.context
+
+ private val contextMenuList = ContextMenuList(
+ recyclerView = contentView.findViewById(R.id.signal_context_menu_list),
+ onItemClick = { dismiss() },
+ )
+
+ init {
+ setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.signal_context_menu_background))
+
+ isFocusable = true
+
+ if (onDismiss != null) {
+ setOnDismissListener { onDismiss.run() }
+ }
+
+ if (Build.VERSION.SDK_INT >= 21) {
+ elevation = 20f
+ }
+
+ contextMenuList.setItems(items)
+ }
+
+ private fun show(): SignalContextMenu {
+ if (anchor.width == 0 || anchor.height == 0) {
+ anchor.post(this::show)
+ return this
+ }
+
+ contentView.measure(
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
+ )
+
+ val anchorRect = Rect(anchor.left, anchor.top, anchor.right, anchor.bottom).also {
+ if (anchor.parent != container) {
+ container.offsetDescendantRectToMyCoords(anchor, it)
+ }
+ }
+
+ val menuBottomBound = anchorRect.bottom + contentView.measuredHeight + baseOffsetY
+ val menuTopBound = anchorRect.top - contentView.measuredHeight - baseOffsetY
+
+ val screenBottomBound = container.height
+ val screenTopBound = container.y
+
+ val offsetY: Int
+
+ if (menuBottomBound < screenBottomBound) {
+ offsetY = baseOffsetY
+ } else if (menuTopBound > screenTopBound) {
+ offsetY = -(anchorRect.height() + contentView.measuredHeight + baseOffsetY)
+ contextMenuList.setItems(items.reversed())
+ } else {
+ offsetY = -((anchorRect.height() / 2) + (contentView.measuredHeight / 2) + baseOffsetY)
+ }
+
+ val offsetX: Int = when (horizontalPosition) {
+ HorizontalPosition.START -> {
+ if (ViewUtil.isLtr(context)) {
+ baseOffsetX
+ } else {
+ -(baseOffsetX + contentView.measuredWidth)
+ }
+ }
+ HorizontalPosition.END -> {
+ if (ViewUtil.isLtr(context)) {
+ -(baseOffsetX + contentView.measuredWidth - anchorRect.width())
+ } else {
+ baseOffsetX - anchorRect.width()
+ }
+ }
+ }
+
+ showAsDropDown(anchor, offsetX, offsetY)
+
+ return this
+ }
+
+ enum class HorizontalPosition {
+ START, END
+ }
+
+ /**
+ * @param anchor The view to put the pop-up on
+ * @param container A parent of [anchor] that represents the acceptable boundaries of the popup
+ */
+ class Builder(
+ val anchor: View,
+ val container: ViewGroup
+ ) {
+
+ var onDismiss: Runnable? = null
+ var offsetX = 0
+ var offsetY = 0
+ var horizontalPosition = HorizontalPosition.START
+
+ fun onDismiss(onDismiss: Runnable): Builder {
+ this.onDismiss = onDismiss
+ return this
+ }
+
+ fun offsetX(offsetPx: Int): Builder {
+ this.offsetX = offsetPx
+ return this
+ }
+
+ fun offsetY(offsetPx: Int): Builder {
+ this.offsetY = offsetPx
+ return this
+ }
+
+ fun preferredHorizontalPosition(horizontalPosition: HorizontalPosition): Builder {
+ this.horizontalPosition = horizontalPosition
+ return this
+ }
+
+ fun show(items: List): SignalContextMenu {
+ return SignalContextMenu(
+ anchor = anchor,
+ container = container,
+ items = items,
+ baseOffsetX = offsetX,
+ baseOffsetY = offsetY,
+ horizontalPosition = horizontalPosition,
+ onDismiss = onDismiss
+ ).show()
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/DeleteItemAnimator.java b/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/DeleteItemAnimator.java
deleted file mode 100644
index 4fc116565fe..00000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/DeleteItemAnimator.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package org.thoughtcrime.securesms.components.recyclerview;
-
-
-import androidx.recyclerview.widget.DefaultItemAnimator;
-import androidx.recyclerview.widget.RecyclerView;
-
-public class DeleteItemAnimator extends DefaultItemAnimator {
-
- public DeleteItemAnimator() {
- setSupportsChangeAnimations(false);
- }
-
- @Override
- public boolean animateAdd(RecyclerView.ViewHolder viewHolder) {
- dispatchAddFinished(viewHolder);
- return false;
- }
-
- @Override
- public boolean animateMove(RecyclerView.ViewHolder viewHolder, int fromX, int fromY, int toX, int toY) {
- dispatchMoveFinished(viewHolder);
- return false;
- }
-
-
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/SmoothScrollingLinearLayoutManager.java b/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/SmoothScrollingLinearLayoutManager.java
index ccbcd11bcf9..f79a01222cf 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/SmoothScrollingLinearLayoutManager.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/SmoothScrollingLinearLayoutManager.java
@@ -14,6 +14,11 @@ public SmoothScrollingLinearLayoutManager(Context context, boolean reverseLayout
super(context, RecyclerView.VERTICAL, reverseLayout);
}
+ @Override
+ public boolean supportsPredictiveItemAnimations() {
+ return false;
+ }
+
public void smoothScrollToPosition(@NonNull Context context, int position, float millisecondsPerInch) {
final LinearSmoothScroller scroller = new LinearSmoothScroller(context) {
@Override
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/BubbleOptOutReminder.kt b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/BubbleOptOutReminder.kt
new file mode 100644
index 00000000000..75492a2e00e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/BubbleOptOutReminder.kt
@@ -0,0 +1,16 @@
+package org.thoughtcrime.securesms.components.reminder
+
+import android.content.Context
+import org.thoughtcrime.securesms.R
+
+class BubbleOptOutReminder(context: Context) : Reminder(null, context.getString(R.string.BubbleOptOutTooltip__description)) {
+
+ init {
+ addAction(Action(context.getString(R.string.BubbleOptOutTooltip__turn_off), R.id.reminder_action_turn_off))
+ addAction(Action(context.getString(R.string.BubbleOptOutTooltip__not_now), R.id.reminder_action_not_now))
+ }
+
+ override fun isDismissable(): Boolean {
+ return false
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/DozeReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/DozeReminder.java
index fd647c12f22..05f274ea7c8 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/DozeReminder.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/DozeReminder.java
@@ -8,12 +8,12 @@
import android.os.Build;
import android.os.PowerManager;
import android.provider.Settings;
-import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@SuppressLint("BatteryLife")
@@ -31,18 +31,13 @@ public DozeReminder(@NonNull final Context context) {
context.startActivity(intent);
});
- setDismissListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- TextSecurePreferences.setPromptedOptimizeDoze(context, true);
- }
- });
+ setDismissListener(v -> TextSecurePreferences.setPromptedOptimizeDoze(context, true));
}
public static boolean isEligible(Context context) {
- return TextSecurePreferences.isFcmDisabled(context) &&
+ return !SignalStore.account().isFcmEnabled() &&
!TextSecurePreferences.hasPromptedOptimizeDoze(context) &&
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
+ Build.VERSION.SDK_INT >= 23 &&
!((PowerManager)context.getSystemService(Context.POWER_SERVICE)).isIgnoringBatteryOptimizations(context.getPackageName());
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/PushRegistrationReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/PushRegistrationReminder.java
index e431fa68fbd..2adc8b37a61 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/PushRegistrationReminder.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/PushRegistrationReminder.java
@@ -3,8 +3,8 @@
import android.content.Context;
import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
-import org.thoughtcrime.securesms.util.TextSecurePreferences;
public class PushRegistrationReminder extends Reminder {
@@ -21,6 +21,6 @@ public boolean isDismissable() {
}
public static boolean isEligible(Context context) {
- return !TextSecurePreferences.isPushRegistered(context);
+ return !SignalStore.account().isRegistered();
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ReminderActionsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ReminderActionsAdapter.java
index 64afd9f8092..d4fd5b0bcb8 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ReminderActionsAdapter.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ReminderActionsAdapter.java
@@ -1,11 +1,14 @@
package org.thoughtcrime.securesms.components.reminder;
+import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
+import android.widget.TextView;
import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
@@ -15,10 +18,12 @@
final class ReminderActionsAdapter extends RecyclerView.Adapter {
+ private final Reminder.Importance importance;
private final List actions;
private final ReminderView.OnActionClickListener actionClickListener;
- ReminderActionsAdapter(List actions, ReminderView.OnActionClickListener actionClickListener) {
+ ReminderActionsAdapter(Reminder.Importance importance, List actions, ReminderView.OnActionClickListener actionClickListener) {
+ this.importance = importance;
this.actions = Collections.unmodifiableList(actions);
this.actionClickListener = actionClickListener;
}
@@ -26,7 +31,14 @@ final class ReminderActionsAdapter extends RecyclerView.Adapter actions = reminder.getActions();
if (actions.isEmpty()) {
+ text.setPadding(0, 0, 0, ((int) DimensionUnit.DP.toPixels(16f)));
actionsRecycler.setVisibility(GONE);
} else {
+ text.setPadding(0, 0, 0, 0);
actionsRecycler.setVisibility(VISIBLE);
- actionsRecycler.setAdapter(new ReminderActionsAdapter(actions, this::handleActionClicked));
+ actionsRecycler.setAdapter(new ReminderActionsAdapter(reminder.getImportance(), actions, this::handleActionClicked));
}
container.setVisibility(View.VISIBLE);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Segment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Segment.kt
new file mode 100644
index 00000000000..dc3feff88cd
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Segment.kt
@@ -0,0 +1,52 @@
+/*
+MIT License
+
+Copyright (c) 2020 Tiago Ornelas
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+package org.thoughtcrime.securesms.components.segmentedprogressbar
+
+/**
+ * Created by Tiago Ornelas on 18/04/2020.
+ * Model that holds the segment state
+ */
+class Segment(val animationDurationMillis: Long) {
+
+ var animationProgressPercentage: Float = 0f
+
+ var animationState: AnimationState = AnimationState.IDLE
+ set(value) {
+ animationProgressPercentage = when (value) {
+ AnimationState.ANIMATED -> 1f
+ AnimationState.IDLE -> 0f
+ else -> animationProgressPercentage
+ }
+ field = value
+ }
+
+ /**
+ * Represents possible drawing states of the segment
+ */
+ enum class AnimationState {
+ ANIMATED,
+ ANIMATING,
+ IDLE
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentState.kt
new file mode 100644
index 00000000000..ef5db4111f5
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentState.kt
@@ -0,0 +1,6 @@
+package org.thoughtcrime.securesms.components.segmentedprogressbar
+
+data class SegmentState(
+ val position: Long,
+ val duration: Long
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBar.kt b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBar.kt
new file mode 100644
index 00000000000..af2acd54cbc
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBar.kt
@@ -0,0 +1,416 @@
+/*
+MIT License
+
+Copyright (c) 2020 Tiago Ornelas
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+package org.thoughtcrime.securesms.components.segmentedprogressbar
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Path
+import android.util.AttributeSet
+import android.view.MotionEvent
+import android.view.View
+import androidx.viewpager.widget.ViewPager
+import org.thoughtcrime.securesms.R
+import java.util.concurrent.TimeUnit
+
+/**
+ * Created by Tiago Ornelas on 18/04/2020.
+ * Represents a segmented progress bar on which, the progress is set by segments
+ * @see Segment
+ * And the progress of each segment is animated based on a set speed
+ */
+class SegmentedProgressBar : View, ViewPager.OnPageChangeListener, View.OnTouchListener {
+
+ companion object {
+ /**
+ * It is common now for devices to run at 60FPS
+ */
+ val MILLIS_PER_FRAME = TimeUnit.MILLISECONDS.toMillis(17)
+ }
+
+ private val path = Path()
+ private val corners = floatArrayOf(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f)
+
+ /**
+ * Number of total segments to draw
+ */
+ var segmentCount: Int = resources.getInteger(R.integer.segmentedprogressbar_default_segments_count)
+ set(value) {
+ field = value
+ this.initSegments()
+ }
+
+ /**
+ * Mapping of segment index -> duration in millis. Negative durations
+ * ARE valid but they'll result in a call to SegmentedProgressBarListener#onRequestSegmentProgressPercentage
+ * which should return the current % position for the currently playing item. This helps
+ * to avoid synchronizing the seek bar to playback.
+ */
+ var segmentDurations: Map = mapOf()
+ set(value) {
+ field = value
+ this.initSegments()
+ }
+
+ var margin: Int = resources.getDimensionPixelSize(R.dimen.segmentedprogressbar_default_segment_margin)
+ private set
+ var radius: Int = resources.getDimensionPixelSize(R.dimen.segmentedprogressbar_default_corner_radius)
+ private set
+ var segmentStrokeWidth: Int =
+ resources.getDimensionPixelSize(R.dimen.segmentedprogressbar_default_segment_stroke_width)
+ private set
+
+ var segmentBackgroundColor: Int = Color.WHITE
+ private set
+ var segmentSelectedBackgroundColor: Int =
+ context.getThemeColor(R.attr.colorAccent)
+ private set
+ var segmentStrokeColor: Int = Color.BLACK
+ private set
+ var segmentSelectedStrokeColor: Int = Color.BLACK
+ private set
+
+ var timePerSegmentMs: Long =
+ resources.getInteger(R.integer.segmentedprogressbar_default_time_per_segment_ms).toLong()
+ private set
+
+ private var segments = mutableListOf()
+ private val selectedSegment: Segment?
+ get() = segments.firstOrNull { it.animationState == Segment.AnimationState.ANIMATING }
+ private val selectedSegmentIndex: Int
+ get() = segments.indexOf(this.selectedSegment)
+
+ // Drawing
+ val strokeApplicable: Boolean
+ get() = segmentStrokeWidth * 4 <= measuredHeight
+
+ val segmentWidth: Float
+ get() = (measuredWidth - margin * (segmentCount - 1)).toFloat() / segmentCount
+
+ var viewPager: ViewPager? = null
+ @SuppressLint("ClickableViewAccessibility")
+ set(value) {
+ field = value
+ if (value == null) {
+ viewPager?.removeOnPageChangeListener(this)
+ viewPager?.setOnTouchListener(null)
+ } else {
+ viewPager?.addOnPageChangeListener(this)
+ viewPager?.setOnTouchListener(this)
+ }
+ }
+
+ /**
+ * Sets callbacks for progress bar state changes
+ * @see SegmentedProgressBarListener
+ */
+ var listener: SegmentedProgressBarListener? = null
+
+ private var lastFrameTimeMillis: Long = 0L
+
+ constructor(context: Context) : super(context)
+
+ constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
+
+ val typedArray =
+ context.theme.obtainStyledAttributes(attrs, R.styleable.SegmentedProgressBar, 0, 0)
+
+ segmentCount =
+ typedArray.getInt(R.styleable.SegmentedProgressBar_totalSegments, segmentCount)
+
+ margin =
+ typedArray.getDimensionPixelSize(
+ R.styleable.SegmentedProgressBar_segmentMargins,
+ margin
+ )
+ radius =
+ typedArray.getDimensionPixelSize(
+ R.styleable.SegmentedProgressBar_segmentCornerRadius,
+ radius
+ )
+ segmentStrokeWidth =
+ typedArray.getDimensionPixelSize(
+ R.styleable.SegmentedProgressBar_segmentStrokeWidth,
+ segmentStrokeWidth
+ )
+
+ segmentBackgroundColor =
+ typedArray.getColor(
+ R.styleable.SegmentedProgressBar_segmentBackgroundColor,
+ segmentBackgroundColor
+ )
+ segmentSelectedBackgroundColor =
+ typedArray.getColor(
+ R.styleable.SegmentedProgressBar_segmentSelectedBackgroundColor,
+ segmentSelectedBackgroundColor
+ )
+
+ segmentStrokeColor =
+ typedArray.getColor(
+ R.styleable.SegmentedProgressBar_segmentStrokeColor,
+ segmentStrokeColor
+ )
+ segmentSelectedStrokeColor =
+ typedArray.getColor(
+ R.styleable.SegmentedProgressBar_segmentSelectedStrokeColor,
+ segmentSelectedStrokeColor
+ )
+
+ timePerSegmentMs =
+ typedArray.getInt(
+ R.styleable.SegmentedProgressBar_timePerSegment,
+ timePerSegmentMs.toInt()
+ ).toLong()
+
+ typedArray.recycle()
+ }
+
+ constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
+ context,
+ attrs,
+ defStyleAttr
+ )
+
+ init {
+ setLayerType(LAYER_TYPE_SOFTWARE, null)
+ }
+
+ override fun onDraw(canvas: Canvas?) {
+ super.onDraw(canvas)
+
+ segments.forEachIndexed { index, segment ->
+ val drawingComponents = getDrawingComponents(segment, index)
+
+ when (index) {
+ 0 -> {
+ corners.indices.forEach { corners[it] = 0f }
+ corners[0] = radius.toFloat()
+ corners[1] = radius.toFloat()
+ corners[6] = radius.toFloat()
+ corners[7] = radius.toFloat()
+ }
+ segments.lastIndex -> {
+ corners.indices.forEach { corners[it] = 0f }
+ corners[2] = radius.toFloat()
+ corners[3] = radius.toFloat()
+ corners[4] = radius.toFloat()
+ corners[5] = radius.toFloat()
+ }
+ }
+
+ drawingComponents.first.forEachIndexed { drawingIndex, rectangle ->
+ when (index) {
+ 0, segments.lastIndex -> {
+ path.reset()
+ path.addRoundRect(rectangle, corners, Path.Direction.CW)
+ canvas?.drawPath(path, drawingComponents.second[drawingIndex])
+ }
+ else -> canvas?.drawRect(
+ rectangle,
+ drawingComponents.second[drawingIndex]
+ )
+ }
+ }
+ }
+
+ onFrame(System.currentTimeMillis())
+ }
+
+ /**
+ * Start/Resume progress animation
+ */
+ fun start() {
+ pause()
+ val segment = selectedSegment
+ if (segment == null) {
+ next()
+ } else {
+ isPaused = false
+ invalidate()
+ }
+ }
+
+ /**
+ * Pauses the animation process
+ */
+ fun pause() {
+ isPaused = true
+ lastFrameTimeMillis = 0L
+ }
+
+ /**
+ * Resets the whole animation state and selected segments
+ * !Doesn't restart it!
+ * To restart, call the start() method
+ */
+ fun reset() {
+ this.segments.map { it.animationState = Segment.AnimationState.IDLE }
+ this.invalidate()
+ }
+
+ /**
+ * Starts animation for the following segment
+ */
+ fun next() {
+ loadSegment(offset = 1, userAction = true)
+ }
+
+ /**
+ * Starts animation for the previous segment
+ */
+ fun previous() {
+ loadSegment(offset = -1, userAction = true)
+ }
+
+ /**
+ * Restarts animation for the current segment
+ */
+ fun restartSegment() {
+ loadSegment(offset = 0, userAction = true)
+ }
+
+ /**
+ * Skips a number of segments
+ * @param offset number o segments fo skip
+ */
+ fun skip(offset: Int) {
+ loadSegment(offset = offset, userAction = true)
+ }
+
+ /**
+ * Sets current segment to the
+ * @param position index
+ */
+ fun setPosition(position: Int) {
+ loadSegment(offset = position - this.selectedSegmentIndex, userAction = true)
+ }
+
+ // Private methods
+ private fun loadSegment(offset: Int, userAction: Boolean) {
+ val oldSegmentIndex = this.segments.indexOf(this.selectedSegment)
+
+ val nextSegmentIndex = oldSegmentIndex + offset
+
+ // Index out of bounds, ignore operation
+ if (userAction && nextSegmentIndex !in 0 until segmentCount) {
+ if (nextSegmentIndex >= segmentCount) {
+ this.listener?.onFinished()
+ } else {
+ loadSegment(offset = 0, userAction = false)
+ }
+ return
+ }
+
+ segments.mapIndexed { index, segment ->
+ if (offset > 0) {
+ if (index < nextSegmentIndex) segment.animationState =
+ Segment.AnimationState.ANIMATED
+ } else if (offset < 0) {
+ if (index > nextSegmentIndex - 1) segment.animationState =
+ Segment.AnimationState.IDLE
+ } else if (offset == 0) {
+ if (index == nextSegmentIndex) segment.animationState = Segment.AnimationState.IDLE
+ }
+ }
+
+ val nextSegment = this.segments.getOrNull(nextSegmentIndex)
+
+ // Handle next segment transition/ending
+ if (nextSegment != null) {
+ pause()
+ nextSegment.animationState = Segment.AnimationState.ANIMATING
+ isPaused = false
+ invalidate()
+ this.listener?.onPage(oldSegmentIndex, this.selectedSegmentIndex)
+ viewPager?.currentItem = this.selectedSegmentIndex
+ } else {
+ pause()
+ this.listener?.onFinished()
+ }
+ }
+
+ private fun getSegmentProgressPercentage(segment: Segment, timeSinceLastFrameMillis: Long): Float {
+ return if (segment.animationDurationMillis > 0) {
+ segment.animationProgressPercentage + timeSinceLastFrameMillis.toFloat() / segment.animationDurationMillis
+ } else {
+ listener?.onRequestSegmentProgressPercentage() ?: 0f
+ }
+ }
+
+ private fun initSegments() {
+ this.segments.clear()
+ segments.addAll(
+ List(segmentCount) {
+ val duration = segmentDurations[it] ?: timePerSegmentMs
+ Segment(duration)
+ }
+ )
+ this.invalidate()
+ reset()
+ }
+
+ private var isPaused = true
+
+ private fun onFrame(frameTimeMillis: Long) {
+ if (isPaused) {
+ return
+ }
+
+ val lastFrameTimeMillis = this.lastFrameTimeMillis
+
+ this.lastFrameTimeMillis = frameTimeMillis
+
+ val selectedSegment = this.selectedSegment
+ if (selectedSegment == null) {
+ loadSegment(offset = 1, userAction = false)
+ } else if (lastFrameTimeMillis > 0L) {
+ val segmentProgressPercentage = getSegmentProgressPercentage(selectedSegment, frameTimeMillis - lastFrameTimeMillis)
+ selectedSegment.animationProgressPercentage = segmentProgressPercentage
+ if (selectedSegment.animationProgressPercentage >= 1f) {
+ loadSegment(offset = 1, userAction = false)
+ } else {
+ this.invalidate()
+ }
+ } else {
+ this.invalidate()
+ }
+ }
+
+ override fun onPageScrollStateChanged(state: Int) {}
+
+ override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {}
+
+ override fun onPageSelected(position: Int) {
+ this.setPosition(position)
+ }
+
+ override fun onTouch(p0: View?, p1: MotionEvent?): Boolean {
+ when (p1?.action) {
+ MotionEvent.ACTION_DOWN -> pause()
+ MotionEvent.ACTION_UP -> start()
+ }
+ return false
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBarListener.kt b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBarListener.kt
new file mode 100644
index 00000000000..4f7a0e9c05c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBarListener.kt
@@ -0,0 +1,42 @@
+/*
+MIT License
+
+Copyright (c) 2020 Tiago Ornelas
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+package org.thoughtcrime.securesms.components.segmentedprogressbar
+
+/**
+ * Created by Tiago Ornelas on 18/04/2020.
+ * Interface to communicate progress events
+ */
+interface SegmentedProgressBarListener {
+ /**
+ * Notifies when selected segment changed
+ */
+ fun onPage(oldPageIndex: Int, newPageIndex: Int)
+
+ /**
+ * Notifies when last segment finished animating
+ */
+ fun onFinished()
+
+ fun onRequestSegmentProgressPercentage(): Float?
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Utils.kt b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Utils.kt
new file mode 100644
index 00000000000..bd8974b694c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Utils.kt
@@ -0,0 +1,95 @@
+/*
+MIT License
+
+Copyright (c) 2020 Tiago Ornelas
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+package org.thoughtcrime.securesms.components.segmentedprogressbar
+
+import android.content.Context
+import android.graphics.Paint
+import android.graphics.RectF
+import android.util.TypedValue
+
+fun Context.getThemeColor(attributeColor: Int): Int {
+ val typedValue = TypedValue()
+ this.theme.resolveAttribute(attributeColor, typedValue, true)
+ return typedValue.data
+}
+
+fun SegmentedProgressBar.getDrawingComponents(
+ segment: Segment,
+ segmentIndex: Int
+): Pair, MutableList> {
+
+ val rectangles = mutableListOf()
+ val paints = mutableListOf()
+ val segmentWidth = segmentWidth
+ val startBound = segmentIndex * segmentWidth + ((segmentIndex) * margin)
+ val endBound = startBound + segmentWidth
+ val stroke = if (!strokeApplicable) 0f else this.segmentStrokeWidth.toFloat()
+
+ val backgroundPaint = Paint().apply {
+ style = Paint.Style.FILL
+ color = segmentBackgroundColor
+ }
+
+ val selectedBackgroundPaint = Paint().apply {
+ style = Paint.Style.FILL
+ color = segmentSelectedBackgroundColor
+ }
+
+ val strokePaint = Paint().apply {
+ color =
+ if (segment.animationState == Segment.AnimationState.IDLE) segmentStrokeColor else segmentSelectedStrokeColor
+ style = Paint.Style.STROKE
+ strokeWidth = stroke
+ }
+
+ // Background component
+ if (segment.animationState == Segment.AnimationState.ANIMATED) {
+ rectangles.add(RectF(startBound + stroke, height - stroke, endBound - stroke, stroke))
+ paints.add(selectedBackgroundPaint)
+ } else {
+ rectangles.add(RectF(startBound + stroke, height - stroke, endBound - stroke, stroke))
+ paints.add(backgroundPaint)
+ }
+
+ // Progress component
+ if (segment.animationState == Segment.AnimationState.ANIMATING) {
+ rectangles.add(
+ RectF(
+ startBound + stroke,
+ height - stroke,
+ startBound + segment.animationProgressPercentage * segmentWidth,
+ stroke
+ )
+ )
+ paints.add(selectedBackgroundPaint)
+ }
+
+ // Stroke component
+ if (stroke > 0) {
+ rectangles.add(RectF(startBound + stroke, height - stroke, endBound - stroke, stroke))
+ paints.add(strokePaint)
+ }
+
+ return Pair(rectangles, paints)
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsAdapter.java
index ed4ce3e4eaa..5214471cae2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsAdapter.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsAdapter.java
@@ -3,7 +3,8 @@
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
-import org.thoughtcrime.securesms.util.MappingAdapter;
+import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory;
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter;
/**
* Reusable adapter for generic settings list.
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsFragment.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsFragment.java
index 02a7fd9f55e..d5841c2a0e2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsFragment.java
@@ -13,7 +13,7 @@
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
-import org.thoughtcrime.securesms.util.MappingModelList;
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import java.io.Serializable;
import java.util.Objects;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/CustomizableSingleSelectSetting.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/CustomizableSingleSelectSetting.java
index 74f1615bea7..33aec570a09 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/CustomizableSingleSelectSetting.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/CustomizableSingleSelectSetting.java
@@ -7,8 +7,8 @@
import androidx.constraintlayout.widget.Group;
import org.thoughtcrime.securesms.R;
-import org.thoughtcrime.securesms.util.MappingModel;
-import org.thoughtcrime.securesms.util.MappingViewHolder;
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder;
import java.util.Objects;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt
index bea5a85f467..af7d76e6352 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt
@@ -11,16 +11,23 @@ import androidx.annotation.CallSuper
import androidx.core.content.ContextCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
+import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.settings.models.AsyncSwitch
+import org.thoughtcrime.securesms.components.settings.models.Button
+import org.thoughtcrime.securesms.components.settings.models.Space
+import org.thoughtcrime.securesms.components.settings.models.Text
import org.thoughtcrime.securesms.util.CommunicationActions
-import org.thoughtcrime.securesms.util.MappingAdapter
-import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ViewUtil
+import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
class DSLSettingsAdapter : MappingAdapter() {
init {
registerFactory(ClickPreference::class.java, LayoutFactory(::ClickPreferenceViewHolder, R.layout.dsl_preference_item))
+ registerFactory(LongClickPreference::class.java, LayoutFactory(::LongClickPreferenceViewHolder, R.layout.dsl_preference_item))
registerFactory(TextPreference::class.java, LayoutFactory(::TextPreferenceViewHolder, R.layout.dsl_preference_item))
registerFactory(RadioListPreference::class.java, LayoutFactory(::RadioListPreferenceViewHolder, R.layout.dsl_preference_item))
registerFactory(MultiSelectListPreference::class.java, LayoutFactory(::MultiSelectListPreferenceViewHolder, R.layout.dsl_preference_item))
@@ -29,6 +36,10 @@ class DSLSettingsAdapter : MappingAdapter() {
registerFactory(SectionHeaderPreference::class.java, LayoutFactory(::SectionHeaderPreferenceViewHolder, R.layout.dsl_section_header))
registerFactory(SwitchPreference::class.java, LayoutFactory(::SwitchPreferenceViewHolder, R.layout.dsl_switch_preference_item))
registerFactory(RadioPreference::class.java, LayoutFactory(::RadioPreferenceViewHolder, R.layout.dsl_radio_preference_item))
+ Text.register(this)
+ Space.register(this)
+ Button.register(this)
+ AsyncSwitch.register(this)
}
}
@@ -82,12 +93,27 @@ class ClickPreferenceViewHolder(itemView: View) : PreferenceViewHolder(itemView) {
+ override fun bind(model: LongClickPreference) {
+ super.bind(model)
+ itemView.setOnLongClickListener() {
+ model.onLongClick()
+ true
+ }
+ }
+}
+
class RadioListPreferenceViewHolder(itemView: View) : PreferenceViewHolder(itemView) {
override fun bind(model: RadioListPreference) {
super.bind(model)
- summaryView.visibility = View.VISIBLE
- summaryView.text = model.listItems[model.selected]
+ if (model.selected >= 0) {
+ summaryView.visibility = View.VISIBLE
+ summaryView.text = model.listItems[model.selected]
+ } else {
+ summaryView.visibility = View.GONE
+ Log.w(TAG, "Detected a radio list without a default selection: ${model.dialogTitle}")
+ }
itemView.setOnClickListener {
var selection = -1
@@ -117,6 +143,10 @@ class RadioListPreferenceViewHolder(itemView: View) : PreferenceViewHolder(itemView) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsBottomSheetFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsBottomSheetFragment.kt
new file mode 100644
index 00000000000..f0b00b8bd80
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsBottomSheetFragment.kt
@@ -0,0 +1,54 @@
+package org.thoughtcrime.securesms.components.settings
+
+import android.content.Context
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.EdgeEffect
+import androidx.annotation.LayoutRes
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
+
+abstract class DSLSettingsBottomSheetFragment(
+ @LayoutRes private val layoutId: Int = R.layout.dsl_settings_bottom_sheet,
+ val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) },
+ override val peekHeightPercentage: Float = 1f
+) : FixedRoundedCornerBottomSheetDialogFragment() {
+
+ protected lateinit var recyclerView: RecyclerView
+ private set
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ return inflater.inflate(layoutId, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ recyclerView = view.findViewById(R.id.recycler)
+ recyclerView.edgeEffectFactory = EdgeEffectFactory()
+ val adapter = DSLSettingsAdapter()
+
+ recyclerView.layoutManager = layoutManagerProducer(requireContext())
+ recyclerView.adapter = adapter
+ recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS
+
+ bindAdapter(adapter)
+ }
+
+ abstract fun bindAdapter(adapter: DSLSettingsAdapter)
+
+ private class EdgeEffectFactory : RecyclerView.EdgeEffectFactory() {
+ override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect {
+ return super.createEdgeEffect(view, direction).apply {
+ if (Build.VERSION.SDK_INT > 21) {
+ color =
+ requireNotNull(ContextCompat.getColor(view.context, R.color.settings_ripple_color))
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt
index 82d08b0ed10..06b6388dd74 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt
@@ -1,15 +1,18 @@
package org.thoughtcrime.securesms.components.settings
+import android.content.Context
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.EdgeEffect
+import androidx.annotation.CallSuper
import androidx.annotation.LayoutRes
import androidx.annotation.MenuRes
import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
@@ -18,38 +21,57 @@ import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimation
abstract class DSLSettingsFragment(
@StringRes private val titleId: Int = -1,
@MenuRes private val menuId: Int = -1,
- @LayoutRes layoutId: Int = R.layout.dsl_settings_fragment
+ @LayoutRes layoutId: Int = R.layout.dsl_settings_fragment,
+ protected var layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) }
) : Fragment(layoutId) {
- private lateinit var recyclerView: RecyclerView
- private lateinit var scrollAnimationHelper: OnScrollAnimationHelper
+ protected var recyclerView: RecyclerView? = null
+ private set
+ private var scrollAnimationHelper: OnScrollAnimationHelper? = null
+
+ @CallSuper
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- val toolbar: Toolbar = view.findViewById(R.id.toolbar)
- val toolbarShadow: View = view.findViewById(R.id.toolbar_shadow)
+ val toolbar: Toolbar? = view.findViewById(R.id.toolbar)
+ val toolbarShadow: View? = view.findViewById(R.id.toolbar_shadow)
if (titleId != -1) {
- toolbar.setTitle(titleId)
+ toolbar?.setTitle(titleId)
}
- toolbar.setNavigationOnClickListener {
+ toolbar?.setNavigationOnClickListener {
requireActivity().onBackPressed()
}
if (menuId != -1) {
- toolbar.inflateMenu(menuId)
- toolbar.setOnMenuItemClickListener { onOptionsItemSelected(it) }
+ toolbar?.inflateMenu(menuId)
+ toolbar?.setOnMenuItemClickListener { onOptionsItemSelected(it) }
+ }
+
+ if (toolbarShadow != null) {
+ scrollAnimationHelper = getOnScrollAnimationHelper(toolbarShadow)
}
- recyclerView = view.findViewById(R.id.recycler)
- recyclerView.edgeEffectFactory = EdgeEffectFactory()
- scrollAnimationHelper = getOnScrollAnimationHelper(toolbarShadow)
- val adapter = DSLSettingsAdapter()
+ val settingsAdapter = DSLSettingsAdapter()
+
+ recyclerView = view.findViewById(R.id.recycler).apply {
+ edgeEffectFactory = EdgeEffectFactory()
+ layoutManager = layoutManagerProducer(requireContext())
+ adapter = settingsAdapter
- recyclerView.adapter = adapter
- recyclerView.addOnScrollListener(scrollAnimationHelper)
+ val helper = scrollAnimationHelper
+ if (helper != null) {
+ addOnScrollListener(helper)
+ }
+ }
+
+ bindAdapter(settingsAdapter)
+ }
- bindAdapter(adapter)
+ override fun onDestroyView() {
+ super.onDestroyView()
+ recyclerView = null
+ scrollAnimationHelper = null
}
protected open fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsIcon.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsIcon.kt
index 81ba8622e55..fb0b2431ef9 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsIcon.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsIcon.kt
@@ -4,8 +4,11 @@ import android.content.Context
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Drawable
+import android.graphics.drawable.InsetDrawable
+import android.graphics.drawable.LayerDrawable
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
+import androidx.annotation.Px
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
@@ -24,6 +27,23 @@ sealed class DSLSettingsIcon {
}
}
+ private data class FromResourceWithBackground(
+ @DrawableRes private val iconId: Int,
+ @ColorRes private val iconTintId: Int,
+ @DrawableRes private val backgroundId: Int,
+ @ColorRes private val backgroundTint: Int,
+ @Px private val insetPx: Int,
+ ) : DSLSettingsIcon() {
+ override fun resolve(context: Context): Drawable {
+ return LayerDrawable(
+ arrayOf(
+ FromResource(backgroundId, backgroundTint).resolve(context),
+ InsetDrawable(FromResource(iconId, iconTintId).resolve(context), insetPx, insetPx, insetPx, insetPx)
+ )
+ )
+ }
+ }
+
private data class FromDrawable(
private val drawable: Drawable
) : DSLSettingsIcon() {
@@ -33,6 +53,17 @@ sealed class DSLSettingsIcon {
abstract fun resolve(context: Context): Drawable
companion object {
+ @JvmStatic
+ fun from(
+ @DrawableRes iconId: Int,
+ @ColorRes iconTintId: Int,
+ @DrawableRes backgroundId: Int,
+ @ColorRes backgroundTint: Int,
+ @Px insetPx: Int = 0
+ ): DSLSettingsIcon {
+ return FromResourceWithBackground(iconId, iconTintId, backgroundId, backgroundTint, insetPx)
+ }
+
@JvmStatic
fun from(@DrawableRes iconId: Int, @ColorRes iconTintId: Int = R.color.signal_icon_tint_primary): DSLSettingsIcon = FromResource(iconId, iconTintId)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt
index 9457fed5969..f1bb0b8edcd 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt
@@ -1,37 +1,98 @@
package org.thoughtcrime.securesms.components.settings
import android.content.Context
+import android.text.SpannableStringBuilder
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
+import androidx.annotation.StyleRes
+import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.SpanUtil
sealed class DSLSettingsText {
+ protected abstract val modifiers: List
+
private data class FromResource(
@StringRes private val stringId: Int,
- @ColorInt private val textColor: Int?
+ override val modifiers: List