Compare commits

..

47 Commits

Author SHA1 Message Date
Greyson Parrelli
e551ea8bd9 Bump version to 4.55.1 2020-02-04 00:59:45 -05:00
Greyson Parrelli
5a28b1bf1c Updated language translations. 2020-02-04 00:59:15 -05:00
Greyson Parrelli
4cd1129c92 Show snooze snackbars for longer. 2020-02-04 00:55:38 -05:00
Greyson Parrelli
a5d7bc4efc Force custom emoji for reactions. 2020-02-04 00:42:41 -05:00
Greyson Parrelli
1ff5b2af2a Capitalize 'PIN' in strings. 2020-02-04 00:01:07 -05:00
Greyson Parrelli
82446ce30a Hide attachment keyboard after selecting an action. 2020-02-04 00:00:06 -05:00
Alan Evans
6465248483 Fix megaphone snooze. 2020-02-03 22:44:43 -05:00
Greyson Parrelli
48e7f82466 Fix issue with view-once toggle breaking video controls. 2020-02-03 18:26:49 -05:00
Greyson Parrelli
6fef21ebc0 Update permission copy in attachment keyboard. 2020-02-03 15:32:20 -05:00
Greyson Parrelli
837e594607 Bump version to 4.55.0 2020-02-03 15:05:05 -05:00
Greyson Parrelli
ab0cb55b80 Updated language translations. 2020-02-03 15:05:05 -05:00
Greyson Parrelli
2d24c8c525 Update conditions for PIN megaphone.
Handles additional corner cases.
- Shows megaphone when you register with a v1 pin.
- Show fullscreen when you fail to set a PIN during registration.
2020-02-03 15:04:53 -05:00
Alan Evans
40383f3733 Handle presenting KBS account locked cases. 2020-02-03 15:04:53 -05:00
Alex Hart
e14861d79d CreatePinActivity naming update and copy fixes. 2020-02-03 15:04:53 -05:00
Alan Evans
b29b3d0432 Require at least 4 digits during registration. 2020-02-03 15:04:53 -05:00
Greyson Parrelli
c21d4861c0 Clear text entry after changing PIN types. 2020-02-03 15:04:53 -05:00
Greyson Parrelli
a6786e5c2b Fix strings in KBS PIN flow. 2020-02-03 15:04:53 -05:00
Greyson Parrelli
77caa9e9d4 Fix crash in getIntentForPinCreate(), show 'Create' in prefs. 2020-02-03 15:04:53 -05:00
Greyson Parrelli
835ef02872 Add an 'All' tab to reaction details. 2020-02-03 15:04:53 -05:00
Alex Hart
279dcb1428 Apply KBS Lock fixes and pluralization 2020-02-03 15:04:53 -05:00
Alan Evans
4a8c312e0a Clear pin on confirm screen on submit. 2020-02-03 15:04:53 -05:00
Alan Evans
c2bc376f87 Hide PIN from summary when feature flag set. 2020-02-03 15:04:37 -05:00
Greyson Parrelli
73160d4d26 Update reactions UI. 2020-02-03 14:20:08 -05:00
Alan Evans
1dd2a4e9c5 Allow backup passphrase verification. 2020-02-03 14:20:08 -05:00
Alan Evans
ed0c4b8de5 Remove KBS feature flag. 2020-02-03 14:20:08 -05:00
Greyson Parrelli
4f921d761d Enable reaction sending. 2020-02-03 14:20:08 -05:00
Greyson Parrelli
37f85d6deb Delete unused megaphones from the database. 2020-02-03 14:20:08 -05:00
Alex Hart
e1b75c78ab Add Pins for All Megaphone Kill Switch. 2020-02-03 14:20:08 -05:00
Alan Evans
5e83206e6e Fix group timer message. 2020-02-03 14:20:08 -05:00
Alan Evans
1ea6838db6 Bring KBS fragment source into RegistrationLockFragment and handle account locked. 2020-02-03 14:20:08 -05:00
Alex Hart
fb82420376 Implement new PIN UX. 2020-02-01 12:42:29 -04:00
Greyson Parrelli
109d67956f Implement new attachment keyboard.
Such beauty. Such grace.
2020-02-01 12:38:53 -04:00
Greyson Parrelli
9f7b2e2cfd Track the first time a megaphone appeared. 2020-01-30 11:40:22 -05:00
Greyson Parrelli
22f9bfeceb Add support for creating Megaphones. Includes reactions megaphone. 2020-01-29 19:15:02 -05:00
Greyson Parrelli
ef4c7e96da Bump version to 4.54.3 2020-01-29 18:31:07 -05:00
Greyson Parrelli
02865f99a9 Limit impact of crash on unexpected SMS receive. 2020-01-29 18:28:59 -05:00
Greyson Parrelli
ef6019f13b Fix reddit link previews. 2020-01-29 18:26:16 -05:00
Greyson Parrelli
33d02bb7b8 Bump version to 4.54.2 2020-01-28 15:31:11 -05:00
Greyson Parrelli
d34df2c1cf Updated language translations. 2020-01-28 15:30:11 -05:00
Alex Hart
7fdf540742 Implement new reaction notifications. 2020-01-28 15:48:24 -04:00
Alex Hart
f916aabb98 Fix NPE when retrieving display name of unknown recipient. 2020-01-28 15:22:41 -04:00
Alex Hart
4ae7d56db4 Fix NPE when returning to profile from background.
Also generally improves saved-state management for Profile editor.
2020-01-28 14:57:17 -04:00
Alex Hart
e3878ffde7 Change profile preference screen to use toolbar. 2020-01-28 13:41:06 -04:00
Alex Hart
5221b6fb43 Fix expiration timer display issue on devices with modified font sizes.
Fixes #9335
2020-01-27 14:54:04 -04:00
Alex Hart
5e0fe86858 Add SM-G920F and BLK-L09 to LegacyCameraModels. 2020-01-27 14:32:48 -04:00
Alex Hart
c86ced0911 Add back arrow to profile editor. 2020-01-27 14:30:53 -04:00
Alex Hart
0aad82d3d7 Check content disposition flag in carrier config before parsing PDU.
Fixes #9081
2020-01-27 12:15:32 -04:00
230 changed files with 12967 additions and 4920 deletions

View File

@@ -188,8 +188,8 @@ dependencyVerification {
configuration = '(play|website)(Debug|Release)RuntimeClasspath'
}
def canonicalVersionCode = 594
def canonicalVersionName = "4.54.1"
def canonicalVersionCode = 598
def canonicalVersionName = "4.55.1"
def postFixSize = 10
def abiPostFix = ['universal' : 0,

View File

@@ -414,11 +414,21 @@
android:windowSoftInputMode="stateVisible"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".lock.v2.CreateKbsPinActivity"
android:theme="@style/TextSecure.LightRegistrationTheme"
android:windowSoftInputMode="adjustResize"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".lock.v2.KbsMigrationActivity"
android:theme="@style/TextSecure.LightRegistrationTheme"
android:windowSoftInputMode="adjustResize"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".ClearProfileAvatarActivity"
android:theme="@style/Theme.AppCompat.Dialog.Alert"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:icon="@drawable/clear_profile_avatar"
android:label="@string/AndroidManifest_remove_photo">
android:theme="@style/Theme.AppCompat.Dialog.Alert"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:icon="@drawable/clear_profile_avatar"
android:label="@string/AndroidManifest_remove_photo">
<intent-filter>
<action android:name="org.thoughtcrime.securesms.action.CLEAR_PROFILE_PHOTO"/>

View File

@@ -50,6 +50,7 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
import org.thoughtcrime.securesms.logging.AndroidLogger;
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
import org.thoughtcrime.securesms.logging.Log;
@@ -155,6 +156,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
executePendingContactSync();
KeyCachingService.onAppForegrounded(this);
ApplicationDependencies.getFrameRateTracker().begin();
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
}
@Override
@@ -248,6 +250,8 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
TextSecurePreferences.setJobManagerVersion(this, JobManager.CURRENT_VERSION);
TextSecurePreferences.setLastExperienceVersionCode(this, Util.getCanonicalVersionCode());
TextSecurePreferences.setHasSeenStickerIntroTooltip(this, true);
ApplicationDependencies.getMegaphoneRepository().onFirstEverAppLaunch();
SignalStore.registrationValues().onNewInstall();
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false));
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.BANDIT.getPackId(), BlessedPacks.BANDIT.getPackKey(), false));
}

View File

@@ -36,8 +36,8 @@ public class BasicIntroFragment extends Fragment {
super.onCreate(savedInstanceState);
if (getArguments() != null) {
drawable = getArguments().getInt(ARG_DRAWABLE);
text = getArguments().getInt(ARG_TEXT );
subtext = getArguments().getInt(ARG_SUBTEXT );
text = getArguments().getInt(ARG_TEXT);
subtext = getArguments().getInt(ARG_SUBTEXT);
}
}

View File

@@ -14,9 +14,14 @@ import androidx.fragment.app.Fragment;
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
import org.thoughtcrime.securesms.lock.v2.PinUtil;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.migrations.ApplicationMigrationActivity;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
@@ -35,6 +40,8 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA
private static final int STATE_UI_BLOCKING_UPGRADE = 3;
private static final int STATE_EXPERIENCE_UPGRADE = 4;
private static final int STATE_WELCOME_PUSH_SCREEN = 5;
private static final int STATE_CREATE_PROFILE_NAME = 6;
private static final int STATE_CREATE_KBS_PIN = 7;
private SignalServiceNetworkAccess networkAccess;
private BroadcastReceiver clearKeyReceiver;
@@ -150,6 +157,8 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA
case STATE_UI_BLOCKING_UPGRADE: return getUiBlockingUpgradeIntent();
case STATE_WELCOME_PUSH_SCREEN: return getPushRegistrationIntent();
case STATE_EXPERIENCE_UPGRADE: return getExperienceUpgradeIntent();
case STATE_CREATE_KBS_PIN: return getCreateKbsPinIntent();
case STATE_CREATE_PROFILE_NAME: return getCreateProfileNameIntent();
default: return null;
}
}
@@ -165,11 +174,23 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA
return STATE_WELCOME_PUSH_SCREEN;
} else if (ExperienceUpgradeActivity.isUpdate(this)) {
return STATE_EXPERIENCE_UPGRADE;
} else if (userMustSetKbsPin()) {
return STATE_CREATE_KBS_PIN;
} else if (userMustSetProfileName()) {
return STATE_CREATE_PROFILE_NAME;
} else {
return STATE_NORMAL;
}
}
private boolean userMustSetKbsPin() {
return !SignalStore.registrationValues().isRegistrationComplete() && !PinUtil.userHasPin(this);
}
private boolean userMustSetProfileName() {
return !SignalStore.registrationValues().isRegistrationComplete() && TextSecurePreferences.getProfileName(this) == ProfileName.EMPTY;
}
private Intent getCreatePassphraseIntent() {
return getRoutedIntent(PassphraseCreateActivity.class, getIntent());
}
@@ -193,6 +214,22 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA
return RegistrationNavigationActivity.newIntentForNewRegistration(this);
}
private Intent getCreateKbsPinIntent() {
final Intent intent;
if (userMustSetProfileName()) {
intent = getCreateProfileNameIntent();
} else {
intent = getIntent();
}
return getRoutedIntent(CreateKbsPinActivity.class, intent);
}
private Intent getCreateProfileNameIntent() {
return getRoutedIntent(EditProfileActivity.class, getIntent());
}
private Intent getRoutedIntent(Class<?> destination, @Nullable Intent nextIntent) {
final Intent intent = new Intent(this, destination);
if (nextIntent != null) intent.putExtra("next_intent", nextIntent);

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.animation;
import android.animation.Animator;
import androidx.annotation.NonNull;
import androidx.core.util.Consumer;
public final class AnimationRepeatListener implements Animator.AnimatorListener {
private final Consumer<Animator> animationConsumer;
public AnimationRepeatListener(@NonNull Consumer<Animator> animationConsumer) {
this.animationConsumer = animationConsumer;
}
@Override
public final void onAnimationStart(Animator animation) {
}
@Override
public final void onAnimationEnd(Animator animation) {
}
@Override
public final void onAnimationCancel(Animator animation) {
}
@Override
public final void onAnimationRepeat(Animator animation) {
this.animationConsumer.accept(animation);
}
}

View File

@@ -4,19 +4,25 @@ package org.thoughtcrime.securesms.backup;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.SwitchPreferenceCompat;
import org.thoughtcrime.securesms.registration.fragments.RestoreBackupFragment;
import org.thoughtcrime.securesms.service.LocalBackupListener;
import org.thoughtcrime.securesms.util.BackupUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
public class BackupDialog {
@@ -83,4 +89,35 @@ public class BackupDialog {
.create()
.show();
}
public static void showVerifyBackupPassphraseDialog(@NonNull Context context) {
View view = LayoutInflater.from(context).inflate(R.layout.enter_backup_passphrase_dialog, null);
EditText prompt = view.findViewById(R.id.restore_passphrase_input);
AlertDialog dialog = new AlertDialog.Builder(context)
.setTitle(R.string.BackupDialog_enter_backup_passphrase_to_verify)
.setView(view)
.setPositiveButton(R.string.BackupDialog_verify, null)
.setNegativeButton(android.R.string.cancel, null)
.show();
Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
positiveButton.setEnabled(false);
RestoreBackupFragment.PassphraseAsYouTypeFormatter formatter = new RestoreBackupFragment.PassphraseAsYouTypeFormatter();
prompt.addTextChangedListener(new AfterTextChanged(editable -> {
formatter.afterTextChanged(editable);
positiveButton.setEnabled(editable.length() == BackupUtil.PASSPHRASE_LENGTH);
}));
positiveButton.setOnClickListener(v -> {
String passphrase = prompt.getText().toString();
if (passphrase.equals(BackupPassphrase.get(context))) {
Toast.makeText(context, R.string.BackupDialog_you_successfully_entered_your_backup_passphrase, Toast.LENGTH_SHORT).show();
dialog.dismiss();
} else {
Toast.makeText(context, R.string.BackupDialog_passphrase_was_not_correct, Toast.LENGTH_SHORT).show();
}
});
}
}

View File

@@ -1,292 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.Manifest;
import android.animation.Animator;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.Context;
import android.graphics.drawable.BitmapDrawable;
import android.net.Uri;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.loader.app.LoaderManager;
import android.util.Pair;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewAnimationUtils;
import android.view.ViewTreeObserver;
import android.view.animation.Animation;
import android.view.animation.AnimationSet;
import android.view.animation.OvershootInterpolator;
import android.view.animation.ScaleAnimation;
import android.view.animation.TranslateAnimation;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.PopupWindow;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.util.ViewUtil;
public class AttachmentTypeSelector extends PopupWindow {
public static final int ADD_GALLERY = 1;
public static final int ADD_DOCUMENT = 2;
public static final int ADD_SOUND = 3;
public static final int ADD_CONTACT_INFO = 4;
public static final int TAKE_PHOTO = 5;
public static final int ADD_LOCATION = 6;
public static final int ADD_GIF = 7;
private static final int ANIMATION_DURATION = 300;
@SuppressWarnings("unused")
private static final String TAG = AttachmentTypeSelector.class.getSimpleName();
private final @NonNull LoaderManager loaderManager;
private final @NonNull RecentPhotoViewRail recentRail;
private final @NonNull ImageView imageButton;
private final @NonNull ImageView audioButton;
private final @NonNull ImageView documentButton;
private final @NonNull ImageView contactButton;
private final @NonNull ImageView cameraButton;
private final @NonNull ImageView locationButton;
private final @NonNull ImageView gifButton;
private final @NonNull ImageView closeButton;
private @Nullable View currentAnchor;
private @Nullable AttachmentClickedListener listener;
public AttachmentTypeSelector(@NonNull Context context, @NonNull LoaderManager loaderManager, @Nullable AttachmentClickedListener listener) {
super(context);
LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
LinearLayout layout = (LinearLayout) inflater.inflate(R.layout.attachment_type_selector, null, true);
this.listener = listener;
this.loaderManager = loaderManager;
this.recentRail = ViewUtil.findById(layout, R.id.recent_photos);
this.imageButton = ViewUtil.findById(layout, R.id.gallery_button);
this.audioButton = ViewUtil.findById(layout, R.id.audio_button);
this.documentButton = ViewUtil.findById(layout, R.id.document_button);
this.contactButton = ViewUtil.findById(layout, R.id.contact_button);
this.cameraButton = ViewUtil.findById(layout, R.id.camera_button);
this.locationButton = ViewUtil.findById(layout, R.id.location_button);
this.gifButton = ViewUtil.findById(layout, R.id.giphy_button);
this.closeButton = ViewUtil.findById(layout, R.id.close_button);
this.imageButton.setOnClickListener(new PropagatingClickListener(ADD_GALLERY));
this.audioButton.setOnClickListener(new PropagatingClickListener(ADD_SOUND));
this.documentButton.setOnClickListener(new PropagatingClickListener(ADD_DOCUMENT));
this.contactButton.setOnClickListener(new PropagatingClickListener(ADD_CONTACT_INFO));
this.cameraButton.setOnClickListener(new PropagatingClickListener(TAKE_PHOTO));
this.locationButton.setOnClickListener(new PropagatingClickListener(ADD_LOCATION));
this.gifButton.setOnClickListener(new PropagatingClickListener(ADD_GIF));
this.closeButton.setOnClickListener(new CloseClickListener());
this.recentRail.setListener(new RecentPhotoSelectedListener());
setContentView(layout);
setWidth(LinearLayout.LayoutParams.MATCH_PARENT);
setHeight(LinearLayout.LayoutParams.WRAP_CONTENT);
setBackgroundDrawable(new BitmapDrawable());
setAnimationStyle(0);
setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED);
setFocusable(true);
setTouchable(true);
loaderManager.initLoader(1, null, recentRail);
}
public void show(@NonNull Activity activity, final @NonNull View anchor) {
if (Permissions.hasAll(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
recentRail.setVisibility(View.VISIBLE);
loaderManager.restartLoader(1, null, recentRail);
} else {
recentRail.setVisibility(View.GONE);
}
this.currentAnchor = anchor;
showAtLocation(anchor, Gravity.BOTTOM, 0, 0);
getContentView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
getContentView().getViewTreeObserver().removeGlobalOnLayoutListener(this);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
animateWindowInCircular(anchor, getContentView());
} else {
animateWindowInTranslate(getContentView());
}
}
});
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
animateButtonIn(imageButton, ANIMATION_DURATION / 2);
animateButtonIn(cameraButton, ANIMATION_DURATION / 2);
animateButtonIn(audioButton, ANIMATION_DURATION / 3);
animateButtonIn(locationButton, ANIMATION_DURATION / 3);
animateButtonIn(documentButton, ANIMATION_DURATION / 4);
animateButtonIn(gifButton, ANIMATION_DURATION / 4);
animateButtonIn(contactButton, 0);
animateButtonIn(closeButton, 0);
}
}
@Override
public void dismiss() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
animateWindowOutCircular(currentAnchor, getContentView());
} else {
animateWindowOutTranslate(getContentView());
}
}
public void setListener(@Nullable AttachmentClickedListener listener) {
this.listener = listener;
}
private void animateButtonIn(View button, int delay) {
AnimationSet animation = new AnimationSet(true);
Animation scale = new ScaleAnimation(0.0f, 1.0f, 0.0f, 1.0f,
Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.0f);
animation.addAnimation(scale);
animation.setInterpolator(new OvershootInterpolator(1));
animation.setDuration(ANIMATION_DURATION);
animation.setStartOffset(delay);
button.startAnimation(animation);
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private void animateWindowInCircular(@Nullable View anchor, @NonNull View contentView) {
Pair<Integer, Integer> coordinates = getClickOrigin(anchor, contentView);
Animator animator = ViewAnimationUtils.createCircularReveal(contentView,
coordinates.first,
coordinates.second,
0,
Math.max(contentView.getWidth(), contentView.getHeight()));
animator.setDuration(ANIMATION_DURATION);
animator.start();
}
private void animateWindowInTranslate(@NonNull View contentView) {
Animation animation = new TranslateAnimation(0, 0, contentView.getHeight(), 0);
animation.setDuration(ANIMATION_DURATION);
getContentView().startAnimation(animation);
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private void animateWindowOutCircular(@Nullable View anchor, @NonNull View contentView) {
Pair<Integer, Integer> coordinates = getClickOrigin(anchor, contentView);
Animator animator = ViewAnimationUtils.createCircularReveal(getContentView(),
coordinates.first,
coordinates.second,
Math.max(getContentView().getWidth(), getContentView().getHeight()),
0);
animator.setDuration(ANIMATION_DURATION);
animator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
AttachmentTypeSelector.super.dismiss();
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
animator.start();
}
private void animateWindowOutTranslate(@NonNull View contentView) {
Animation animation = new TranslateAnimation(0, 0, 0, contentView.getTop() + contentView.getHeight());
animation.setDuration(ANIMATION_DURATION);
animation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
AttachmentTypeSelector.super.dismiss();
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
getContentView().startAnimation(animation);
}
private Pair<Integer, Integer> getClickOrigin(@Nullable View anchor, @NonNull View contentView) {
if (anchor == null) return new Pair<>(0, 0);
final int[] anchorCoordinates = new int[2];
anchor.getLocationOnScreen(anchorCoordinates);
anchorCoordinates[0] += anchor.getWidth() / 2;
anchorCoordinates[1] += anchor.getHeight() / 2;
final int[] contentCoordinates = new int[2];
contentView.getLocationOnScreen(contentCoordinates);
int x = anchorCoordinates[0] - contentCoordinates[0];
int y = anchorCoordinates[1] - contentCoordinates[1];
return new Pair<>(x, y);
}
private class RecentPhotoSelectedListener implements RecentPhotoViewRail.OnItemClickedListener {
@Override
public void onItemClicked(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height, long size) {
animateWindowOutTranslate(getContentView());
if (listener != null) listener.onQuickAttachment(uri, mimeType, bucketId, dateTaken, width, height, size);
}
}
private class PropagatingClickListener implements View.OnClickListener {
private final int type;
private PropagatingClickListener(int type) {
this.type = type;
}
@Override
public void onClick(View v) {
animateWindowOutTranslate(getContentView());
if (listener != null) listener.onClick(type);
}
}
private class CloseClickListener implements View.OnClickListener {
@Override
public void onClick(View v) {
dismiss();
}
}
public interface AttachmentClickedListener {
void onClick(int type);
void onQuickAttachment(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height, long size);
}
}

View File

@@ -1,10 +1,14 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import android.graphics.Color;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.R;
@@ -21,20 +25,30 @@ public class OutlinedThumbnailView extends ThumbnailView {
public OutlinedThumbnailView(Context context) {
super(context);
init();
init(null);
}
public OutlinedThumbnailView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
init(attrs);
}
private void init() {
private void init(@Nullable AttributeSet attrs) {
cornerMask = new CornerMask(this);
outliner = new Outliner();
outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color));
setRadius(0);
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);
}
setRadius(radius);
setCorners(radius, radius, radius, radius);
setWillNotDraw(false);
}

View File

@@ -344,6 +344,10 @@ public class ThumbnailView extends FrameLayout {
}
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Uri uri) {
return setImageResource(glideRequests, uri, 0, 0);
}
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Uri uri, int width, int height) {
SettableFuture<Boolean> future = new SettableFuture<>();
if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE);
@@ -352,6 +356,10 @@ public class ThumbnailView extends FrameLayout {
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(withCrossFade());
if (width > 0 && height > 0) {
request = request.override(width, height);
}
if (radius > 0) {
request = request.transforms(new CenterCrop(), new RoundedCorners(radius));
} else {

View File

@@ -23,6 +23,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
public class EmojiTextView extends AppCompatTextView {
private final boolean scaleEmojis;
private final boolean forceCustom;
private static final char ELLIPSIS = '…';
@@ -49,6 +50,7 @@ public class EmojiTextView extends AppCompatTextView {
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);
a.recycle();
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize});
@@ -166,7 +168,7 @@ public class EmojiTextView extends AppCompatTextView {
}
private boolean useSystemEmoji() {
return TextSecurePreferences.isSystemEmojiPreferred(getContext());
return !forceCustom && TextSecurePreferences.isSystemEmojiPreferred(getContext());
}
@Override

View File

@@ -0,0 +1,127 @@
package org.thoughtcrime.securesms.conversation;
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.InputAwareLayout;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mms.GlideApp;
import java.util.Arrays;
import java.util.List;
public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout.InputView {
private AttachmentKeyboardMediaAdapter mediaAdapter;
private AttachmentKeyboardButtonAdapter buttonAdapter;
private Callback callback;
private RecyclerView mediaList;
private View permissionText;
private View permissionButton;
public AttachmentKeyboard(@NonNull Context context) {
super(context);
init(context);
}
public AttachmentKeyboard(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(context);
}
private void init(@NonNull Context context) {
inflate(context, R.layout.attachment_keyboard, this);
this.mediaList = findViewById(R.id.attachment_keyboard_media_list );
this.permissionText = findViewById(R.id.attachment_keyboard_permission_text );
this.permissionButton = findViewById(R.id.attachment_keyboard_permission_button);
RecyclerView buttonList = findViewById(R.id.attachment_keyboard_button_list);
mediaAdapter = new AttachmentKeyboardMediaAdapter(GlideApp.with(this), media -> {
if (callback != null) {
callback.onAttachmentMediaClicked(media);
}
});
buttonAdapter = new AttachmentKeyboardButtonAdapter(button -> {
if (callback != null) {
callback.onAttachmentSelectorClicked(button);
}
});
mediaList.setAdapter(mediaAdapter);
buttonList.setAdapter(buttonAdapter);
mediaList.setLayoutManager(new GridLayoutManager(context, 1, GridLayoutManager.HORIZONTAL, false));
buttonList.setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false));
buttonAdapter.setButtons(Arrays.asList(
AttachmentKeyboardButton.GALLERY,
AttachmentKeyboardButton.GIF,
AttachmentKeyboardButton.FILE,
AttachmentKeyboardButton.CONTACT,
AttachmentKeyboardButton.LOCATION
));
}
public void setCallback(@NonNull Callback callback) {
this.callback = callback;
}
public void onMediaChanged(@NonNull List<Media> media) {
if (ContextCompat.checkSelfPermission(getContext(), Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
mediaAdapter.setMedia(media);
permissionButton.setVisibility(GONE);
permissionText.setVisibility(GONE);
} else {
permissionButton.setVisibility(VISIBLE);
permissionText.setVisibility(VISIBLE);
permissionButton.setOnClickListener(v -> {
if (callback != null) {
callback.onAttachmentPermissionsRequested();
}
});
}
}
@Override
public void show(int height, boolean immediate) {
ViewGroup.LayoutParams params = getLayoutParams();
params.height = height;
setLayoutParams(params);
setVisibility(VISIBLE);
}
@Override
public void hide(boolean immediate) {
setVisibility(GONE);
}
@Override
public boolean isShowing() {
return getVisibility() == VISIBLE;
}
public interface Callback {
void onAttachmentMediaClicked(@NonNull Media media);
void onAttachmentSelectorClicked(@NonNull AttachmentKeyboardButton button);
void onAttachmentPermissionsRequested();
}
}

View File

@@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.conversation;
import androidx.annotation.DrawableRes;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
public enum AttachmentKeyboardButton {
GALLERY(R.string.AttachmentKeyboard_gallery, R.drawable.ic_photo_album_outline_32),
GIF(R.string.AttachmentKeyboard_gif, R.drawable.ic_gif_outline_32),
FILE(R.string.AttachmentKeyboard_file, R.drawable.ic_file_outline_32),
CONTACT(R.string.AttachmentKeyboard_contact, R.drawable.ic_contact_circle_outline_32),
LOCATION(R.string.AttachmentKeyboard_location, R.drawable.ic_location_outline_32);
private final int titleRes;
private final int iconRes;
AttachmentKeyboardButton(@StringRes int titleRes, @DrawableRes int iconRes) {
this.titleRes = titleRes;
this.iconRes = iconRes;
}
public @StringRes int getTitleRes() {
return titleRes;
}
public @DrawableRes int getIconRes() {
return iconRes;
}
}

View File

@@ -0,0 +1,89 @@
package org.thoughtcrime.securesms.conversation;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import java.util.ArrayList;
import java.util.List;
class AttachmentKeyboardButtonAdapter extends RecyclerView.Adapter<AttachmentKeyboardButtonAdapter.ButtonViewHolder> {
private final List<AttachmentKeyboardButton> buttons;
private final Listener listener;
AttachmentKeyboardButtonAdapter(@NonNull Listener listener) {
this.buttons = new ArrayList<>();
this.listener = listener;
setHasStableIds(true);
}
@Override
public long getItemId(int position) {
return buttons.get(position).getTitleRes();
}
@Override
public @NonNull
ButtonViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ButtonViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.attachment_keyboard_button_item, parent, false));
}
@Override
public void onBindViewHolder(@NonNull ButtonViewHolder holder, int position) {
holder.bind(buttons.get(position), listener);
}
@Override
public void onViewRecycled(@NonNull ButtonViewHolder holder) {
holder.recycle();
}
@Override
public int getItemCount() {
return buttons.size();
}
public void setButtons(@NonNull List<AttachmentKeyboardButton> buttons) {
this.buttons.clear();
this.buttons.addAll(buttons);
notifyDataSetChanged();
}
interface Listener {
void onClick(@NonNull AttachmentKeyboardButton button);
}
static class ButtonViewHolder extends RecyclerView.ViewHolder {
private final ImageView image;
private final TextView title;
public ButtonViewHolder(@NonNull View itemView) {
super(itemView);
this.image = itemView.findViewById(R.id.attachment_button_image);
this.title = itemView.findViewById(R.id.attachment_button_title);
}
void bind(@NonNull AttachmentKeyboardButton button, @NonNull Listener listener) {
image.setImageResource(button.getIconRes());
title.setText(button.getTitleRes());
itemView.setOnClickListener(v -> listener.onClick(button));
}
void recycle() {
itemView.setOnClickListener(null);
}
}
}

View File

@@ -0,0 +1,129 @@
package org.thoughtcrime.securesms.conversation;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.OutlinedThumbnailView;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.adapter.StableIdGenerator;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
class AttachmentKeyboardMediaAdapter extends RecyclerView.Adapter<AttachmentKeyboardMediaAdapter.MediaViewHolder> {
private final List<Media> media;
private final GlideRequests glideRequests;
private final Listener listener;
private final StableIdGenerator<Media> idGenerator;
AttachmentKeyboardMediaAdapter(@NonNull GlideRequests glideRequests, @NonNull Listener listener) {
this.glideRequests = glideRequests;
this.listener = listener;
this.media = new ArrayList<>();
this.idGenerator = new StableIdGenerator<>();
setHasStableIds(true);
}
@Override
public long getItemId(int position) {
return idGenerator.getId(media.get(position));
}
@Override
public @NonNull MediaViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new MediaViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.attachment_keyboad_media_item, parent, false));
}
@Override
public void onBindViewHolder(@NonNull MediaViewHolder holder, int position) {
holder.bind(media.get(position), glideRequests, listener);
}
@Override
public void onViewRecycled(@NonNull MediaViewHolder holder) {
holder.recycle();
}
@Override
public int getItemCount() {
return media.size();
}
public void setMedia(@NonNull List<Media> media) {
this.media.clear();
this.media.addAll(media);
notifyDataSetChanged();
}
interface Listener {
void onMediaClicked(@NonNull Media media);
}
static class MediaViewHolder extends RecyclerView.ViewHolder {
private final OutlinedThumbnailView image;
private final TextView duration;
private final View videoIcon;
public MediaViewHolder(@NonNull View itemView) {
super(itemView);
image = itemView.findViewById(R.id.attachment_keyboard_item_image);
duration = itemView.findViewById(R.id.attachment_keyboard_item_video_time);
videoIcon = itemView.findViewById(R.id.attachment_keyboard_item_video_icon);
}
void bind(@NonNull Media media, @NonNull GlideRequests glideRequests, @NonNull Listener listener) {
image.setImageResource(glideRequests, media.getUri(), 400, 400);
image.setOnClickListener(v -> listener.onMediaClicked(media));
duration.setVisibility(View.GONE);
videoIcon.setVisibility(View.GONE);
if (media.getDuration() > 0) {
duration.setVisibility(View.VISIBLE);
duration.setText(formatTime(media.getDuration()));
} else if (MediaUtil.isVideoType(media.getMimeType())) {
videoIcon.setVisibility(View.VISIBLE);
}
}
void recycle() {
image.setOnClickListener(null);
}
@NonNull static String formatTime(long time) {
long hours = TimeUnit.MILLISECONDS.toHours(time);
time -= TimeUnit.HOURS.toMillis(hours);
long minutes = TimeUnit.MILLISECONDS.toMinutes(time);
time -= TimeUnit.MINUTES.toHours(time);
long seconds = TimeUnit.MILLISECONDS.toSeconds(time);
if (hours > 0) {
return zeroPad(hours) + ":" + zeroPad(minutes) + ":" + zeroPad(seconds);
} else {
return zeroPad(minutes) + ":" + zeroPad(seconds);
}
}
@NonNull static String zeroPad(long value) {
if (value < 10) {
return "0" + value;
} else {
return String.valueOf(value);
}
}
}
}

View File

@@ -97,7 +97,6 @@ import org.thoughtcrime.securesms.audio.AudioRecorder;
import org.thoughtcrime.securesms.audio.AudioSlidePlayer;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.components.AnimatingToggle;
import org.thoughtcrime.securesms.components.AttachmentTypeSelector;
import org.thoughtcrime.securesms.components.ComposeText;
import org.thoughtcrime.securesms.components.ConversationSearchBottomBar;
import org.thoughtcrime.securesms.components.HidingLinearLayout;
@@ -262,7 +261,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
InputPanel.MediaListener,
ComposeText.CursorPositionChangedListener,
ConversationSearchBottomBar.EventListener,
StickerKeyboardProvider.StickerEventListener
StickerKeyboardProvider.StickerEventListener,
AttachmentKeyboard.Callback
{
private static final String TAG = ConversationActivity.class.getSimpleName();
@@ -311,18 +311,19 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private FrameLayout messageRequestOverlay;
private ConversationReactionOverlay reactionOverlay;
private AttachmentTypeSelector attachmentTypeSelector;
private AttachmentManager attachmentManager;
private AudioRecorder audioRecorder;
private BroadcastReceiver securityUpdateReceiver;
private Stub<MediaKeyboard> emojiDrawerStub;
protected HidingLinearLayout quickAttachmentToggle;
protected HidingLinearLayout inlineAttachmentToggle;
private InputPanel inputPanel;
private AttachmentManager attachmentManager;
private AudioRecorder audioRecorder;
private BroadcastReceiver securityUpdateReceiver;
private Stub<MediaKeyboard> emojiDrawerStub;
private Stub<AttachmentKeyboard> attachmentKeyboardStub;
protected HidingLinearLayout quickAttachmentToggle;
protected HidingLinearLayout inlineAttachmentToggle;
private InputPanel inputPanel;
private LinkPreviewViewModel linkPreviewViewModel;
private ConversationSearchViewModel searchViewModel;
private ConversationStickerViewModel stickerViewModel;
private ConversationViewModel viewModel;
private InviteReminderModel inviteReminderModel;
private LiveRecipient recipient;
@@ -391,6 +392,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
initializeLinkPreviewObserver();
initializeSearchObserver();
initializeStickerObserver();
initializeViewModel();
initializeSecurity(recipient.get().isRegistered(), isDefaultSms).addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean result) {
@@ -844,7 +846,45 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
}
//////// Event Handlers
@Override
public void onAttachmentMediaClicked(@NonNull Media media) {
linkPreviewViewModel.onUserCancel();
startActivityForResult(MediaSendActivity.buildEditorIntent(ConversationActivity.this, Collections.singletonList(media), recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER);
container.hideCurrentInput(composeText);
}
@Override
public void onAttachmentSelectorClicked(@NonNull AttachmentKeyboardButton button) {
switch (button) {
case GALLERY:
AttachmentManager.selectGallery(this, MEDIA_SENDER, recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport());
break;
case GIF:
AttachmentManager.selectGif(this, PICK_GIF, !isSecureText, recipient.get().getColor().toConversationColor(this));
break;
case FILE:
AttachmentManager.selectDocument(this, PICK_DOCUMENT);
break;
case CONTACT:
AttachmentManager.selectContactInfo(this, PICK_CONTACT);
break;
case LOCATION:
AttachmentManager.selectLocation(this, PICK_LOCATION);
break;
}
container.hideCurrentInput(composeText);
}
@Override
public void onAttachmentPermissionsRequested() {
Permissions.with(this)
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
.onAllGranted(() -> viewModel.onAttachmentKeyboardOpen())
.execute();
}
//////// Event Handlers
private void handleSelectMessageExpiration() {
if (isPushGroupConversation() && !isActiveGroup()) {
@@ -1168,10 +1208,17 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private void handleAddAttachment() {
if (this.isMmsEnabled || isSecureText) {
if (attachmentTypeSelector == null) {
attachmentTypeSelector = new AttachmentTypeSelector(this, getSupportLoaderManager(), new AttachmentTypeListener());
viewModel.getRecentMedia().removeObservers(this);
if (attachmentKeyboardStub.resolved() && container.isInputOpen() && container.getCurrentInput() == attachmentKeyboardStub.get()) {
container.showSoftkey(composeText);
} else {
viewModel.getRecentMedia().observe(this, media -> attachmentKeyboardStub.get().onMediaChanged(media));
attachmentKeyboardStub.get().setCallback(this);
container.show(composeText, attachmentKeyboardStub.get());
viewModel.onAttachmentKeyboardOpen();
}
attachmentTypeSelector.show(this, attachButton);
} else {
handleManualMmsRequired();
}
@@ -1564,6 +1611,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
composeText = ViewUtil.findById(this, R.id.embedded_text_editor);
charactersLeft = ViewUtil.findById(this, R.id.space_left);
emojiDrawerStub = ViewUtil.findStubById(this, R.id.emoji_drawer_stub);
attachmentKeyboardStub = ViewUtil.findStubById(this, R.id.attachment_keyboard_stub);
unblockButton = ViewUtil.findById(this, R.id.unblock_button);
makeDefaultSmsButton = ViewUtil.findById(this, R.id.make_default_sms_button);
registerButton = ViewUtil.findById(this, R.id.register_button);
@@ -1586,10 +1634,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
inputPanel.setListener(this);
inputPanel.setMediaListener(this);
attachmentTypeSelector = null;
attachmentManager = new AttachmentManager(this, this);
audioRecorder = new AudioRecorder(this);
typingTextWatcher = new TypingStatusTextWatcher();
attachmentManager = new AttachmentManager(this, this);
audioRecorder = new AudioRecorder(this);
typingTextWatcher = new TypingStatusTextWatcher();
SendButtonListener sendButtonListener = new SendButtonListener();
ComposeKeyPressedListener composeKeyPressedListener = new ComposeKeyPressedListener();
@@ -1732,6 +1779,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
});
}
private void initializeViewModel() {
this.viewModel = ViewModelProviders.of(this, new ConversationViewModel.Factory()).get(ConversationViewModel.class);
}
private void showStickerIntroductionTooltip() {
TextSecurePreferences.setMediaKeyboardMode(this, MediaKeyboardMode.STICKER);
inputPanel.setMediaKeyboardToggleMode(true);
@@ -1835,28 +1886,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
//////// Helper Methods
private void addAttachment(int type) {
linkPreviewViewModel.onUserCancel();
Log.i(TAG, "Selected: " + type);
switch (type) {
case AttachmentTypeSelector.ADD_GALLERY:
AttachmentManager.selectGallery(this, MEDIA_SENDER, recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport()); break;
case AttachmentTypeSelector.ADD_DOCUMENT:
AttachmentManager.selectDocument(this, PICK_DOCUMENT); break;
case AttachmentTypeSelector.ADD_SOUND:
AttachmentManager.selectAudio(this, PICK_AUDIO); break;
case AttachmentTypeSelector.ADD_CONTACT_INFO:
AttachmentManager.selectContactInfo(this, PICK_CONTACT); break;
case AttachmentTypeSelector.ADD_LOCATION:
AttachmentManager.selectLocation(this, PICK_LOCATION); break;
case AttachmentTypeSelector.TAKE_PHOTO:
attachmentManager.capturePhoto(this, TAKE_PHOTO); break;
case AttachmentTypeSelector.ADD_GIF:
AttachmentManager.selectGif(this, PICK_GIF, !isSecureText, recipient.get().getColor().toConversationColor(this)); break;
}
}
private ListenableFuture<Boolean> setMedia(@Nullable Uri uri, @NonNull MediaType mediaType) {
return setMedia(uri, mediaType, 0, 0);
}
@@ -1870,7 +1899,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
openContactShareEditor(uri);
return new SettableFuture<>(false);
} else if (MediaType.IMAGE.equals(mediaType) || MediaType.GIF.equals(mediaType) || MediaType.VIDEO.equals(mediaType)) {
Media media = new Media(uri, MediaUtil.getMimeType(this, uri), 0, width, height, 0, Optional.absent(), Optional.absent());
Media media = new Media(uri, MediaUtil.getMimeType(this, uri), 0, width, height, 0, 0, Optional.absent(), Optional.absent());
startActivityForResult(MediaSendActivity.buildEditorIntent(ConversationActivity.this, Collections.singletonList(media), recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER);
return new SettableFuture<>(false);
} else {
@@ -2583,7 +2612,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private void sendSticker(@NonNull StickerLocator stickerLocator, @NonNull Uri uri, long size, boolean clearCompose) {
if (sendButton.getSelectedTransport().isSms()) {
Media media = new Media(uri, MediaUtil.IMAGE_WEBP, System.currentTimeMillis(), StickerSlide.WIDTH, StickerSlide.HEIGHT, size, Optional.absent(), Optional.absent());
Media media = new Media(uri, MediaUtil.IMAGE_WEBP, System.currentTimeMillis(), StickerSlide.WIDTH, StickerSlide.HEIGHT, size, 0, Optional.absent(), Optional.absent());
Intent intent = MediaSendActivity.buildEditorIntent(this, Collections.singletonList(media), recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport());
startActivityForResult(intent, MEDIA_SENDER);
return;
@@ -2610,20 +2639,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
// Listeners
private class AttachmentTypeListener implements AttachmentTypeSelector.AttachmentClickedListener {
@Override
public void onClick(int type) {
addAttachment(type);
}
@Override
public void onQuickAttachment(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height, long size) {
linkPreviewViewModel.onUserCancel();
Media media = new Media(uri, mimeType, dateTaken, width, height, size, Optional.of(Media.ALL_MEDIA_BUCKET_ID), Optional.absent());
startActivityForResult(MediaSendActivity.buildEditorIntent(ConversationActivity.this, Collections.singletonList(media), recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER);
}
}
private class QuickCameraToggleListener implements OnClickListener {
@Override
public void onClick(View v) {

View File

@@ -602,6 +602,7 @@ public class ConversationFragment extends Fragment
attachment.getWidth(),
attachment.getHeight(),
attachment.getSize(),
0,
Optional.absent(),
Optional.fromNullable(attachment.getCaption())));
}
@@ -994,8 +995,7 @@ public class ConversationFragment extends Fragment
if (actionMode != null) return;
if (FeatureFlags.reactionSending() &&
messageRecord.isSecure() &&
if (messageRecord.isSecure() &&
!messageRecord.isUpdate() &&
((ConversationAdapter) list.getAdapter()).getSelectedItems().isEmpty())
{

View File

@@ -42,6 +42,7 @@ import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
@@ -97,6 +98,7 @@ import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.mms.TextSlide;
import org.thoughtcrime.securesms.reactions.ReactionsConversationView;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
@@ -158,7 +160,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
private AvatarImageView contactPhoto;
private AlertView alertView;
private ViewGroup container;
protected ViewGroup reactionsContainer;
protected ReactionsConversationView reactionsView;
private @NonNull Set<MessageRecord> batchSelected = new HashSet<>();
private @NonNull Outliner outliner = new Outliner();
@@ -171,7 +173,6 @@ public class ConversationItem extends LinearLayout implements BindableConversati
private Stub<StickerView> stickerStub;
private Stub<ViewOnceMessageView> revealableStub;
private @Nullable EventListener eventListener;
private ConversationItemReactionBubbles conversationItemReactionBubbles;
private int defaultBubbleColor;
private int measureCalls;
@@ -226,9 +227,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
this.quoteView = findViewById(R.id.quote_view);
this.container = findViewById(R.id.container);
this.reply = findViewById(R.id.reply_icon);
this.reactionsContainer = findViewById(R.id.reactions_bubbles_container);
this.conversationItemReactionBubbles = new ConversationItemReactionBubbles(this.reactionsContainer);
this.reactionsView = findViewById(R.id.reactions_view);
setOnClickListener(new ClickListener(null));
@@ -906,8 +905,23 @@ public class ConversationItem extends LinearLayout implements BindableConversati
}
private void setReactions(@NonNull MessageRecord current) {
conversationItemReactionBubbles.setReactions(current.getReactions());
reactionsContainer.setOnClickListener(v -> {
if (current.getReactions().isEmpty()) {
reactionsView.clear();
return;
}
bodyBubble.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
setReactionsWithWidth(current);
bodyBubble.getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
});
}
private void setReactionsWithWidth(@NonNull MessageRecord current) {
reactionsView.setReactions(current.getReactions(), bodyBubble.getWidth());
reactionsView.setOnClickListener(v -> {
if (eventListener == null) return;
eventListener.onReactionClicked(current.getId(), current.isMms());

View File

@@ -1,177 +0,0 @@
package org.thoughtcrime.securesms.conversation;
import android.graphics.drawable.Drawable;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.Pair;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ThemeUtil;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
final class ConversationItemReactionBubbles {
private final ViewGroup reactionsContainer;
private final EmojiImageView primaryEmojiReaction;
private final EmojiImageView secondaryEmojiReaction;
ConversationItemReactionBubbles(@NonNull ViewGroup reactionsContainer) {
this.reactionsContainer = reactionsContainer;
this.primaryEmojiReaction = reactionsContainer.findViewById(R.id.reactions_bubbles_primary);
this.secondaryEmojiReaction = reactionsContainer.findViewById(R.id.reactions_bubbles_secondary);
}
void setReactions(@NonNull List<ReactionRecord> reactions) {
if (reactions.size() == 0) {
hideAllReactions();
return;
}
final Collection<ReactionInfo> reactionInfos = getReactionInfos(reactions);
if (reactionInfos.size() == 1) {
displaySingleReaction(reactionInfos.iterator().next());
} else {
displayMultipleReactions(reactionInfos);
}
}
private static @NonNull Collection<ReactionInfo> getReactionInfos(@NonNull List<ReactionRecord> reactions) {
final Map<String, ReactionInfo> counters = new HashMap<>();
for (ReactionRecord reaction : reactions) {
ReactionInfo info = counters.get(reaction.getEmoji());
if (info == null) {
info = new ReactionInfo(reaction.getEmoji(),
1,
reaction.getDateReceived(),
Recipient.self().getId().equals(reaction.getAuthor()));
} else {
info = new ReactionInfo(reaction.getEmoji(),
info.count + 1,
Math.max(info.lastSeen, reaction.getDateReceived()),
info.userWasSender || Recipient.self().getId().equals(reaction.getAuthor()));
}
counters.put(reaction.getEmoji(), info);
}
return counters.values();
}
private void hideAllReactions() {
reactionsContainer.setVisibility(View.GONE);
}
private void displaySingleReaction(@NonNull ReactionInfo reactionInfo) {
reactionsContainer.setVisibility(View.VISIBLE);
primaryEmojiReaction.setVisibility(View.VISIBLE);
secondaryEmojiReaction.setVisibility(View.GONE);
primaryEmojiReaction.setImageEmoji(reactionInfo.emoji);
primaryEmojiReaction.setBackground(getBackgroundDrawableForReactionBubble(reactionInfo));
}
private void displayMultipleReactions(@NonNull Collection<ReactionInfo> reactionInfos) {
reactionsContainer.setVisibility(View.VISIBLE);
primaryEmojiReaction.setVisibility(View.VISIBLE);
secondaryEmojiReaction.setVisibility(View.VISIBLE);
Pair<ReactionInfo, ReactionInfo> primaryAndSecondaryReactions = getPrimaryAndSecondaryReactions(reactionInfos);
primaryEmojiReaction.setImageEmoji(primaryAndSecondaryReactions.first.emoji);
primaryEmojiReaction.setBackground(getBackgroundDrawableForReactionBubble(primaryAndSecondaryReactions.first));
secondaryEmojiReaction.setImageEmoji(primaryAndSecondaryReactions.second.emoji);
secondaryEmojiReaction.setBackground(getBackgroundDrawableForReactionBubble(primaryAndSecondaryReactions.second));
}
private Drawable getBackgroundDrawableForReactionBubble(@NonNull ReactionInfo reactionInfo) {
return ThemeUtil.getThemedDrawable(reactionsContainer.getContext(),
reactionInfo.userWasSender ? R.attr.reactions_sent_background : R.attr.reactions_recv_background);
}
private Pair<ReactionInfo, ReactionInfo> getPrimaryAndSecondaryReactions(@NonNull Collection<ReactionInfo> reactionInfos) {
ReactionInfo mostPopular = null;
ReactionInfo latestReaction = null;
ReactionInfo secondLatestReaction = null;
ReactionInfo ourReaction = null;
for (ReactionInfo current : reactionInfos) {
if (current.userWasSender) {
ourReaction = current;
}
if (mostPopular == null) {
mostPopular = current;
} else if (mostPopular.count < current.count) {
mostPopular = current;
}
if (latestReaction == null) {
latestReaction = current;
} else if (latestReaction.lastSeen < current.lastSeen) {
if (current.count == mostPopular.count) {
mostPopular = current;
}
secondLatestReaction = latestReaction;
latestReaction = current;
} else if (secondLatestReaction == null) {
secondLatestReaction = current;
}
}
if (mostPopular == null) {
throw new AssertionError("getPrimaryAndSecondaryReactions was called with an empty list.");
}
if (ourReaction != null && !mostPopular.equals(ourReaction)) {
return Pair.create(mostPopular, ourReaction);
} else {
return Pair.create(mostPopular, mostPopular.equals(latestReaction) ? secondLatestReaction : latestReaction);
}
}
private static class ReactionInfo {
private final String emoji;
private final int count;
private final long lastSeen;
private final boolean userWasSender;
private ReactionInfo(@NonNull String emoji, int count, long lastSeen, boolean userWasSender) {
this.emoji = emoji;
this.count = count;
this.lastSeen = lastSeen;
this.userWasSender = userWasSender;
}
@Override
public int hashCode() {
return Objects.hash(emoji, count, lastSeen, userWasSender);
}
@Override
public boolean equals(@Nullable Object obj) {
if (!(obj instanceof ReactionInfo)) return false;
ReactionInfo other = (ReactionInfo) obj;
return other.emoji.equals(emoji) &&
other.count == count &&
other.lastSeen == lastSeen &&
other.userWasSender == userWasSender;
}
}
}

View File

@@ -33,7 +33,7 @@ final class ConversationSwipeAnimationHelper {
float progress = dx / TRIGGER_DX;
updateBodyBubbleTransition(conversationItem.bodyBubble, dx, sign);
updateReactionsTransition(conversationItem.reactionsContainer, dx, sign);
updateReactionsTransition(conversationItem.reactionsView, dx, sign);
updateReplyIconTransition(conversationItem.reply, dx, progress, sign);
updateContactPhotoHolderTransition(conversationItem.contactPhotoHolder, progress, sign);
}

View File

@@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.conversation;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mediasend.MediaRepository;
import java.util.List;
class ConversationViewModel extends ViewModel {
private final Context context;
private final MediaRepository mediaRepository;
private final MutableLiveData<List<Media>> recentMedia;
private ConversationViewModel() {
this.context = ApplicationDependencies.getApplication();
this.mediaRepository = new MediaRepository();
this.recentMedia = new MutableLiveData<>();
}
void onAttachmentKeyboardOpen() {
mediaRepository.getMediaInBucket(context, Media.ALL_MEDIA_BUCKET_ID, recentMedia::postValue);
}
@NonNull LiveData<List<Media>> getRecentMedia() {
return recentMedia;
}
static class Factory extends ViewModelProvider.NewInstanceFactory {
@Override
public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new ConversationViewModel());
}
}
}

View File

@@ -29,35 +29,10 @@ import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import androidx.annotation.DrawableRes;
import androidx.annotation.IdRes;
import androidx.annotation.MenuRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.google.android.material.snackbar.Snackbar;
import androidx.annotation.PluralsRes;
import androidx.annotation.WorkerThread;
import androidx.appcompat.widget.Toolbar;
import androidx.appcompat.widget.TooltipCompat;
import androidx.lifecycle.ViewModelProviders;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.ActionMode;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.ItemTouchHelper;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@@ -68,6 +43,30 @@ import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.DrawableRes;
import androidx.annotation.IdRes;
import androidx.annotation.MenuRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.PluralsRes;
import androidx.annotation.WorkerThread;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.ActionMode;
import androidx.appcompat.widget.Toolbar;
import androidx.appcompat.widget.TooltipCompat;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.ProcessLifecycleOwner;
import androidx.lifecycle.ViewModelProviders;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.snackbar.Snackbar;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
@@ -76,8 +75,6 @@ import org.thoughtcrime.securesms.MainFragment;
import org.thoughtcrime.securesms.MainNavigator;
import org.thoughtcrime.securesms.NewConversationActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversationlist.ConversationListAdapter.ItemClickListener;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.components.RatingManager;
import org.thoughtcrime.securesms.components.SearchToolbar;
import org.thoughtcrime.securesms.components.recyclerview.DeleteItemAnimator;
@@ -93,9 +90,7 @@ import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
import org.thoughtcrime.securesms.components.reminder.ShareReminder;
import org.thoughtcrime.securesms.components.reminder.SystemSmsImportReminder;
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
import org.thoughtcrime.securesms.contacts.avatars.ContactColors;
import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.conversationlist.ConversationListAdapter.ItemClickListener;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -108,8 +103,13 @@ import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
import org.thoughtcrime.securesms.lock.RegistrationLockDialog;
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
import org.thoughtcrime.securesms.megaphone.Megaphone;
import org.thoughtcrime.securesms.megaphone.MegaphoneListener;
import org.thoughtcrime.securesms.megaphone.MegaphoneViewBuilder;
import org.thoughtcrime.securesms.megaphone.Megaphones;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
@@ -137,7 +137,8 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
ActionMode.Callback,
ItemClickListener,
ConversationListSearchAdapter.EventListener,
MainNavigator.BackHandler
MainNavigator.BackHandler,
MegaphoneListener
{
private static final String TAG = Log.tag(ConversationListFragment.class);
@@ -163,6 +164,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
private ConversationListAdapter defaultAdapter;
private ConversationListSearchAdapter searchAdapter;
private StickyHeaderDecoration searchAdapterDecoration;
private ViewGroup megaphoneContainer;
public static ConversationListFragment newInstance() {
return new ConversationListFragment();
@@ -181,16 +183,17 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
reminderView = view.findViewById(R.id.reminder);
list = view.findViewById(R.id.list);
fab = view.findViewById(R.id.fab);
cameraFab = view.findViewById(R.id.camera_fab);
emptyState = view.findViewById(R.id.empty_state);
emptyImage = view.findViewById(R.id.empty);
searchEmptyState = view.findViewById(R.id.search_no_results);
searchToolbar = view.findViewById(R.id.search_toolbar);
searchAction = view.findViewById(R.id.search_action);
toolbarShadow = view.findViewById(R.id.conversation_list_toolbar_shadow);
reminderView = view.findViewById(R.id.reminder);
list = view.findViewById(R.id.list);
fab = view.findViewById(R.id.fab);
cameraFab = view.findViewById(R.id.camera_fab);
emptyState = view.findViewById(R.id.empty_state);
emptyImage = view.findViewById(R.id.empty);
searchEmptyState = view.findViewById(R.id.search_no_results);
searchToolbar = view.findViewById(R.id.search_toolbar);
searchAction = view.findViewById(R.id.search_action);
toolbarShadow = view.findViewById(R.id.conversation_list_toolbar_shadow);
megaphoneContainer = view.findViewById(R.id.megaphone_container);
Toolbar toolbar = view.findViewById(getToolbarRes());
toolbar.setVisibility(View.VISIBLE);
@@ -226,7 +229,8 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
initializeSearchListener();
RatingManager.showRatingDialogIfNecessary(requireContext());
RegistrationLockDialog.showReminderIfNecessary(requireContext());
RegistrationLockDialog.showReminderIfNecessary(this);
TooltipCompat.setTooltipText(searchAction, getText(R.string.SearchToolbar_search_for_conversations_contacts_and_messages));
}
@@ -303,6 +307,14 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
return false;
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
if (requestCode == CreateKbsPinActivity.REQUEST_NEW_PIN && resultCode == CreateKbsPinActivity.RESULT_OK) {
Snackbar.make(fab, R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).show();
viewModel.onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL);
}
}
@Override
public void onConversationClicked(@NonNull ThreadRecord threadRecord) {
getNavigator().goToConversation(threadRecord.getRecipient().getId(),
@@ -339,6 +351,31 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
});
}
@Override
public void onMegaphoneNavigationRequested(@NonNull Intent intent) {
startActivity(intent);
}
@Override
public void onMegaphoneNavigationRequested(@NonNull Intent intent, int requestCode) {
startActivityForResult(intent, requestCode);
}
@Override
public void onMegaphoneToastRequested(@NonNull String string) {
Snackbar.make(fab, string, Snackbar.LENGTH_LONG).show();
}
@Override
public void onMegaphoneSnooze(@NonNull Megaphone megaphone) {
viewModel.onMegaphoneSnoozed(megaphone);
}
@Override
public void onMegaphoneCompleted(@NonNull Megaphone megaphone) {
viewModel.onMegaphoneCompleted(megaphone.getEvent());
}
private void initializeProfileIcon(@NonNull Recipient recipient) {
ImageView icon = requireView().findViewById(R.id.toolbar_icon);
@@ -406,19 +443,54 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
private void initializeViewModel() {
viewModel = ViewModelProviders.of(this, new ConversationListViewModel.Factory()).get(ConversationListViewModel.class);
viewModel.getSearchResult().observe(this, result -> {
result = result != null ? result : SearchResult.EMPTY;
searchAdapter.updateResults(result);
viewModel.getSearchResult().observe(this, this::onSearchResultChanged);
viewModel.getMegaphone().observe(this, this::onMegaphoneChanged);
if (result.isEmpty() && activeAdapter == searchAdapter) {
searchEmptyState.setText(getString(R.string.SearchFragment_no_results, result.getQuery()));
searchEmptyState.setVisibility(View.VISIBLE);
} else {
searchEmptyState.setVisibility(View.GONE);
ProcessLifecycleOwner.get().getLifecycle().addObserver(new DefaultLifecycleObserver() {
@Override
public void onStart(@NonNull LifecycleOwner owner) {
viewModel.onVisible();
}
});
}
private void onSearchResultChanged(@Nullable SearchResult result) {
result = result != null ? result : SearchResult.EMPTY;
searchAdapter.updateResults(result);
if (result.isEmpty() && activeAdapter == searchAdapter) {
searchEmptyState.setText(getString(R.string.SearchFragment_no_results, result.getQuery()));
searchEmptyState.setVisibility(View.VISIBLE);
} else {
searchEmptyState.setVisibility(View.GONE);
}
}
private void onMegaphoneChanged(@Nullable Megaphone megaphone) {
if (megaphone == null) {
megaphoneContainer.setVisibility(View.GONE);
megaphoneContainer.removeAllViews();
return;
}
View view = MegaphoneViewBuilder.build(requireContext(), megaphone, this);
megaphoneContainer.removeAllViews();
if (view != null) {
megaphoneContainer.addView(view);
megaphoneContainer.setVisibility(View.VISIBLE);
} else {
megaphoneContainer.setVisibility(View.GONE);
if (megaphone.getOnVisibleListener() != null) {
megaphone.getOnVisibleListener().onEvent(megaphone, this);
}
}
viewModel.onMegaphoneVisible(megaphone);
}
private void updateReminders() {
Context context = requireContext();

View File

@@ -1,19 +1,22 @@
package org.thoughtcrime.securesms.conversationlist;
import android.app.Application;
import android.database.ContentObserver;
import android.os.Handler;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import android.app.Application;
import android.database.ContentObserver;
import android.os.Handler;
import androidx.annotation.NonNull;
import android.text.TextUtils;
import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
import org.thoughtcrime.securesms.database.DatabaseContentProviders;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.megaphone.Megaphone;
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
import org.thoughtcrime.securesms.megaphone.Megaphones;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.Util;
@@ -21,19 +24,23 @@ import org.thoughtcrime.securesms.util.Util;
class ConversationListViewModel extends ViewModel {
private final Application application;
private final MutableLiveData<Megaphone> megaphone;
private final MutableLiveData<SearchResult> searchResult;
private final SearchRepository searchRepository;
private final MegaphoneRepository megaphoneRepository;
private final Debouncer debouncer;
private final ContentObserver observer;
private String lastQuery;
private ConversationListViewModel(@NonNull Application application, @NonNull SearchRepository searchRepository) {
this.application = application;
this.searchResult = new MutableLiveData<>();
this.searchRepository = searchRepository;
this.debouncer = new Debouncer(300);
this.observer = new ContentObserver(new Handler()) {
this.application = application;
this.megaphone = new MutableLiveData<>();
this.searchResult = new MutableLiveData<>();
this.searchRepository = searchRepository;
this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository();
this.debouncer = new Debouncer(300);
this.observer = new ContentObserver(new Handler()) {
@Override
public void onChange(boolean selfChange) {
if (!TextUtils.isEmpty(getLastQuery())) {
@@ -49,6 +56,28 @@ class ConversationListViewModel extends ViewModel {
return searchResult;
}
@NonNull LiveData<Megaphone> getMegaphone() {
return megaphone;
}
void onVisible() {
megaphoneRepository.getNextMegaphone(megaphone::postValue);
}
void onMegaphoneCompleted(@NonNull Megaphones.Event event) {
megaphone.postValue(null);
megaphoneRepository.markFinished(event);
}
void onMegaphoneSnoozed(@NonNull Megaphone snoozed) {
megaphoneRepository.markSeen(snoozed.getEvent());
megaphone.postValue(null);
}
void onMegaphoneVisible(@NonNull Megaphone visible) {
megaphoneRepository.markVisible(visible.getEvent());
}
void updateQuery(String query) {
lastQuery = query;
debouncer.publish(() -> searchRepository.query(query, result -> {

View File

@@ -61,6 +61,7 @@ public class DatabaseFactory {
private final StickerDatabase stickerDatabase;
private final StorageKeyDatabase storageKeyDatabase;
private final KeyValueDatabase keyValueDatabase;
private final MegaphoneDatabase megaphoneDatabase;
public static DatabaseFactory getInstance(Context context) {
synchronized (lock) {
@@ -155,6 +156,10 @@ public class DatabaseFactory {
return getInstance(context).keyValueDatabase;
}
public static MegaphoneDatabase getMegaphoneDatabase(Context context) {
return getInstance(context).megaphoneDatabase;
}
public static SQLiteDatabase getBackupDatabase(Context context) {
return getInstance(context).databaseHelper.getReadableDatabase();
}
@@ -193,6 +198,7 @@ public class DatabaseFactory {
this.stickerDatabase = new StickerDatabase(context, databaseHelper, attachmentSecret);
this.storageKeyDatabase = new StorageKeyDatabase(context, databaseHelper);
this.keyValueDatabase = new KeyValueDatabase(context, databaseHelper);
this.megaphoneDatabase = new MegaphoneDatabase(context, databaseHelper);
}
public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret,

View File

@@ -0,0 +1,144 @@
package org.thoughtcrime.securesms.database;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import androidx.annotation.NonNull;
import net.sqlcipher.database.SQLiteDatabase;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.megaphone.Megaphones.Event;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* IMPORTANT: Writes should only be made through {@link org.thoughtcrime.securesms.megaphone.MegaphoneRepository}.
*/
public class MegaphoneDatabase extends Database {
private static final String TAG = Log.tag(MegaphoneDatabase.class);
private static final String TABLE_NAME = "megaphone";
private static final String ID = "_id";
private static final String EVENT = "event";
private static final String SEEN_COUNT = "seen_count";
private static final String LAST_SEEN = "last_seen";
private static final String FIRST_VISIBLE = "first_visible";
private static final String FINISHED = "finished";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
EVENT + " TEXT UNIQUE, " +
SEEN_COUNT + " INTEGER, " +
LAST_SEEN + " INTEGER, " +
FIRST_VISIBLE + " INTEGER, " +
FINISHED + " INTEGER)";
MegaphoneDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
}
public void insert(@NonNull Collection<Event> events) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
for (Event event : events) {
ContentValues values = new ContentValues();
values.put(EVENT, event.getKey());
db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
public @NonNull List<MegaphoneRecord> getAllAndDeleteMissing() {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
List<MegaphoneRecord> records = new ArrayList<>();
db.beginTransaction();
try {
Set<String> missingKeys = new HashSet<>();
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
String event = cursor.getString(cursor.getColumnIndexOrThrow(EVENT));
int seenCount = cursor.getInt(cursor.getColumnIndexOrThrow(SEEN_COUNT));
long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(LAST_SEEN));
long firstVisible = cursor.getLong(cursor.getColumnIndexOrThrow(FIRST_VISIBLE));
boolean finished = cursor.getInt(cursor.getColumnIndexOrThrow(FINISHED)) == 1;
if (Event.hasKey(event)) {
records.add(new MegaphoneRecord(Event.fromKey(event), seenCount, lastSeen, firstVisible, finished));
} else {
Log.w(TAG, "No in-app handing for event '" + event + "'! Deleting it from the database.");
missingKeys.add(event);
}
}
}
for (String missing : missingKeys) {
String query = EVENT + " = ?";
String[] args = new String[]{missing};
databaseHelper.getWritableDatabase().delete(TABLE_NAME, query, args);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
return records;
}
public void markFirstVisible(@NonNull Event event, long time) {
String query = EVENT + " = ?";
String[] args = new String[]{event.getKey()};
ContentValues values = new ContentValues();
values.put(FIRST_VISIBLE, time);
databaseHelper.getWritableDatabase().update(TABLE_NAME, values, query, args);
}
public void markSeen(@NonNull Event event, int seenCount, long lastSeen) {
String query = EVENT + " = ?";
String[] args = new String[]{event.getKey()};
ContentValues values = new ContentValues();
values.put(SEEN_COUNT, seenCount);
values.put(LAST_SEEN, lastSeen);
databaseHelper.getWritableDatabase().update(TABLE_NAME, values, query, args);
}
public void markFinished(@NonNull Event event) {
String query = EVENT + " = ?";
String[] args = new String[]{event.getKey()};
ContentValues values = new ContentValues();
values.put(FINISHED, 1);
databaseHelper.getWritableDatabase().update(TABLE_NAME, values, query, args);
}
public void delete(@NonNull Event event) {
String query = EVENT + " = ?";
String[] args = new String[]{event.getKey()};
databaseHelper.getWritableDatabase().delete(TABLE_NAME, query, args);
}
}

View File

@@ -350,9 +350,15 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
}
private void setReactions(@NonNull SQLiteDatabase db, long messageId, @NonNull ReactionList reactionList) {
ContentValues values = new ContentValues(1);
ContentValues values = new ContentValues(1);
boolean hasReactions = reactionList.getReactionsCount() != 0;
values.put(REACTIONS, reactionList.getReactionsList().isEmpty() ? null : reactionList.toByteArray());
values.put(REACTIONS_UNREAD, reactionList.getReactionsCount() != 0 ? 1 : 0);
values.put(REACTIONS_UNREAD, hasReactions ? 1 : 0);
if (hasReactions) {
values.put(NOTIFIED, 0);
}
String query = ID + " = ?";
String[] args = new String[] { String.valueOf(messageId) };

View File

@@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.JobDatabase;
import org.thoughtcrime.securesms.database.KeyValueDatabase;
import org.thoughtcrime.securesms.database.MegaphoneDatabase;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase;
import org.thoughtcrime.securesms.database.PushDatabase;
@@ -102,8 +103,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int ATTACHMENT_DISPLAY_ORDER = 42;
private static final int SPLIT_PROFILE_NAMES = 43;
private static final int STICKER_PACK_ORDER = 44;
private static final int MEGAPHONES = 45;
private static final int MEGAPHONE_FIRST_APPEARANCE = 46;
private static final int DATABASE_VERSION = 44;
private static final int DATABASE_VERSION = 46;
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@@ -146,6 +149,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(StickerDatabase.CREATE_TABLE);
db.execSQL(StorageKeyDatabase.CREATE_TABLE);
db.execSQL(KeyValueDatabase.CREATE_TABLE);
db.execSQL(MegaphoneDatabase.CREATE_TABLE);
executeStatements(db, SearchDatabase.CREATE_TABLE);
executeStatements(db, JobDatabase.CREATE_TABLE);
@@ -708,6 +712,18 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL("ALTER TABLE sticker ADD COLUMN pack_order INTEGER DEFAULT 0");
}
if (oldVersion < MEGAPHONES) {
db.execSQL("CREATE TABLE megaphone (_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"event TEXT UNIQUE, " +
"seen_count INTEGER, " +
"last_seen INTEGER, " +
"finished INTEGER)");
}
if (oldVersion < MEGAPHONE_FIRST_APPEARANCE) {
db.execSQL("ALTER TABLE megaphone ADD COLUMN first_visible INTEGER DEFAULT 0");
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();

View File

@@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.database.model;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.megaphone.Megaphones;
public class MegaphoneRecord {
private final Megaphones.Event event;
private final int seenCount;
private final long lastSeen;
private final long firstVisible;
private final boolean finished;
public MegaphoneRecord(@NonNull Megaphones.Event event, int seenCount, long lastSeen, long firstVisible, boolean finished) {
this.event = event;
this.seenCount = seenCount;
this.lastSeen = lastSeen;
this.firstVisible = firstVisible;
this.finished = finished;
}
public @NonNull Megaphones.Event getEvent() {
return event;
}
public int getSeenCount() {
return seenCount;
}
public long getLastSeen() {
return lastSeen;
}
public long getFirstVisible() {
return firstVisible;
}
public boolean isFinished() {
return finished;
}
}

View File

@@ -4,6 +4,8 @@ import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Objects;
public class ReactionRecord {
private final String emoji;
private final RecipientId author;
@@ -36,4 +38,20 @@ public class ReactionRecord {
public long getDateReceived() {
return dateReceived;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ReactionRecord that = (ReactionRecord) o;
return dateSent == that.dateSent &&
dateReceived == that.dateReceived &&
Objects.equals(emoji, that.emoji) &&
Objects.equals(author, that.author);
}
@Override
public int hashCode() {
return Objects.hash(emoji, author, dateSent, dateReceived);
}
}

View File

@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.IncomingMessageProcessor;
import org.thoughtcrime.securesms.gcm.MessageRetriever;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.keyvalue.KeyValueStore;
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.LiveRecipientCache;
import org.thoughtcrime.securesms.service.IncomingMessageObserver;
@@ -42,6 +43,7 @@ public class ApplicationDependencies {
private static JobManager jobManager;
private static FrameRateTracker frameRateTracker;
private static KeyValueStore keyValueStore;
private static MegaphoneRepository megaphoneRepository;
public static synchronized void init(@NonNull Application application, @NonNull Provider provider) {
if (ApplicationDependencies.application != null || ApplicationDependencies.provider != null) {
@@ -167,6 +169,16 @@ public class ApplicationDependencies {
return keyValueStore;
}
public static synchronized @NonNull MegaphoneRepository getMegaphoneRepository() {
assertInitialization();
if (megaphoneRepository == null) {
megaphoneRepository = provider.provideMegaphoneRepository();
}
return megaphoneRepository;
}
private static void assertInitialization() {
if (application == null || provider == null) {
throw new UninitializedException();
@@ -184,6 +196,7 @@ public class ApplicationDependencies {
@NonNull JobManager provideJobManager();
@NonNull FrameRateTracker provideFrameRateTracker();
@NonNull KeyValueStore provideKeyValueStore();
@NonNull MegaphoneRepository provideMegaphoneRepository();
}
private static class UninitializedException extends IllegalStateException {

View File

@@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.jobs.FastJobStorage;
import org.thoughtcrime.securesms.jobs.JobManagerFactories;
import org.thoughtcrime.securesms.keyvalue.KeyValueStore;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
import org.thoughtcrime.securesms.push.SecurityEventListener;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.LiveRecipientCache;
@@ -125,6 +126,11 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr
return new KeyValueStore(context);
}
@Override
public @NonNull MegaphoneRepository provideMegaphoneRepository() {
return new MegaphoneRepository(context);
}
private static class DynamicCredentialsProvider implements CredentialsProvider {
private final Context context;

View File

@@ -528,8 +528,9 @@ public final class PushProcessMessageJob extends BaseJob {
{
try {
MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
Recipient sender = Recipient.externalPush(context, content.getSender());
Recipient recipient = getMessageDestination(content, message);
IncomingMediaMessage mediaMessage = new IncomingMediaMessage(recipient.getId(),
IncomingMediaMessage mediaMessage = new IncomingMediaMessage(sender.getId(),
message.getTimestamp(), -1,
message.getExpiresInSeconds() * 1000L, true,
false,

View File

@@ -71,8 +71,10 @@ public class SmsReceiveJob extends BaseJob {
@Override
public void onRun() throws MigrationPendingException {
Log.i(TAG, "onRun()");
if (TextSecurePreferences.getLocalUuid(context) == null && TextSecurePreferences.getLocalNumber(context) == null) {
throw new NotReadyException();
}
Optional<IncomingTextMessage> message = assembleMessageFragments(pdus, subscriptionId);
if (message.isPresent() && !isBlocked(message.get())) {
@@ -167,4 +169,7 @@ public class SmsReceiveJob extends BaseJob {
}
}
}
private class NotReadyException extends RuntimeException {
}
}

View File

@@ -1,8 +1,10 @@
package org.thoughtcrime.securesms.keyvalue;
import androidx.annotation.CheckResult;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.whispersystems.signalservice.api.RegistrationLockData;
import org.whispersystems.signalservice.api.kbs.MasterKey;
@@ -17,6 +19,7 @@ public final class KbsValues {
private static final String MASTER_KEY = "kbs.registration_lock_master_key";
private static final String TOKEN_RESPONSE = "kbs.token_response";
private static final String LOCK_LOCAL_PIN_HASH = "kbs.registration_lock_local_pin_hash";
private static final String KEYBOARD_TYPE = "kbs.keyboard_type";
private final KeyValueStore store;
@@ -32,6 +35,7 @@ public final class KbsValues {
.remove(V2_LOCK_ENABLED)
.remove(TOKEN_RESPONSE)
.remove(LOCK_LOCAL_PIN_HASH)
.remove(KEYBOARD_TYPE)
.commit();
}
@@ -112,4 +116,15 @@ public final class KbsValues {
throw new AssertionError(e);
}
}
public void setKeyboardType(@NonNull PinKeyboardType keyboardType) {
store.beginWrite()
.putString(KEYBOARD_TYPE, keyboardType.getCode())
.commit();
}
@CheckResult
public @NonNull PinKeyboardType getKeyboardType() {
return PinKeyboardType.fromCode(store.getString(KEYBOARD_TYPE, null));
}
}

View File

@@ -106,6 +106,10 @@ public class KeyValueDataSet implements KeyValueReader {
}
}
boolean containsKey(@NonNull String key) {
return values.containsKey(key);
}
public @NonNull Map<String, Object> getValues() {
return values;
}
@@ -114,10 +118,6 @@ public class KeyValueDataSet implements KeyValueReader {
return types.get(key);
}
public boolean containsKey(@NonNull String key) {
return values.containsKey(key);
}
private <E> E readValueAsType(@NonNull String key, Class<E> type, boolean nullable) {
Object value = values.get(key);
if ((value == null && nullable) || (value != null && value.getClass() == type)) {

View File

@@ -0,0 +1,43 @@
package org.thoughtcrime.securesms.keyvalue;
import androidx.annotation.CheckResult;
import androidx.annotation.NonNull;
public final class RegistrationValues {
private static final String REGISTRATION_COMPLETE = "registration.complete";
private static final String PIN_REQUIRED = "registration.pin_required";
private final KeyValueStore store;
RegistrationValues(@NonNull KeyValueStore store) {
this.store = store;
}
public synchronized void onNewInstall() {
store.beginWrite()
.putBoolean(REGISTRATION_COMPLETE, false)
.putBoolean(PIN_REQUIRED, true)
.commit();
}
public synchronized void clearRegistrationComplete() {
onNewInstall();
}
public synchronized void setRegistrationComplete() {
store.beginWrite()
.putBoolean(REGISTRATION_COMPLETE, true)
.commit();
}
@CheckResult
public synchronized boolean pinWasRequiredAtRegistration() {
return store.getBoolean(PIN_REQUIRED, false);
}
@CheckResult
public synchronized boolean isRegistrationComplete() {
return store.getBoolean(REGISTRATION_COMPLETE, true);
}
}

View File

@@ -21,6 +21,10 @@ public final class SignalStore {
return new KbsValues(getStore());
}
public static RegistrationValues registrationValues() {
return new RegistrationValues(getStore());
}
public static String getRemoteConfig() {
return getStore().getString(REMOTE_CONFIG, null);
}

View File

@@ -61,7 +61,6 @@ public class LinkPreviewRepository {
public LinkPreviewRepository() {
this.client = new OkHttpClient.Builder()
.proxySelector(new ContentProxySelector())
.addInterceptor(new UserAgentInterceptor())
.addNetworkInterceptor(new ContentProxySafetyInterceptor())
.cache(null)
.build();

View File

@@ -6,6 +6,7 @@ import android.graphics.Typeface;
import android.os.AsyncTask;
import android.os.Build;
import android.text.Editable;
import android.text.InputType;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
@@ -26,13 +27,20 @@ import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.app.DialogCompat;
import androidx.fragment.app.Fragment;
import com.google.android.material.textfield.TextInputLayout;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.SwitchPreferenceCompat;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.KbsValues;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
import org.thoughtcrime.securesms.lock.v2.KbsConstants;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.migrations.RegistrationPinV2MigrationJob;
import org.thoughtcrime.securesms.util.FeatureFlags;
@@ -42,8 +50,8 @@ import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
import org.whispersystems.signalservice.api.KeyBackupService;
import org.whispersystems.signalservice.api.KeyBackupServicePinException;
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException;
import org.whispersystems.signalservice.api.RegistrationLockData;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.kbs.HashedPin;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException;
@@ -55,10 +63,9 @@ public final class RegistrationLockDialog {
private static final String TAG = Log.tag(RegistrationLockDialog.class);
private static final int MIN_V2_NUMERIC_PIN_LENGTH_ENTRY = 4;
private static final int MIN_V2_NUMERIC_PIN_LENGTH_SETTING = 4;
public static void showReminderIfNecessary(@NonNull Fragment fragment) {
final Context context = fragment.requireContext();
public static void showReminderIfNecessary(@NonNull Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return;
if (!RegistrationLockReminders.needsReminder(context)) return;
@@ -69,6 +76,86 @@ public final class RegistrationLockDialog {
return;
}
if (FeatureFlags.pinsForAll()) {
showReminder(context, fragment);
} else {
showLegacyPinReminder(context);
}
}
private static void showReminder(@NonNull Context context, @NonNull Fragment fragment) {
AlertDialog dialog = new AlertDialog.Builder(context, ThemeUtil.isDarkTheme(context) ? R.style.RationaleDialogDark_SignalAccent : R.style.RationaleDialogLight_SignalAccent)
.setView(R.layout.kbs_pin_reminder_view)
.setCancelable(false)
.setOnCancelListener(d -> RegistrationLockReminders.scheduleReminder(context, false))
.create();
WindowManager windowManager = ServiceUtil.getWindowManager(context);
Display display = windowManager.getDefaultDisplay();
DisplayMetrics metrics = new DisplayMetrics();
display.getMetrics(metrics);
dialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);
dialog.show();
dialog.getWindow().setLayout((int)(metrics.widthPixels * .80), ViewGroup.LayoutParams.WRAP_CONTENT);
TextInputLayout pinWrapper = (TextInputLayout) DialogCompat.requireViewById(dialog, R.id.pin_wrapper);
EditText pinEditText = (EditText) DialogCompat.requireViewById(dialog, R.id.pin);
TextView reminder = (TextView) DialogCompat.requireViewById(dialog, R.id.reminder);
View skip = DialogCompat.requireViewById(dialog, R.id.skip);
View submit = DialogCompat.requireViewById(dialog, R.id.submit);
SpannableString reminderText = new SpannableString(context.getString(R.string.KbsReminderDialog__to_help_you_memorize_your_pin));
SpannableString forgotText = new SpannableString(context.getString(R.string.KbsReminderDialog__forgot_pin));
pinEditText.requestFocus();
switch (SignalStore.kbsValues().getKeyboardType()) {
case NUMERIC:
pinEditText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
break;
case ALPHA_NUMERIC:
pinEditText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
break;
}
ClickableSpan clickableSpan = new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
dialog.dismiss();
RegistrationLockReminders.scheduleReminder(context, true);
fragment.startActivityForResult(CreateKbsPinActivity.getIntentForPinChangeFromForgotPin(context), CreateKbsPinActivity.REQUEST_NEW_PIN);
}
};
forgotText.setSpan(clickableSpan, 0, forgotText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
reminder.setText(new SpannableStringBuilder(reminderText).append(" ").append(forgotText));
reminder.setMovementMethod(LinkMovementMethod.getInstance());
skip.setOnClickListener(v -> {
dialog.dismiss();
RegistrationLockReminders.scheduleReminder(context, false);
});
PinVerifier.Callback callback = getPinWatcherCallback(context, dialog, pinWrapper);
PinVerifier verifier = SignalStore.kbsValues().isV2RegistrationLockEnabled()
? new V2PinVerifier()
: new V1PinVerifier(context);
submit.setOnClickListener(v -> {
Editable pinEditable = pinEditText.getText();
verifier.verifyPin(pinEditable == null ? null : pinEditable.toString(), callback);
});
}
/**
* @deprecated TODO [alex]: Remove after pins for all live.
*/
@Deprecated
private static void showLegacyPinReminder(@NonNull Context context) {
AlertDialog dialog = new AlertDialog.Builder(context, ThemeUtil.isDarkTheme(context) ? R.style.RationaleDialogDark : R.style.RationaleDialogLight)
.setView(R.layout.registration_lock_reminder_view)
.setCancelable(true)
@@ -84,8 +171,8 @@ public final class RegistrationLockDialog {
dialog.show();
dialog.getWindow().setLayout((int)(metrics.widthPixels * .80), ViewGroup.LayoutParams.WRAP_CONTENT);
EditText pinEditText = dialog.findViewById(R.id.pin);
TextView reminder = dialog.findViewById(R.id.reminder);
EditText pinEditText = (EditText) DialogCompat.requireViewById(dialog, R.id.pin);
TextView reminder = (TextView) DialogCompat.requireViewById(dialog, R.id.reminder);
if (pinEditText == null) throw new AssertionError();
if (reminder == null) throw new AssertionError();
@@ -126,27 +213,23 @@ public final class RegistrationLockDialog {
dialog.dismiss();
RegistrationLockReminders.scheduleReminder(context, true);
if (FeatureFlags.kbs()) {
Log.i(TAG, "Pin V1 successfully remembered, scheduling a migration to V2");
ApplicationDependencies.getJobManager().add(new RegistrationPinV2MigrationJob());
}
Log.i(TAG, "Pin V1 successfully remembered, scheduling a migration to V2");
ApplicationDependencies.getJobManager().add(new RegistrationPinV2MigrationJob());
}
});
}
private static TextWatcher getV2PinWatcher(@NonNull Context context, AlertDialog dialog) {
KbsValues kbsValues = SignalStore.kbsValues();
MasterKey masterKey = kbsValues.getPinBackedMasterKey();
String localPinHash = kbsValues.getLocalPinHash();
if (masterKey == null) throw new AssertionError("No masterKey set at time of reminder");
if (localPinHash == null) throw new AssertionError("No local pin hash set at time of reminder");
return new AfterTextChanged((Editable s) -> {
if (s == null) return;
String pin = s.toString();
if (TextUtils.isEmpty(pin)) return;
if (pin.length() < MIN_V2_NUMERIC_PIN_LENGTH_ENTRY) return;
if (pin.length() < KbsConstants.MINIMUM_POSSIBLE_PIN_LENGTH) return;
if (PinHashing.verifyLocalPinHash(localPinHash, pin)) {
dialog.dismiss();
@@ -178,9 +261,9 @@ public final class RegistrationLockDialog {
String pinValue = pin.getText().toString().replace(" ", "");
String repeatValue = repeat.getText().toString().replace(" ", "");
if (pinValue.length() < MIN_V2_NUMERIC_PIN_LENGTH_SETTING) {
if (pinValue.length() < KbsConstants.MINIMUM_POSSIBLE_PIN_LENGTH) {
Toast.makeText(context,
context.getString(R.string.RegistrationLockDialog_the_registration_lock_pin_must_be_at_least_d_digits, MIN_V2_NUMERIC_PIN_LENGTH_SETTING),
context.getString(R.string.RegistrationLockDialog_the_registration_lock_pin_must_be_at_least_d_digits, KbsConstants.MINIMUM_POSSIBLE_PIN_LENGTH),
Toast.LENGTH_LONG).show();
return;
}
@@ -201,36 +284,29 @@ public final class RegistrationLockDialog {
@Override
protected Boolean doInBackground(Void... voids) {
try {
if (FeatureFlags.kbs()) {
Log.i(TAG, "Setting pin on KBS");
Log.i(TAG, "Setting pin on KBS");
KbsValues kbsValues = SignalStore.kbsValues();
MasterKey masterKey = kbsValues.getOrCreateMasterKey();
KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService();
KeyBackupService.PinChangeSession pinChangeSession = keyBackupService.newPinChangeSession();
HashedPin hashedPin = PinHashing.hashPin(pinValue, pinChangeSession);
RegistrationLockData kbsData = pinChangeSession.setPin(hashedPin, masterKey);
RegistrationLockData restoredData = keyBackupService.newRestoreSession(kbsData.getTokenResponse())
.restorePin(hashedPin);
KbsValues kbsValues = SignalStore.kbsValues();
MasterKey masterKey = kbsValues.getOrCreateMasterKey();
KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService();
KeyBackupService.PinChangeSession pinChangeSession = keyBackupService.newPinChangeSession();
HashedPin hashedPin = PinHashing.hashPin(pinValue, pinChangeSession);
RegistrationLockData kbsData = pinChangeSession.setPin(hashedPin, masterKey);
RegistrationLockData restoredData = keyBackupService.newRestoreSession(kbsData.getTokenResponse())
.restorePin(hashedPin);
if (!restoredData.getMasterKey().equals(masterKey)) {
throw new AssertionError("Failed to set the pin correctly");
} else {
Log.i(TAG, "Set and retrieved pin on KBS successfully");
}
kbsValues.setRegistrationLockMasterKey(restoredData, PinHashing.localPinHash(pinValue));
TextSecurePreferences.clearOldRegistrationLockPin(context);
if (!restoredData.getMasterKey().equals(masterKey)) {
throw new AssertionError("Failed to set the pin correctly");
} else {
Log.i(TAG, "Setting V1 pin");
SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager();
accountManager.setPin(pinValue);
TextSecurePreferences.setDeprecatedRegistrationLockPin(context, pinValue);
Log.i(TAG, "Set and retrieved pin on KBS successfully");
}
kbsValues.setRegistrationLockMasterKey(restoredData, PinHashing.localPinHash(pinValue));
TextSecurePreferences.clearOldRegistrationLockPin(context);
TextSecurePreferences.setRegistrationLockLastReminderTime(context, System.currentTimeMillis());
TextSecurePreferences.setRegistrationLockNextReminderInterval(context, RegistrationLockReminders.INITIAL_INTERVAL);
return true;
} catch (IOException | UnauthenticatedResponseException | KeyBackupServicePinException e) {
} catch (IOException | UnauthenticatedResponseException | KeyBackupServicePinException | KeyBackupSystemNoDataException e) {
Log.w(TAG, e);
return false;
}
@@ -325,4 +401,78 @@ public final class RegistrationLockDialog {
dialog.show();
}
private static PinVerifier.Callback getPinWatcherCallback(@NonNull Context context,
@NonNull AlertDialog dialog,
@NonNull TextInputLayout inputWrapper)
{
return new PinVerifier.Callback() {
@Override
public void onPinCorrect() {
dialog.dismiss();
RegistrationLockReminders.scheduleReminder(context, true);
}
@Override
public void onPinWrong() {
inputWrapper.setError(context.getString(R.string.KbsReminderDialog__incorrect_pin_try_again));
}
};
}
private static final class V1PinVerifier implements PinVerifier {
private final String pinInPreferences;
private V1PinVerifier(@NonNull Context context) {
//noinspection deprecation Acceptable to check the old pin in a reminder on a non-migrated system.
this.pinInPreferences = TextSecurePreferences.getDeprecatedV1RegistrationLockPin(context);
}
@Override
public void verifyPin(@Nullable String pin, @NonNull Callback callback) {
if (pin != null && pin.replace(" ", "").equals(pinInPreferences)) {
callback.onPinCorrect();
Log.i(TAG, "Pin V1 successfully remembered, scheduling a migration to V2");
ApplicationDependencies.getJobManager().add(new RegistrationPinV2MigrationJob());
} else {
callback.onPinWrong();
}
}
}
private static final class V2PinVerifier implements PinVerifier {
private final String localPinHash;
V2PinVerifier() {
localPinHash = SignalStore.kbsValues().getLocalPinHash();
if (localPinHash == null) throw new AssertionError("No local pin hash set at time of reminder");
}
@Override
public void verifyPin(@Nullable String pin, @NonNull Callback callback) {
if (pin == null) return;
if (TextUtils.isEmpty(pin)) return;
if (pin.length() < KbsConstants.MINIMUM_POSSIBLE_PIN_LENGTH) return;
if (PinHashing.verifyLocalPinHash(localPinHash, pin)) {
callback.onPinCorrect();
} else {
callback.onPinWrong();
}
}
}
private interface PinVerifier {
void verifyPin(@Nullable String pin, @NonNull PinVerifier.Callback callback);
interface Callback {
void onPinCorrect();
void onPinWrong();
}
}
}

View File

@@ -35,19 +35,19 @@ public class RegistrationLockReminders {
}
public static void scheduleReminder(@NonNull Context context, boolean success) {
Long nextReminderInterval;
if (success) {
long timeSinceLastReminder = System.currentTimeMillis() - TextSecurePreferences.getRegistrationLockLastReminderTime(context);
nextReminderInterval = INTERVALS.higher(timeSinceLastReminder);
if (nextReminderInterval == null) nextReminderInterval = INTERVALS.last();
} else {
long lastReminderInterval = TextSecurePreferences.getRegistrationLockNextReminderInterval(context);
nextReminderInterval = INTERVALS.lower(lastReminderInterval);
if (nextReminderInterval == null) nextReminderInterval = INTERVALS.first();
}
Long nextReminderInterval = INTERVALS.higher(timeSinceLastReminder);
TextSecurePreferences.setRegistrationLockLastReminderTime(context, System.currentTimeMillis());
TextSecurePreferences.setRegistrationLockNextReminderInterval(context, nextReminderInterval);
if (nextReminderInterval == null) {
nextReminderInterval = INTERVALS.last();
}
TextSecurePreferences.setRegistrationLockLastReminderTime(context, System.currentTimeMillis());
TextSecurePreferences.setRegistrationLockNextReminderInterval(context, nextReminderInterval);
} else {
long timeSinceLastReminder = TextSecurePreferences.getRegistrationLockLastReminderTime(context) + TimeUnit.MINUTES.toMillis(5);
TextSecurePreferences.setRegistrationLockLastReminderTime(context, timeSinceLastReminder);
}
}
}

View File

@@ -0,0 +1,149 @@
package org.thoughtcrime.securesms.lock.v2;
import android.os.Bundle;
import android.text.InputType;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.fragment.app.Fragment;
import com.airbnb.lottie.LottieAnimationView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
abstract class BaseKbsPinFragment<ViewModel extends BaseKbsPinViewModel> extends Fragment {
private TextView title;
private TextView description;
private EditText input;
private TextView label;
private TextView keyboardToggle;
private TextView confirm;
private LottieAnimationView lottieProgress;
private LottieAnimationView lottieEnd;
private ViewModel viewModel;
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState)
{
return inflater.inflate(R.layout.base_kbs_pin_fragment, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
initializeViews(view);
viewModel = initializeViewModel();
viewModel.getUserEntry().observe(getViewLifecycleOwner(), kbsPin -> {
boolean isEntryValid = kbsPin.length() >= KbsConstants.MINIMUM_NEW_PIN_LENGTH;
confirm.setEnabled(isEntryValid);
confirm.setAlpha(isEntryValid ? 1f : 0.5f);
});
viewModel.getKeyboard().observe(getViewLifecycleOwner(), keyboardType -> {
updateKeyboard(keyboardType);
keyboardToggle.setText(resolveKeyboardToggleText(keyboardType));
});
initializeListeners();
}
@Override
public void onResume() {
super.onResume();
input.requestFocus();
}
protected abstract ViewModel initializeViewModel();
protected abstract void initializeViewStates();
protected TextView getTitle() {
return title;
}
protected TextView getDescription() {
return description;
}
protected EditText getInput() {
return input;
}
protected LottieAnimationView getLottieProgress() {
return lottieProgress;
}
protected LottieAnimationView getLottieEnd() {
return lottieEnd;
}
protected TextView getLabel() {
return label;
}
protected TextView getKeyboardToggle() {
return keyboardToggle;
}
protected TextView getConfirm() {
return confirm;
}
private void initializeViews(@NonNull View view) {
title = view.findViewById(R.id.edit_kbs_pin_title);
description = view.findViewById(R.id.edit_kbs_pin_description);
input = view.findViewById(R.id.edit_kbs_pin_input);
label = view.findViewById(R.id.edit_kbs_pin_input_label);
keyboardToggle = view.findViewById(R.id.edit_kbs_pin_keyboard_toggle);
confirm = view.findViewById(R.id.edit_kbs_pin_confirm);
lottieProgress = view.findViewById(R.id.edit_kbs_pin_lottie_progress);
lottieEnd = view.findViewById(R.id.edit_kbs_pin_lottie_end);
initializeViewStates();
}
private void initializeListeners() {
input.addTextChangedListener(new AfterTextChanged(s -> viewModel.setUserEntry(s.toString())));
input.setImeOptions(EditorInfo.IME_ACTION_NEXT);
input.setOnEditorActionListener(this::handleEditorAction);
keyboardToggle.setOnClickListener(v -> viewModel.toggleAlphaNumeric());
confirm.setOnClickListener(v -> viewModel.confirm());
}
private boolean handleEditorAction(@NonNull View view, int actionId, @NonNull KeyEvent event) {
if (actionId == EditorInfo.IME_ACTION_NEXT && confirm.isEnabled()) {
viewModel.confirm();
}
return true;
}
private void updateKeyboard(@NonNull PinKeyboardType keyboard) {
boolean isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC;
input.setInputType(isAlphaNumeric ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD
: InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
}
private @StringRes int resolveKeyboardToggleText(@NonNull PinKeyboardType keyboard) {
if (keyboard == PinKeyboardType.ALPHA_NUMERIC) {
return R.string.BaseKbsPinFragment__create_numeric_pin;
} else {
return R.string.BaseKbsPinFragment__create_alphanumeric_pin;
}
}
}

View File

@@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.lock.v2;
import androidx.annotation.MainThread;
import androidx.lifecycle.LiveData;
interface BaseKbsPinViewModel {
LiveData<KbsPin> getUserEntry();
LiveData<PinKeyboardType> getKeyboard();
@MainThread
void setUserEntry(String userEntry);
@MainThread
void toggleAlphaNumeric();
@MainThread
void confirm();
}

View File

@@ -0,0 +1,187 @@
package org.thoughtcrime.securesms.lock.v2;
import android.animation.Animator;
import android.app.Activity;
import android.content.Intent;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.RawRes;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import androidx.core.util.Preconditions;
import androidx.lifecycle.ViewModelProviders;
import com.airbnb.lottie.LottieAnimationView;
import com.airbnb.lottie.LottieDrawable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.animation.AnimationRepeatListener;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.megaphone.Megaphones;
import org.thoughtcrime.securesms.util.SpanUtil;
public class ConfirmKbsPinFragment extends BaseKbsPinFragment<ConfirmKbsPinViewModel> {
private ConfirmKbsPinViewModel viewModel;
@Override
protected void initializeViewStates() {
ConfirmKbsPinFragmentArgs args = ConfirmKbsPinFragmentArgs.fromBundle(requireArguments());
if (args.getIsPinChange()) {
initializeViewStatesForPinChange();
} else {
initializeViewStatesForPinCreate();
}
}
@Override
protected ConfirmKbsPinViewModel initializeViewModel() {
ConfirmKbsPinFragmentArgs args = ConfirmKbsPinFragmentArgs.fromBundle(requireArguments());
KbsPin userEntry = Preconditions.checkNotNull(args.getUserEntry());
PinKeyboardType keyboard = args.getKeyboard();
ConfirmKbsPinRepository repository = new ConfirmKbsPinRepository();
ConfirmKbsPinViewModel.Factory factory = new ConfirmKbsPinViewModel.Factory(userEntry, keyboard, repository);
viewModel = ViewModelProviders.of(this, factory).get(ConfirmKbsPinViewModel.class);
viewModel.getLabel().observe(getViewLifecycleOwner(), this::updateLabel);
viewModel.getSaveAnimation().observe(getViewLifecycleOwner(), this::updateSaveAnimation);
return viewModel;
}
private void initializeViewStatesForPinCreate() {
getTitle().setText(R.string.CreateKbsPinFragment__create_your_pin);
getDescription().setText(R.string.ConfirmKbsPinFragment__confirm_your_pin);
getKeyboardToggle().setVisibility(View.INVISIBLE);
getLabel().setText("");
}
private void initializeViewStatesForPinChange() {
getTitle().setText(R.string.CreateKbsPinFragment__create_a_new_pin);
getDescription().setText(R.string.ConfirmKbsPinFragment__confirm_your_pin);
getKeyboardToggle().setVisibility(View.INVISIBLE);
getLabel().setText("");
}
private void updateLabel(@NonNull ConfirmKbsPinViewModel.Label label) {
switch (label) {
case EMPTY:
getLabel().setText("");
break;
case CREATING_PIN:
getLabel().setText(R.string.ConfirmKbsPinFragment__creating_pin);
break;
case RE_ENTER_PIN:
getLabel().setText(R.string.ConfirmKbsPinFragment__re_enter_pin);
break;
case PIN_DOES_NOT_MATCH:
getLabel().setText(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.red),
getString(R.string.ConfirmKbsPinFragment__pins_dont_match)));
getInput().getText().clear();
break;
}
}
private void updateSaveAnimation(@NonNull ConfirmKbsPinViewModel.SaveAnimation animation) {
updateAnimationAndInputVisibility(animation);
LottieAnimationView lottieProgress = getLottieProgress();
switch (animation) {
case NONE:
lottieProgress.cancelAnimation();
break;
case LOADING:
lottieProgress.setAnimation(R.raw.lottie_kbs_loading);
lottieProgress.setRepeatMode(LottieDrawable.RESTART);
lottieProgress.setRepeatCount(LottieDrawable.INFINITE);
lottieProgress.playAnimation();
break;
case SUCCESS:
startEndAnimationOnNextProgressRepetition(R.raw.lottie_kbs_success, new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
requireActivity().setResult(Activity.RESULT_OK);
closeNavGraphBranch();
}
});
break;
case FAILURE:
startEndAnimationOnNextProgressRepetition(R.raw.lottie_kbs_failure, new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
displayFailedDialog();
}
});
break;
}
}
private void startEndAnimationOnNextProgressRepetition(@RawRes int lottieAnimationId,
@NonNull AnimationCompleteListener listener)
{
LottieAnimationView lottieProgress = getLottieProgress();
LottieAnimationView lottieEnd = getLottieEnd();
lottieEnd.setAnimation(lottieAnimationId);
lottieEnd.removeAllAnimatorListeners();
lottieEnd.setRepeatCount(0);
lottieEnd.addAnimatorListener(listener);
if (lottieProgress.isAnimating()) {
lottieProgress.addAnimatorListener(new AnimationRepeatListener(animator ->
hideProgressAndStartEndAnimation(lottieProgress, lottieEnd)
));
} else {
hideProgressAndStartEndAnimation(lottieProgress, lottieEnd);
}
}
private void hideProgressAndStartEndAnimation(@NonNull LottieAnimationView lottieProgress,
@NonNull LottieAnimationView lottieEnd)
{
viewModel.onLoadingAnimationComplete();
lottieProgress.setVisibility(View.GONE);
lottieEnd.setVisibility(View.VISIBLE);
lottieEnd.playAnimation();
}
private void updateAnimationAndInputVisibility(ConfirmKbsPinViewModel.SaveAnimation saveAnimation) {
if (saveAnimation == ConfirmKbsPinViewModel.SaveAnimation.NONE) {
getInput().setVisibility(View.VISIBLE);
getLottieProgress().setVisibility(View.GONE);
} else {
getInput().setVisibility(View.GONE);
getLottieProgress().setVisibility(View.VISIBLE);
}
}
private void displayFailedDialog() {
new AlertDialog.Builder(requireContext()).setTitle(R.string.ConfirmKbsPinFragment__pin_creation_failed)
.setMessage(R.string.ConfirmKbsPinFragment__your_pin_was_not_saved)
.setCancelable(false)
.setPositiveButton(R.string.ok, (d, w) -> {
d.dismiss();
markMegaphoneSeenIfNecessary();
requireActivity().setResult(Activity.RESULT_CANCELED);
closeNavGraphBranch();
})
.show();
}
private void closeNavGraphBranch() {
Intent activityIntent = requireActivity().getIntent();
if (activityIntent != null && activityIntent.hasExtra("next_intent")) {
startActivity(activityIntent.getParcelableExtra("next_intent"));
}
requireActivity().finish();
}
private void markMegaphoneSeenIfNecessary() {
ApplicationDependencies.getMegaphoneRepository().markSeen(Megaphones.Event.PINS_FOR_ALL);
}
}

View File

@@ -0,0 +1,74 @@
package org.thoughtcrime.securesms.lock.v2;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.core.util.Consumer;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.KbsValues;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.lock.PinHashing;
import org.thoughtcrime.securesms.lock.RegistrationLockReminders;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.megaphone.Megaphones;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.whispersystems.signalservice.api.KeyBackupService;
import org.whispersystems.signalservice.api.KeyBackupServicePinException;
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException;
import org.whispersystems.signalservice.api.RegistrationLockData;
import org.whispersystems.signalservice.api.kbs.HashedPin;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException;
import java.io.IOException;
final class ConfirmKbsPinRepository {
private static final String TAG = Log.tag(ConfirmKbsPinRepository.class);
void setPin(@NonNull KbsPin kbsPin, @NonNull PinKeyboardType keyboard, @NonNull Consumer<PinSetResult> resultConsumer) {
Context context = ApplicationDependencies.getApplication();
String pinValue = kbsPin.toString();
SimpleTask.run(() -> {
try {
Log.i(TAG, "Setting pin on KBS");
KbsValues kbsValues = SignalStore.kbsValues();
MasterKey masterKey = kbsValues.getOrCreateMasterKey();
KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService();
KeyBackupService.PinChangeSession pinChangeSession = keyBackupService.newPinChangeSession();
HashedPin hashedPin = PinHashing.hashPin(pinValue, pinChangeSession);
RegistrationLockData kbsData = pinChangeSession.setPin(hashedPin, masterKey);
RegistrationLockData restoredData = keyBackupService.newRestoreSession(kbsData.getTokenResponse())
.restorePin(hashedPin);
if (!restoredData.getMasterKey().equals(masterKey)) {
throw new AssertionError("Failed to set the pin correctly");
} else {
Log.i(TAG, "Set and retrieved pin on KBS successfully");
}
kbsValues.setRegistrationLockMasterKey(restoredData, PinHashing.localPinHash(pinValue));
TextSecurePreferences.clearOldRegistrationLockPin(context);
TextSecurePreferences.setRegistrationLockLastReminderTime(context, System.currentTimeMillis());
TextSecurePreferences.setRegistrationLockNextReminderInterval(context, RegistrationLockReminders.INITIAL_INTERVAL);
SignalStore.kbsValues().setKeyboardType(keyboard);
ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.PINS_FOR_ALL);
return PinSetResult.SUCCESS;
} catch (IOException | UnauthenticatedResponseException | KeyBackupServicePinException | KeyBackupSystemNoDataException e) {
Log.w(TAG, e);
return PinSetResult.FAILURE;
}
}, resultConsumer::accept);
}
enum PinSetResult {
SUCCESS,
FAILURE
}
}

View File

@@ -0,0 +1,130 @@
package org.thoughtcrime.securesms.lock.v2;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.core.util.Preconditions;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.lock.v2.ConfirmKbsPinRepository.PinSetResult;
final class ConfirmKbsPinViewModel extends ViewModel implements BaseKbsPinViewModel {
private final ConfirmKbsPinRepository repository;
private final MutableLiveData<KbsPin> userEntry = new MutableLiveData<>(KbsPin.EMPTY);
private final MutableLiveData<PinKeyboardType> keyboard = new MutableLiveData<>(PinKeyboardType.NUMERIC);
private final MutableLiveData<SaveAnimation> saveAnimation = new MutableLiveData<>(SaveAnimation.NONE);
private final MutableLiveData<Label> label = new MutableLiveData<>(Label.RE_ENTER_PIN);
private final KbsPin pinToConfirm;
private ConfirmKbsPinViewModel(@NonNull KbsPin pinToConfirm,
@NonNull PinKeyboardType keyboard,
@NonNull ConfirmKbsPinRepository repository)
{
this.keyboard.setValue(keyboard);
this.pinToConfirm = pinToConfirm;
this.repository = repository;
}
LiveData<SaveAnimation> getSaveAnimation() {
return Transformations.distinctUntilChanged(saveAnimation);
}
LiveData<Label> getLabel() {
return Transformations.distinctUntilChanged(label);
}
@Override
public void confirm() {
KbsPin userEntry = this.userEntry.getValue();
this.userEntry.setValue(KbsPin.EMPTY);
if (pinToConfirm.toString().equals(userEntry.toString())) {
this.label.setValue(Label.CREATING_PIN);
this.saveAnimation.setValue(SaveAnimation.LOADING);
repository.setPin(pinToConfirm, Preconditions.checkNotNull(this.keyboard.getValue()), this::handleResult);
} else {
this.label.setValue(Label.PIN_DOES_NOT_MATCH);
}
}
void onLoadingAnimationComplete() {
this.label.setValue(Label.EMPTY);
}
@Override
public LiveData<KbsPin> getUserEntry() {
return userEntry;
}
@Override
public LiveData<PinKeyboardType> getKeyboard() {
return keyboard;
}
@MainThread
public void setUserEntry(String userEntry) {
this.userEntry.setValue(KbsPin.from(userEntry));
}
@MainThread
public void toggleAlphaNumeric() {
this.keyboard.setValue(this.keyboard.getValue().getOther());
}
private void handleResult(PinSetResult result) {
switch (result) {
case SUCCESS:
this.saveAnimation.setValue(SaveAnimation.SUCCESS);
break;
case FAILURE:
this.saveAnimation.setValue(SaveAnimation.FAILURE);
break;
default:
throw new IllegalStateException("Unknown state: " + result.name());
}
}
enum Label {
RE_ENTER_PIN,
PIN_DOES_NOT_MATCH,
CREATING_PIN,
EMPTY
}
enum SaveAnimation {
NONE,
LOADING,
SUCCESS,
FAILURE
}
static final class Factory implements ViewModelProvider.Factory {
private final KbsPin pinToConfirm;
private final PinKeyboardType keyboard;
private final ConfirmKbsPinRepository repository;
Factory(@NonNull KbsPin pinToConfirm,
@NonNull PinKeyboardType keyboard,
@NonNull ConfirmKbsPinRepository repository)
{
this.pinToConfirm = pinToConfirm;
this.keyboard = keyboard;
this.repository = repository;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection unchecked
return (T) new ConfirmKbsPinViewModel(pinToConfirm, keyboard, repository);
}
}
}

View File

@@ -0,0 +1,92 @@
package org.thoughtcrime.securesms.lock.v2;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.navigation.NavGraph;
import androidx.navigation.Navigation;
import org.thoughtcrime.securesms.BaseActionBarActivity;
import org.thoughtcrime.securesms.PassphrasePromptActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.DynamicRegistrationTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
public class CreateKbsPinActivity extends BaseActionBarActivity {
public static final int REQUEST_NEW_PIN = 27698;
private final DynamicTheme dynamicTheme = new DynamicRegistrationTheme();
public static @NonNull Intent getIntentForPinCreate(@NonNull Context context) {
CreateKbsPinFragmentArgs args = new CreateKbsPinFragmentArgs.Builder()
.setIsForgotPin(false)
.setIsPinChange(false)
.build();
return getIntentWithArgs(context, args);
}
public static @NonNull Intent getIntentForPinChangeFromForgotPin(@NonNull Context context) {
CreateKbsPinFragmentArgs args = new CreateKbsPinFragmentArgs.Builder()
.setIsForgotPin(true)
.setIsPinChange(true)
.build();
return getIntentWithArgs(context, args);
}
public static @NonNull Intent getIntentForPinChangeFromSettings(@NonNull Context context) {
CreateKbsPinFragmentArgs args = new CreateKbsPinFragmentArgs.Builder()
.setIsForgotPin(false)
.setIsPinChange(true)
.build();
return getIntentWithArgs(context, args);
}
private static @NonNull Intent getIntentWithArgs(@NonNull Context context, @NonNull CreateKbsPinFragmentArgs args) {
return new Intent(context, CreateKbsPinActivity.class).putExtras(args.toBundle());
}
@Override
public void onCreate(Bundle bundle) {
super.onCreate(bundle);
if (KeyCachingService.isLocked(this)) {
startActivity(getPromptPassphraseIntent());
finish();
return;
}
dynamicTheme.onCreate(this);
setContentView(R.layout.create_kbs_pin_activity);
CreateKbsPinFragmentArgs arguments = CreateKbsPinFragmentArgs.fromBundle(getIntent().getExtras());
NavGraph graph = Navigation.findNavController(this, R.id.nav_host_fragment).getGraph();
Navigation.findNavController(this, R.id.nav_host_fragment).setGraph(graph, arguments.toBundle());
}
@Override
public void onResume() {
super.onResume();
dynamicTheme.onResume(this);
}
private Intent getPromptPassphraseIntent() {
return getRoutedIntent(PassphrasePromptActivity.class, getIntent());
}
private Intent getRoutedIntent(Class<?> destination, @Nullable Intent nextIntent) {
final Intent intent = new Intent(this, destination);
if (nextIntent != null) intent.putExtra("next_intent", nextIntent);
return intent;
}
}

View File

@@ -0,0 +1,82 @@
package org.thoughtcrime.securesms.lock.v2;
import androidx.annotation.NonNull;
import androidx.annotation.PluralsRes;
import androidx.lifecycle.ViewModelProviders;
import androidx.navigation.Navigation;
import org.thoughtcrime.securesms.R;
public class CreateKbsPinFragment extends BaseKbsPinFragment<CreateKbsPinViewModel> {
private static final int PIN_LOCKOUT_DAYS = 7;
@Override
protected void initializeViewStates() {
CreateKbsPinFragmentArgs args = CreateKbsPinFragmentArgs.fromBundle(requireArguments());
if (args.getIsPinChange()) {
initializeViewStatesForPinChange(args.getIsForgotPin());
} else {
initializeViewStatesForPinCreate();
}
getLabel().setText(getPinLengthRestrictionText(R.plurals.CreateKbsPinFragment__pin_must_be_at_least_digits));
getConfirm().setEnabled(false);
}
private void initializeViewStatesForPinChange(boolean isForgotPin) {
getTitle().setText(R.string.CreateKbsPinFragment__create_a_new_pin);
if (isForgotPin) {
getDescription().setText(requireContext().getResources()
.getQuantityString(R.plurals.CreateKbsPinFragment__you_can_choose_a_new_pin_because_this_device_is_registered,
PIN_LOCKOUT_DAYS,
PIN_LOCKOUT_DAYS));
} else {
getDescription().setText(R.string.CreateKbsPinFragment__pins_add_an_extra_layer_of_security);
}
}
private void initializeViewStatesForPinCreate() {
getTitle().setText(R.string.CreateKbsPinFragment__create_your_pin);
getDescription().setText(R.string.CreateKbsPinFragment__pins_add_an_extra_layer_of_security);
}
@Override
protected CreateKbsPinViewModel initializeViewModel() {
CreateKbsPinViewModel viewModel = ViewModelProviders.of(this).get(CreateKbsPinViewModel.class);
CreateKbsPinFragmentArgs args = CreateKbsPinFragmentArgs.fromBundle(requireArguments());
viewModel.getNavigationEvents().observe(getViewLifecycleOwner(), e -> onConfirmPin(e.getUserEntry(), e.getKeyboard(), args.getIsPinChange()));
viewModel.getKeyboard().observe(getViewLifecycleOwner(), k -> {
getLabel().setText(getLabelText(k));
getInput().getText().clear();
});
return viewModel;
}
private void onConfirmPin(@NonNull KbsPin userEntry, @NonNull PinKeyboardType keyboard, boolean isPinChange) {
CreateKbsPinFragmentDirections.ActionConfirmPin action = CreateKbsPinFragmentDirections.actionConfirmPin();
action.setUserEntry(userEntry);
action.setKeyboard(keyboard);
action.setIsPinChange(isPinChange);
Navigation.findNavController(requireView()).navigate(action);
}
private String getLabelText(@NonNull PinKeyboardType keyboard) {
if (keyboard == PinKeyboardType.ALPHA_NUMERIC) {
return getPinLengthRestrictionText(R.plurals.CreateKbsPinFragment__pin_must_be_at_least_characters);
} else {
return getPinLengthRestrictionText(R.plurals.CreateKbsPinFragment__pin_must_be_at_least_digits);
}
}
private String getPinLengthRestrictionText(@PluralsRes int plurals) {
return requireContext().getResources().getQuantityString(plurals, KbsConstants.MINIMUM_NEW_PIN_LENGTH, KbsConstants.MINIMUM_NEW_PIN_LENGTH);
}
}

View File

@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.lock.v2;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.core.util.Preconditions;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
public final class CreateKbsPinViewModel extends ViewModel implements BaseKbsPinViewModel {
private final MutableLiveData<KbsPin> userEntry = new MutableLiveData<>(KbsPin.EMPTY);
private final MutableLiveData<PinKeyboardType> keyboard = new MutableLiveData<>(PinKeyboardType.NUMERIC);
private final SingleLiveEvent<NavigationEvent> events = new SingleLiveEvent<>();
@Override
public LiveData<KbsPin> getUserEntry() {
return userEntry;
}
@Override
public LiveData<PinKeyboardType> getKeyboard() {
return keyboard;
}
LiveData<NavigationEvent> getNavigationEvents() { return events; }
@Override
@MainThread
public void setUserEntry(String userEntry) {
this.userEntry.setValue(KbsPin.from(userEntry));
}
@Override
@MainThread
public void toggleAlphaNumeric() {
this.keyboard.setValue(Preconditions.checkNotNull(this.keyboard.getValue()).getOther());
}
@Override
@MainThread
public void confirm() {
events.setValue(new NavigationEvent(Preconditions.checkNotNull(this.getUserEntry().getValue()),
Preconditions.checkNotNull(this.getKeyboard().getValue())));
}
static final class NavigationEvent {
private final KbsPin userEntry;
private final PinKeyboardType keyboard;
NavigationEvent(@NonNull KbsPin userEntry, @NonNull PinKeyboardType keyboard) {
this.userEntry = userEntry;
this.keyboard = keyboard;
}
KbsPin getUserEntry() {
return userEntry;
}
PinKeyboardType getKeyboard() {
return keyboard;
}
}
}

View File

@@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.lock.v2;
public final class KbsConstants {
static final int MINIMUM_NEW_PIN_LENGTH = 6;
/** Migrated pins from V1 might be 4 */
public static final int MINIMUM_POSSIBLE_PIN_LENGTH = 4;
private KbsConstants() {
}
}

View File

@@ -0,0 +1,56 @@
package org.thoughtcrime.securesms.lock.v2;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.BaseActionBarActivity;
import org.thoughtcrime.securesms.PassphrasePromptActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.DynamicRegistrationTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
public class KbsMigrationActivity extends BaseActionBarActivity {
public static final int REQUEST_NEW_PIN = CreateKbsPinActivity.REQUEST_NEW_PIN;
private final DynamicTheme dynamicTheme = new DynamicRegistrationTheme();
public static Intent createIntent() {
return new Intent(ApplicationDependencies.getApplication(), KbsMigrationActivity.class);
}
@Override
public void onCreate(Bundle bundle) {
super.onCreate(bundle);
if (KeyCachingService.isLocked(this)) {
startActivity(getPromptPassphraseIntent());
finish();
return;
}
dynamicTheme.onCreate(this);
setContentView(R.layout.kbs_migration_activity);
}
@Override
public void onResume() {
super.onResume();
dynamicTheme.onResume(this);
}
private Intent getPromptPassphraseIntent() {
return getRoutedIntent(PassphrasePromptActivity.class, getIntent());
}
private Intent getRoutedIntent(Class<?> destination, @Nullable Intent nextIntent) {
final Intent intent = new Intent(this, destination);
if (nextIntent != null) intent.putExtra("next_intent", nextIntent);
return intent;
}
}

View File

@@ -0,0 +1,63 @@
package org.thoughtcrime.securesms.lock.v2;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public final class KbsPin implements Parcelable {
public static KbsPin EMPTY = new KbsPin("");
private final String pin;
private KbsPin(String pin) {
this.pin = pin;
}
private KbsPin(Parcel in) {
pin = in.readString();
}
@Override
public @NonNull String toString() {
return pin;
}
public static KbsPin from(@Nullable String pin) {
if (pin == null) return EMPTY;
pin = pin.trim();
if (pin.length() == 0) return EMPTY;
return new KbsPin(pin);
}
public int length() {
return pin.length();
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(pin);
}
public static final Creator<KbsPin> CREATOR = new Creator<KbsPin>() {
@Override
public KbsPin createFromParcel(Parcel in) {
return new KbsPin(in);
}
@Override
public KbsPin[] newArray(int size) {
return new KbsPin[size];
}
};
}

View File

@@ -0,0 +1,88 @@
package org.thoughtcrime.securesms.lock.v2;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.text.method.LinkMovementMethod;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.navigation.Navigation;
import org.thoughtcrime.securesms.R;
public final class KbsSplashFragment extends Fragment {
private TextView title;
private TextView description;
private TextView primaryAction;
private TextView secondaryAction;
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState)
{
return inflater.inflate(R.layout.kbs_splash_fragment, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
title = view.findViewById(R.id.kbs_splash_title);
description = view.findViewById(R.id.kbs_splash_description);
primaryAction = view.findViewById(R.id.kbs_splash_primary_action);
secondaryAction = view.findViewById(R.id.kbs_splash_secondary_action);
primaryAction.setOnClickListener(v -> onCreatePin());
secondaryAction.setOnClickListener(v -> onLearnMore());
if (PinUtil.userHasPin(requireContext())) {
setUpRegLockEnabled();
} else {
setUpRegLockDisabled();
}
description.setMovementMethod(LinkMovementMethod.getInstance());
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() { }
});
}
private void setUpRegLockEnabled() {
title.setText(R.string.KbsSplashFragment__registration_lock_equals_pin);
description.setText(R.string.KbsSplashFragment__your_registration_lock_is_now_called_a_pin);
primaryAction.setText(R.string.KbsSplashFragment__update_pin);
secondaryAction.setText(R.string.KbsSplashFragment__learn_more);
}
private void setUpRegLockDisabled() {
title.setText(R.string.KbsSplashFragment__introducing_pins);
description.setText(R.string.KbsSplashFragment__pins_add_another_level_of_security_to_your_account);
primaryAction.setText(R.string.KbsSplashFragment__create_your_pin);
secondaryAction.setText(R.string.KbsSplashFragment__learn_more);
}
private void onCreatePin() {
KbsSplashFragmentDirections.ActionCreateKbsPin action = KbsSplashFragmentDirections.actionCreateKbsPin();
action.setIsPinChange(PinUtil.userHasPin(requireContext()));
Navigation.findNavController(requireView()).navigate(action);
}
private void onLearnMore() {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse(getString(R.string.KbsSplashFragment__learn_more_link)));
startActivity(intent);
}
}

View File

@@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.lock.v2;
import androidx.annotation.Nullable;
public enum PinKeyboardType {
NUMERIC("numeric"),
ALPHA_NUMERIC("alphaNumeric");
private final String code;
PinKeyboardType(String code) {
this.code = code;
}
public PinKeyboardType getOther() {
if (this == NUMERIC) return ALPHA_NUMERIC;
else return NUMERIC;
}
public String getCode() {
return code;
}
public static PinKeyboardType fromCode(@Nullable String code) {
for (PinKeyboardType type : PinKeyboardType.values()) {
if (type.code.equals(code)) {
return type;
}
}
return NUMERIC;
}
}

View File

@@ -0,0 +1,18 @@
package org.thoughtcrime.securesms.lock.v2;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
public final class PinUtil {
private PinUtil() {}
public static boolean userHasPin(@NonNull Context context) {
return TextSecurePreferences.isV1RegistrationLockEnabled(context) || SignalStore.kbsValues().isV2RegistrationLockEnabled();
}
}

View File

@@ -110,6 +110,7 @@ public class MediaPreviewViewModel extends ViewModel {
mediaRecord.getAttachment().getWidth(),
mediaRecord.getAttachment().getHeight(),
mediaRecord.getAttachment().getSize(),
0,
Optional.absent(),
Optional.fromNullable(mediaRecord.getAttachment().getCaption()));
}

View File

@@ -75,6 +75,12 @@ public final class LegacyCameraModels {
add("COL-AL10");
add("COL-L29");
add("COL-L19");
// Samsung Galaxy S6
add("SM-G920F");
// Honor View 10
add("BLK-L09");
}};
private LegacyCameraModels() {

View File

@@ -20,17 +20,28 @@ public class Media implements Parcelable {
private final int width;
private final int height;
private final long size;
private final long duration;
private Optional<String> bucketId;
private Optional<String> caption;
public Media(@NonNull Uri uri, @NonNull String mimeType, long date, int width, int height, long size, Optional<String> bucketId, Optional<String> caption) {
public Media(@NonNull Uri uri,
@NonNull String mimeType,
long date,
int width,
int height,
long size,
long duration,
Optional<String> bucketId,
Optional<String> caption)
{
this.uri = uri;
this.mimeType = mimeType;
this.date = date;
this.width = width;
this.height = height;
this.size = size;
this.duration = duration;
this.bucketId = bucketId;
this.caption = caption;
}
@@ -42,6 +53,7 @@ public class Media implements Parcelable {
width = in.readInt();
height = in.readInt();
size = in.readLong();
duration = in.readLong();
bucketId = Optional.fromNullable(in.readString());
caption = Optional.fromNullable(in.readString());
}
@@ -70,6 +82,10 @@ public class Media implements Parcelable {
return size;
}
public long getDuration() {
return duration;
}
public Optional<String> getBucketId() {
return bucketId;
}
@@ -95,6 +111,7 @@ public class Media implements Parcelable {
dest.writeInt(width);
dest.writeInt(height);
dest.writeLong(size);
dest.writeLong(duration);
dest.writeString(bucketId.orNull());
dest.writeString(caption.orNull());
}

View File

@@ -42,7 +42,7 @@ import java.util.Map;
/**
* Handles the retrieval of media present on the user's device.
*/
class MediaRepository {
public class MediaRepository {
private static final String TAG = Log.tag(MediaRepository.class);
@@ -56,7 +56,7 @@ class MediaRepository {
/**
* Retrieves a list of media items (images and videos) that are present int he specified bucket.
*/
void getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Callback<List<Media>> callback) {
public void getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Callback<List<Media>> callback) {
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getMediaInBucket(context, bucketId)));
}
@@ -141,9 +141,9 @@ class MediaRepository {
long thumbnailTimestamp = 0;
Map<String, FolderData> folders = new HashMap<>();
String[] projection = new String[] { Images.Media.DATA, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_TAKEN };
String[] projection = new String[] { Images.Media.DATA, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED };
String selection = Images.Media.DATA + " NOT NULL";
String sortBy = Images.Media.BUCKET_DISPLAY_NAME + " COLLATE NOCASE ASC, " + Images.Media.DATE_TAKEN + " DESC";
String sortBy = Images.Media.BUCKET_DISPLAY_NAME + " COLLATE NOCASE ASC, " + Images.Media.DATE_MODIFIED + " DESC";
try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, null, sortBy)) {
while (cursor != null && cursor.moveToNext()) {
@@ -189,18 +189,18 @@ class MediaRepository {
}
@WorkerThread
private @NonNull List<Media> getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri, boolean hasOrientation) {
private @NonNull List<Media> getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri, boolean isImage) {
List<Media> media = new LinkedList<>();
String selection = Images.Media.BUCKET_ID + " = ? AND " + Images.Media.DATA + " NOT NULL";
String[] selectionArgs = new String[] { bucketId };
String sortBy = Images.Media.DATE_TAKEN + " DESC";
String sortBy = Images.Media.DATE_MODIFIED + " DESC";
String[] projection;
if (hasOrientation) {
projection = new String[]{Images.Media.DATA, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.ORIENTATION, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE};
if (isImage) {
projection = new String[]{Images.Media.DATA, Images.Media.MIME_TYPE, Images.Media.DATE_MODIFIED, Images.Media.ORIENTATION, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE};
} else {
projection = new String[]{Images.Media.DATA, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE};
projection = new String[]{Images.Media.DATA, Images.Media.MIME_TYPE, Images.Media.DATE_MODIFIED, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE, Video.Media.DURATION};
}
if (Media.ALL_MEDIA_BUCKET_ID.equals(bucketId)) {
@@ -213,13 +213,14 @@ class MediaRepository {
String path = cursor.getString(cursor.getColumnIndexOrThrow(projection[0]));
Uri uri = Uri.fromFile(new File(path));
String mimetype = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.MIME_TYPE));
long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_TAKEN));
int orientation = hasOrientation ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0;
long date = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED));
int orientation = isImage ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0;
int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)));
int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)));
long size = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.SIZE));
long duration = !isImage ? cursor.getInt(cursor.getColumnIndexOrThrow(Video.Media.DURATION)) : 0;
media.add(new Media(uri, mimetype, dateTaken, width, height, size, Optional.of(bucketId), Optional.absent()));
media.add(new Media(uri, mimetype, date, width, height, size, duration, Optional.of(bucketId), Optional.absent()));
}
}
@@ -268,7 +269,7 @@ class MediaRepository {
.withMimeType(MediaUtil.IMAGE_JPEG)
.createForSingleSessionOnDisk(context);
Media updated = new Media(uri, MediaUtil.IMAGE_JPEG, media.getDate(), bitmap.getWidth(), bitmap.getHeight(), outputStream.size(), media.getBucketId(), media.getCaption());
Media updated = new Media(uri, MediaUtil.IMAGE_JPEG, media.getDate(), bitmap.getWidth(), bitmap.getHeight(), outputStream.size(), 0, media.getBucketId(), media.getCaption());
updatedMedia.put(media, updated);
} catch (IOException e) {
@@ -332,7 +333,7 @@ class MediaRepository {
height = dimens.second;
}
return new Media(media.getUri(), media.getMimeType(), media.getDate(), width, height, size, media.getBucketId(), media.getCaption());
return new Media(media.getUri(), media.getMimeType(), media.getDate(), width, height, size, 0, media.getBucketId(), media.getCaption());
}
private Media getContentResolverPopulatedMedia(@NonNull Context context, @NonNull Media media) throws IOException {
@@ -358,7 +359,7 @@ class MediaRepository {
height = dimens.second;
}
return new Media(media.getUri(), media.getMimeType(), media.getDate(), width, height, size, media.getBucketId(), media.getCaption());
return new Media(media.getUri(), media.getMimeType(), media.getDate(), width, height, size, 0, media.getBucketId(), media.getCaption());
}
private static class FolderResult {
@@ -433,7 +434,7 @@ class MediaRepository {
}
}
interface Callback<E> {
public interface Callback<E> {
void onComplete(@NonNull E result);
}
}

View File

@@ -412,6 +412,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
width,
height,
length,
0,
Optional.of(Media.ALL_MEDIA_BUCKET_ID),
Optional.absent()
);

View File

@@ -303,10 +303,11 @@ class MediaSendViewModel extends ViewModel {
captionVisible = false;
List<Media> uncaptioned = Stream.of(getSelectedMediaOrDefault())
.map(m -> new Media(m.getUri(), m.getMimeType(), m.getDate(), m.getWidth(), m.getHeight(), m.getSize(), m.getBucketId(), Optional.absent()))
.map(m -> new Media(m.getUri(), m.getMimeType(), m.getDate(), m.getWidth(), m.getHeight(), m.getSize(), m.getDuration(), m.getBucketId(), Optional.absent()))
.toList();
selectedMedia.setValue(uncaptioned);
position.setValue(position.getValue() != null ? position.getValue() : 0);
hudState.setValue(buildHudState());
}
@@ -476,7 +477,7 @@ class MediaSendViewModel extends ViewModel {
if (splitMessage.getTextSlide().isPresent()) {
Slide slide = splitMessage.getTextSlide().get();
uploadRepository.startUpload(new Media(Objects.requireNonNull(slide.getUri()), slide.getContentType(), System.currentTimeMillis(), 0, 0, slide.getFileSize(), Optional.absent(), Optional.absent()), recipient);
uploadRepository.startUpload(new Media(Objects.requireNonNull(slide.getUri()), slide.getContentType(), System.currentTimeMillis(), 0, 0, slide.getFileSize(), 0, Optional.absent(), Optional.absent()), recipient);
}
uploadRepository.applyMediaUpdates(oldToNew, recipient);

View File

@@ -0,0 +1,105 @@
package org.thoughtcrime.securesms.megaphone;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
public class BasicMegaphoneView extends FrameLayout {
private ImageView image;
private TextView titleText;
private TextView bodyText;
private Button actionButton;
private Button snoozeButton;
private Megaphone megaphone;
private MegaphoneListener megaphoneListener;
public BasicMegaphoneView(@NonNull Context context) {
super(context);
init(context);
}
public BasicMegaphoneView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(context);
}
private void init(@NonNull Context context) {
inflate(context, R.layout.basic_megaphone_view, this);
this.image = findViewById(R.id.basic_megaphone_image);
this.titleText = findViewById(R.id.basic_megaphone_title);
this.bodyText = findViewById(R.id.basic_megaphone_body);
this.actionButton = findViewById(R.id.basic_megaphone_action);
this.snoozeButton = findViewById(R.id.basic_megaphone_snooze);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (megaphone != null && megaphoneListener != null && megaphone.getOnVisibleListener() != null) {
megaphone.getOnVisibleListener().onEvent(megaphone, megaphoneListener);
}
}
public void present(@NonNull Megaphone megaphone, @NonNull MegaphoneListener megaphoneListener) {
this.megaphone = megaphone;
this.megaphoneListener = megaphoneListener;
if (megaphone.getImage() != 0) {
image.setVisibility(VISIBLE);
image.setImageResource(megaphone.getImage());
} else {
image.setVisibility(GONE);
}
if (megaphone.getTitle() != 0) {
titleText.setVisibility(VISIBLE);
titleText.setText(megaphone.getTitle());
} else {
titleText.setVisibility(GONE);
}
if (megaphone.getBody() != 0) {
bodyText.setVisibility(VISIBLE);
bodyText.setText(megaphone.getBody());
} else {
bodyText.setVisibility(GONE);
}
if (megaphone.getButtonText() != 0) {
actionButton.setVisibility(VISIBLE);
actionButton.setText(megaphone.getButtonText());
actionButton.setOnClickListener(v -> {
if (megaphone.getButtonClickListener() != null) {
megaphone.getButtonClickListener().onEvent(megaphone, megaphoneListener);
}
});
} else {
actionButton.setVisibility(GONE);
}
if (megaphone.canSnooze()) {
snoozeButton.setVisibility(VISIBLE);
snoozeButton.setOnClickListener(v -> {
megaphoneListener.onMegaphoneSnooze(megaphone);
if (megaphone.getSnoozeListener() != null) {
megaphone.getSnoozeListener().onEvent(megaphone, megaphoneListener);
}
});
} else {
actionButton.setVisibility(GONE);
}
}
}

View File

@@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.megaphone;
final class ForeverSchedule implements MegaphoneSchedule {
private final boolean enabled;
ForeverSchedule(boolean enabled) {
this.enabled = enabled;
}
@Override
public boolean shouldDisplay(int seenCount, long lastSeen, long firstVisible, long currentTime) {
return enabled;
}
}

View File

@@ -0,0 +1,161 @@
package org.thoughtcrime.securesms.megaphone;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.megaphone.Megaphones.Event;
/**
* For guidance on creating megaphones, see {@link Megaphones}.
*/
public class Megaphone {
private final Event event;
private final Style style;
private final boolean mandatory;
private final boolean canSnooze;
private final int titleRes;
private final int bodyRes;
private final int imageRes;
private final int buttonTextRes;
private final EventListener buttonListener;
private final EventListener snoozeListener;
private final EventListener onVisibleListener;
private Megaphone(@NonNull Builder builder) {
this.event = builder.event;
this.style = builder.style;
this.mandatory = builder.mandatory;
this.canSnooze = builder.canSnooze;
this.titleRes = builder.titleRes;
this.bodyRes = builder.bodyRes;
this.imageRes = builder.imageRes;
this.buttonTextRes = builder.buttonTextRes;
this.buttonListener = builder.buttonListener;
this.snoozeListener = builder.snoozeListener;
this.onVisibleListener = builder.onVisibleListener;
}
public @NonNull Event getEvent() {
return event;
}
public boolean isMandatory() {
return mandatory;
}
public boolean canSnooze() {
return canSnooze;
}
public @NonNull Style getStyle() {
return style;
}
public @StringRes int getTitle() {
return titleRes;
}
public @StringRes int getBody() {
return bodyRes;
}
public @DrawableRes int getImage() {
return imageRes;
}
public @StringRes int getButtonText() {
return buttonTextRes;
}
public @Nullable EventListener getButtonClickListener() {
return buttonListener;
}
public @Nullable EventListener getSnoozeListener() {
return snoozeListener;
}
public @Nullable EventListener getOnVisibleListener() {
return onVisibleListener;
}
public static class Builder {
private final Event event;
private final Style style;
private boolean mandatory;
private boolean canSnooze;
private int titleRes;
private int bodyRes;
private int imageRes;
private int buttonTextRes;
private EventListener buttonListener;
private EventListener snoozeListener;
private EventListener onVisibleListener;
public Builder(@NonNull Event event, @NonNull Style style) {
this.event = event;
this.style = style;
}
public @NonNull Builder setMandatory(boolean mandatory) {
this.mandatory = mandatory;
return this;
}
public @NonNull Builder enableSnooze(@Nullable EventListener listener) {
this.canSnooze = true;
this.snoozeListener = listener;
return this;
}
public @NonNull Builder disableSnooze() {
this.canSnooze = false;
this.snoozeListener = null;
return this;
}
public @NonNull Builder setTitle(@StringRes int titleRes) {
this.titleRes = titleRes;
return this;
}
public @NonNull Builder setBody(@StringRes int bodyRes) {
this.bodyRes = bodyRes;
return this;
}
public @NonNull Builder setImage(@DrawableRes int imageRes) {
this.imageRes = imageRes;
return this;
}
public @NonNull Builder setButtonText(@StringRes int buttonTextRes, @NonNull EventListener listener) {
this.buttonTextRes = buttonTextRes;
this.buttonListener = listener;
return this;
}
public @NonNull Builder setOnVisibleListener(@Nullable EventListener listener) {
this.onVisibleListener = listener;
return this;
}
public @NonNull Megaphone build() {
return new Megaphone(this);
}
}
enum Style {
REACTIONS, BASIC, FULLSCREEN
}
public interface EventListener {
void onEvent(@NonNull Megaphone megaphone, @NonNull MegaphoneListener listener);
}
}

View File

@@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.megaphone;
import android.content.Intent;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
public interface MegaphoneListener {
/**
* When a megaphone wants to navigate to a specific intent.
*/
void onMegaphoneNavigationRequested(@NonNull Intent intent);
/**
* When a megaphone wants to navigate to a specific intent for a request code.
*/
void onMegaphoneNavigationRequested(@NonNull Intent intent, int requestCode);
/**
* When a megaphone wants to show a toast/snackbar.
*/
void onMegaphoneToastRequested(@NonNull String string);
/**
* When a megaphone has been snoozed via "remind me later" or a similar option.
*/
void onMegaphoneSnooze(@NonNull Megaphone megaphone);
/**
* Called when a megaphone completed its goal.
*/
void onMegaphoneCompleted(@NonNull Megaphone megaphone);
}

View File

@@ -0,0 +1,130 @@
package org.thoughtcrime.securesms.megaphone;
import android.content.Context;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MegaphoneDatabase;
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
import org.thoughtcrime.securesms.megaphone.Megaphones.Event;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executor;
/**
* Synchronization of data structures is done using a serial executor. Do not access or change
* data structures or fields on anything except the executor.
*/
public class MegaphoneRepository {
private final Context context;
private final Executor executor;
private final MegaphoneDatabase database;
private final Map<Event, MegaphoneRecord> databaseCache;
private boolean enabled;
public MegaphoneRepository(@NonNull Context context) {
this.context = context;
this.executor = SignalExecutors.SERIAL;
this.database = DatabaseFactory.getMegaphoneDatabase(context);
this.databaseCache = new HashMap<>();
executor.execute(this::init);
}
/**
* Marks any megaphones a new user shouldn't see as "finished".
*/
@MainThread
public void onFirstEverAppLaunch() {
executor.execute(() -> {
database.markFinished(Event.REACTIONS);
resetDatabaseCache();
});
}
@MainThread
public void onAppForegrounded() {
executor.execute(() -> enabled = true);
}
@MainThread
public void getNextMegaphone(@NonNull Callback<Megaphone> callback) {
executor.execute(() -> {
if (enabled) {
callback.onResult(Megaphones.getNextMegaphone(context, databaseCache));
} else {
callback.onResult(null);
}
});
}
@MainThread
public void markVisible(@NonNull Megaphones.Event event) {
long time = System.currentTimeMillis();
executor.execute(() -> {
if (getRecord(event).getFirstVisible() == 0) {
database.markFirstVisible(event, time);
resetDatabaseCache();
}
});
}
@MainThread
public void markSeen(@NonNull Event event) {
long lastSeen = System.currentTimeMillis();
executor.execute(() -> {
MegaphoneRecord record = getRecord(event);
database.markSeen(event, record.getSeenCount() + 1, lastSeen);
enabled = false;
resetDatabaseCache();
});
}
@MainThread
public void markFinished(@NonNull Event event) {
executor.execute(() -> {
database.markFinished(event);
resetDatabaseCache();
});
}
@WorkerThread
private void init() {
List<MegaphoneRecord> records = database.getAllAndDeleteMissing();
Set<Event> events = Stream.of(records).map(MegaphoneRecord::getEvent).collect(Collectors.toSet());
Set<Event> missing = Stream.of(Megaphones.Event.values()).filterNot(events::contains).collect(Collectors.toSet());
database.insert(missing);
resetDatabaseCache();
}
@WorkerThread
private @NonNull MegaphoneRecord getRecord(@NonNull Event event) {
//noinspection ConstantConditions
return databaseCache.get(event);
}
@WorkerThread
private void resetDatabaseCache() {
databaseCache.clear();
databaseCache.putAll(Stream.of(database.getAllAndDeleteMissing()).collect(Collectors.toMap(MegaphoneRecord::getEvent, m -> m)));
}
public interface Callback<E> {
void onResult(E result);
}
}

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.megaphone;
public interface MegaphoneSchedule {
boolean shouldDisplay(int seenCount, long lastSeen, long firstVisible, long currentTime);
}

View File

@@ -0,0 +1,46 @@
package org.thoughtcrime.securesms.megaphone;
import android.content.Context;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.reactions.ReactionsMegaphoneView;
public class MegaphoneViewBuilder {
public static @Nullable View build(@NonNull Context context,
@NonNull Megaphone megaphone,
@NonNull MegaphoneListener listener)
{
switch (megaphone.getStyle()) {
case BASIC:
return buildBasicMegaphone(context, megaphone, listener);
case FULLSCREEN:
return null;
case REACTIONS:
return buildReactionsMegaphone(context, megaphone, listener);
default:
throw new IllegalArgumentException("No view implemented for style!");
}
}
private static @NonNull View buildBasicMegaphone(@NonNull Context context,
@NonNull Megaphone megaphone,
@NonNull MegaphoneListener listener)
{
BasicMegaphoneView view = new BasicMegaphoneView(context);
view.present(megaphone, listener);
return view;
}
private static @NonNull View buildReactionsMegaphone(@NonNull Context context,
@NonNull Megaphone megaphone,
@NonNull MegaphoneListener listener)
{
ReactionsMegaphoneView view = new ReactionsMegaphoneView(context);
view.present(megaphone, listener);
return view;
}
}

View File

@@ -0,0 +1,183 @@
package org.thoughtcrime.securesms.megaphone;
import android.content.Context;
import android.content.Intent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
import org.thoughtcrime.securesms.lock.v2.KbsMigrationActivity;
import org.thoughtcrime.securesms.lock.v2.PinUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Creating a new megaphone:
* - Add an enum to {@link Event}
* - Return a megaphone in {@link #forRecord(Context, MegaphoneRecord)}
* - Include the event in {@link #buildDisplayOrder()}
*
* Common patterns:
* - For events that have a snooze-able recurring display schedule, use a {@link RecurringSchedule}.
* - For events guarded by feature flags, set a {@link ForeverSchedule} with false in
* {@link #buildDisplayOrder()}.
* - For events that change, return different megaphones in {@link #forRecord(Context, MegaphoneRecord)}
* based on whatever properties you're interested in.
*/
public final class Megaphones {
private Megaphones() {}
static @Nullable Megaphone getNextMegaphone(@NonNull Context context, @NonNull Map<Event, MegaphoneRecord> records) {
long currentTime = System.currentTimeMillis();
List<Megaphone> megaphones = Stream.of(buildDisplayOrder())
.filter(e -> {
MegaphoneRecord record = Objects.requireNonNull(records.get(e.getKey()));
MegaphoneSchedule schedule = e.getValue();
return !record.isFinished() && schedule.shouldDisplay(record.getSeenCount(), record.getLastSeen(), record.getFirstVisible(), currentTime);
})
.map(Map.Entry::getKey)
.map(records::get)
.map(record -> Megaphones.forRecord(context, record))
.toList();
boolean hasOptional = Stream.of(megaphones).anyMatch(m -> !m.isMandatory());
boolean hasMandatory = Stream.of(megaphones).anyMatch(Megaphone::isMandatory);
if (hasOptional && hasMandatory) {
megaphones = Stream.of(megaphones).filter(Megaphone::isMandatory).toList();
}
if (megaphones.size() > 0) {
return megaphones.get(0);
} else {
return null;
}
}
/**
* This is when you would hide certain megaphones based on {@link FeatureFlags}. You could
* conditionally set a {@link ForeverSchedule} set to false for disabled features.
*/
private static Map<Event, MegaphoneSchedule> buildDisplayOrder() {
return new LinkedHashMap<Event, MegaphoneSchedule>() {{
put(Event.REACTIONS, new ForeverSchedule(true));
put(Event.PINS_FOR_ALL, new PinsForAllSchedule());
}};
}
private static @NonNull Megaphone forRecord(@NonNull Context context, @NonNull MegaphoneRecord record) {
switch (record.getEvent()) {
case REACTIONS:
return buildReactionsMegaphone();
case PINS_FOR_ALL:
return buildPinsForAllMegaphone(context, record);
default:
throw new IllegalArgumentException("Event not handled!");
}
}
private static @NonNull Megaphone buildReactionsMegaphone() {
return new Megaphone.Builder(Event.REACTIONS, Megaphone.Style.REACTIONS)
.setMandatory(false)
.build();
}
private static @NonNull Megaphone buildPinsForAllMegaphone(@NonNull Context context, @NonNull MegaphoneRecord record) {
if (PinsForAllSchedule.shouldDisplayFullScreen(record.getFirstVisible(), System.currentTimeMillis())) {
return new Megaphone.Builder(Event.PINS_FOR_ALL, Megaphone.Style.FULLSCREEN)
.setMandatory(true)
.enableSnooze(null)
.setOnVisibleListener((megaphone, listener) -> {
if (new NetworkConstraint.Factory(ApplicationDependencies.getApplication()).create().isMet()) {
listener.onMegaphoneNavigationRequested(KbsMigrationActivity.createIntent(), KbsMigrationActivity.REQUEST_NEW_PIN);
}
})
.build();
} else {
Megaphone.Builder builder = new Megaphone.Builder(Event.PINS_FOR_ALL, Megaphone.Style.BASIC)
.setMandatory(true)
.setImage(R.drawable.kbs_pin_megaphone);
long daysRemaining = PinsForAllSchedule.getDaysRemaining(record.getFirstVisible(), System.currentTimeMillis());
if (PinUtil.userHasPin(ApplicationDependencies.getApplication())) {
return buildPinsForAllMegaphoneForUserWithPin(
builder.enableSnooze((megaphone, listener) -> listener.onMegaphoneToastRequested(context.getString(R.string.KbsMegaphone__well_remind_you_later_confirming_your_pin, daysRemaining)))
);
} else {
return buildPinsForAllMegaphoneForUserWithoutPin(
builder.enableSnooze((megaphone, listener) -> listener.onMegaphoneToastRequested(context.getString(R.string.KbsMegaphone__well_remind_you_later_creating_a_pin, daysRemaining)))
);
}
}
}
private static @NonNull Megaphone buildPinsForAllMegaphoneForUserWithPin(@NonNull Megaphone.Builder builder) {
return builder.setTitle(R.string.KbsMegaphone__introducing_pins)
.setBody(R.string.KbsMegaphone__your_registration_lock_is_now_called_a_pin)
.setButtonText(R.string.KbsMegaphone__update_pin, (megaphone, listener) -> {
Intent intent = CreateKbsPinActivity.getIntentForPinChangeFromSettings(ApplicationDependencies.getApplication());
listener.onMegaphoneNavigationRequested(intent, CreateKbsPinActivity.REQUEST_NEW_PIN);
})
.build();
}
private static @NonNull Megaphone buildPinsForAllMegaphoneForUserWithoutPin(@NonNull Megaphone.Builder builder) {
return builder.setTitle(R.string.KbsMegaphone__create_a_pin)
.setBody(R.string.KbsMegaphone__pins_add_another_layer_of_security_to_your_signal_account)
.setButtonText(R.string.KbsMegaphone__create_pin, (megaphone, listener) -> {
Intent intent = CreateKbsPinActivity.getIntentForPinCreate(ApplicationDependencies.getApplication());
listener.onMegaphoneNavigationRequested(intent, CreateKbsPinActivity.REQUEST_NEW_PIN);
})
.build();
}
public enum Event {
REACTIONS("reactions"),
PINS_FOR_ALL("pins_for_all");
private final String key;
Event(@NonNull String key) {
this.key = key;
}
public @NonNull String getKey() {
return key;
}
public static Event fromKey(@NonNull String key) {
for (Event event : values()) {
if (event.getKey().equals(key)) {
return event;
}
}
throw new IllegalArgumentException("No event for key: " + key);
}
public static boolean hasKey(@NonNull String key) {
for (Event event : values()) {
if (event.getKey().equals(key)) {
return true;
}
}
return false;
}
}
}

View File

@@ -0,0 +1,84 @@
package org.thoughtcrime.securesms.megaphone;
import androidx.annotation.VisibleForTesting;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import java.util.concurrent.TimeUnit;
class PinsForAllSchedule implements MegaphoneSchedule {
@VisibleForTesting
static final long DAYS_UNTIL_FULLSCREEN = 8L;
@VisibleForTesting
static final long DAYS_REMAINING_MAX = DAYS_UNTIL_FULLSCREEN - 1;
private final MegaphoneSchedule schedule = new RecurringSchedule(TimeUnit.DAYS.toMillis(2));
static boolean shouldDisplayFullScreen(long firstVisible, long currentTime) {
if (pinCreationFailedDuringRegistration()) {
return true;
}
if (firstVisible == 0L) {
return false;
} else {
return currentTime - firstVisible >= TimeUnit.DAYS.toMillis(DAYS_UNTIL_FULLSCREEN);
}
}
static long getDaysRemaining(long firstVisible, long currentTime) {
if (firstVisible == 0L) {
return DAYS_REMAINING_MAX;
} else {
return Util.clamp(DAYS_REMAINING_MAX - TimeUnit.MILLISECONDS.toDays(currentTime - firstVisible), 0, DAYS_REMAINING_MAX);
}
}
@Override
public boolean shouldDisplay(int seenCount, long lastSeen, long firstVisible, long currentTime) {
if (!isEnabled()) return false;
if (shouldDisplayFullScreen(firstVisible, currentTime)) {
return true;
} else {
return schedule.shouldDisplay(seenCount, lastSeen, firstVisible, currentTime);
}
}
private static boolean isEnabled() {
if (FeatureFlags.pinsForAllMegaphoneKillSwitch()) {
return false;
}
if (pinCreationFailedDuringRegistration()) {
return true;
}
if (newlyRegisteredV1PinUser()) {
return true;
}
if (SignalStore.registrationValues().pinWasRequiredAtRegistration()) {
return false;
}
return FeatureFlags.pinsForAll();
}
private static boolean pinCreationFailedDuringRegistration() {
return SignalStore.registrationValues().pinWasRequiredAtRegistration() &&
!SignalStore.kbsValues().isV2RegistrationLockEnabled() &&
!TextSecurePreferences.isV1RegistrationLockEnabled(ApplicationDependencies.getApplication());
}
private static final boolean newlyRegisteredV1PinUser() {
return SignalStore.registrationValues().pinWasRequiredAtRegistration() && TextSecurePreferences.isV1RegistrationLockEnabled(ApplicationDependencies.getApplication());
}
}

View File

@@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.megaphone;
class RecurringSchedule implements MegaphoneSchedule {
private final long[] gaps;
RecurringSchedule(long... durationGaps) {
this.gaps = durationGaps;
}
@Override
public boolean shouldDisplay(int seenCount, long lastSeen, long firstVisible, long currentTime) {
if (seenCount == 0) {
return true;
}
long gap = gaps[Math.min(seenCount - 1, gaps.length - 1)];
return lastSeen + gap <= currentTime ;
}
}

View File

@@ -13,10 +13,10 @@ import org.thoughtcrime.securesms.keyvalue.KbsValues;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.lock.PinHashing;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.signalservice.api.KeyBackupService;
import org.whispersystems.signalservice.api.KeyBackupServicePinException;
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException;
import org.whispersystems.signalservice.api.RegistrationLockData;
import org.whispersystems.signalservice.api.kbs.HashedPin;
import org.whispersystems.signalservice.api.kbs.MasterKey;
@@ -56,12 +56,7 @@ public final class RegistrationPinV2MigrationJob extends BaseJob {
}
@Override
protected void onRun() throws IOException, UnauthenticatedResponseException, KeyBackupServicePinException {
if (!FeatureFlags.kbs()) {
Log.i(TAG, "Not migrating pin to KBS");
return;
}
protected void onRun() throws IOException, UnauthenticatedResponseException, KeyBackupServicePinException, KeyBackupSystemNoDataException {
if (!TextSecurePreferences.isV1RegistrationLockEnabled(context)) {
Log.i(TAG, "Registration lock disabled");
return;

View File

@@ -123,7 +123,10 @@ public class IncomingLollipopMmsConnection extends LollipopMmsConnection impleme
Log.i(TAG, baos.size() + "-byte response: ");// + Hex.dump(baos.toByteArray()));
RetrieveConf retrieved = (RetrieveConf) new PduParser(baos.toByteArray()).parse();
Bundle configValues = smsManager.getCarrierConfigValues();
boolean parseContentDisposition = configValues.getBoolean(SmsManager.MMS_CONFIG_SUPPORT_MMS_CONTENT_DISPOSITION);
RetrieveConf retrieved = (RetrieveConf) new PduParser(baos.toByteArray(), parseContentDisposition).parse();
if (retrieved == null) return null;

View File

@@ -63,6 +63,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.service.IncomingMessageObserver;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -550,7 +551,7 @@ public class MessageNotifier {
if (KeyCachingService.isLocked(context)) {
body = SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message));
} else {
String text = SpanUtil.italic(context.getString(R.string.MessageNotifier_reacted_to_your_message, EMOJI_REPLACEMENT_STRING)).toString();
String text = SpanUtil.italic(getReactionMessageBody(context, record)).toString();
String[] parts = text.split(EMOJI_REPLACEMENT_STRING);
SpannableStringBuilder builder = new SpannableStringBuilder();
@@ -580,6 +581,36 @@ public class MessageNotifier {
return notificationState;
}
private static CharSequence getReactionMessageBody(@NonNull Context context, @NonNull MessageRecord record) {
CharSequence body = record.getDisplayBody(context);
boolean bodyIsEmpty = TextUtils.isEmpty(body);
if (MessageRecordUtil.hasSharedContact(record)) {
Contact contact = ((MmsMessageRecord) record).getSharedContacts().get(0);
CharSequence summary = ContactUtil.getStringSummary(context, contact);
return context.getString(R.string.MessageNotifier_reacted_s_to_s, EMOJI_REPLACEMENT_STRING, summary);
} else if (MessageRecordUtil.hasSticker(record)) {
return context.getString(R.string.MessageNotifier_reacted_s_to_your_sticker, EMOJI_REPLACEMENT_STRING);
} else if (record.isMms() && record.isViewOnce() && MediaUtil.isVideoType(getMessageContentType((MmsMessageRecord) record))) {
return context.getString(R.string.MessageNotifier_reacted_s_to_your_view_once_video, EMOJI_REPLACEMENT_STRING);
} else if (record.isMms() && record.isViewOnce()){
return context.getString(R.string.MessageNotifier_reacted_s_to_your_view_once_photo, EMOJI_REPLACEMENT_STRING);
} else if (!bodyIsEmpty) {
return context.getString(R.string.MessageNotifier_reacted_s_to_s, EMOJI_REPLACEMENT_STRING, body);
} else if (MessageRecordUtil.isMediaMessage(record) && MediaUtil.isVideoType(getMessageContentType((MmsMessageRecord) record))) {
return context.getString(R.string.MessageNotifier_reacted_s_to_your_video, EMOJI_REPLACEMENT_STRING);
} else if (MessageRecordUtil.isMediaMessage(record) && MediaUtil.isImageType(getMessageContentType((MmsMessageRecord) record))) {
return context.getString(R.string.MessageNotifier_reacted_s_to_your_image, EMOJI_REPLACEMENT_STRING);
} else if (MessageRecordUtil.isMediaMessage(record) && MediaUtil.isAudioType(getMessageContentType((MmsMessageRecord) record))) {
return context.getString(R.string.MessageNotifier_reacted_s_to_your_audio, EMOJI_REPLACEMENT_STRING);
} else if (MessageRecordUtil.isMediaMessage(record)) {
return context.getString(R.string.MessageNotifier_reacted_s_to_your_file, EMOJI_REPLACEMENT_STRING);
} else {
return context.getString(R.string.MessageNotifier_reacted_s_to_s, EMOJI_REPLACEMENT_STRING, body);
}
}
private static @StringRes int getViewOnceDescription(@NonNull MmsMessageRecord messageRecord) {
final String contentType = getMessageContentType(messageRecord);

View File

@@ -20,6 +20,7 @@ import androidx.preference.Preference;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.Log;
import com.google.firebase.iid.FirebaseInstanceId;
@@ -174,6 +175,7 @@ public class AdvancedPreferenceFragment extends CorrectedPreferenceFragment {
break;
case SUCCESS:
TextSecurePreferences.setPushRegistered(getActivity(), false);
SignalStore.registrationValues().clearRegistrationComplete();
initializePushMessagingToggle();
break;
}

View File

@@ -11,6 +11,8 @@ import androidx.appcompat.app.AlertDialog;
import androidx.preference.CheckBoxPreference;
import androidx.preference.Preference;
import com.google.android.material.snackbar.Snackbar;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.ApplicationPreferencesActivity;
import org.thoughtcrime.securesms.BlockedContactsActivity;
@@ -21,10 +23,12 @@ import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob;
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.lock.RegistrationLockDialog;
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
import org.thoughtcrime.securesms.lock.v2.PinUtil;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.Locale;
@@ -45,11 +49,27 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment
disablePassphrase = (CheckBoxPreference) this.findPreference("pref_enable_passphrase_temporary");
SwitchPreferenceCompat regLock = (SwitchPreferenceCompat) this.findPreference(TextSecurePreferences.REGISTRATION_LOCK_PREF_V1);
regLock.setChecked(
TextSecurePreferences.isV1RegistrationLockEnabled(requireContext()) || SignalStore.kbsValues().isV2RegistrationLockEnabled()
);
regLock.setOnPreferenceClickListener(new AccountLockClickListener());
SwitchPreferenceCompat regLock = (SwitchPreferenceCompat) this.findPreference(TextSecurePreferences.REGISTRATION_LOCK_PREF_V1);
Preference kbsPinChange = this.findPreference(TextSecurePreferences.KBS_PIN_CHANGE);
Preference regGroup = this.findPreference("prefs_lock_v1");
Preference kbsGroup = this.findPreference("prefs_lock_v2");
if (FeatureFlags.pinsForAll()) {
Preference preference = this.findPreference("pref_kbs_change");
regGroup.setVisible(false);
if (PinUtil.userHasPin(ApplicationDependencies.getApplication())) {
kbsPinChange.setOnPreferenceClickListener(new KbsPinUpdateListener());
preference.setWidgetLayoutResource(R.layout.kbs_pin_change_preference);
} else {
kbsPinChange.setOnPreferenceClickListener(new KbsPinCreateListener());
preference.setWidgetLayoutResource(R.layout.kbs_pin_create_preference);
}
} else {
kbsGroup.setVisible(false);
regLock.setChecked(PinUtil.userHasPin(requireContext()));
regLock.setOnPreferenceClickListener(new AccountLockClickListener());
}
this.findPreference(TextSecurePreferences.SCREEN_LOCK).setOnPreferenceChangeListener(new ScreenLockListener());
this.findPreference(TextSecurePreferences.SCREEN_LOCK_TIMEOUT).setOnPreferenceClickListener(new ScreenLockTimeoutListener());
@@ -84,6 +104,13 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment
disablePassphrase.setChecked(!TextSecurePreferences.isPasswordDisabled(getActivity()));
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
if (requestCode == CreateKbsPinActivity.REQUEST_NEW_PIN && resultCode == CreateKbsPinActivity.RESULT_OK) {
Snackbar.make(requireView(), R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).show();
}
}
private void initializePassphraseTimeoutSummary() {
int timeoutMinutes = TextSecurePreferences.getPassphraseTimeoutInterval(getActivity());
this.findPreference(TextSecurePreferences.PASSPHRASE_TIMEOUT_INTERVAL_PREF)
@@ -151,12 +178,28 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment
}
}
private class KbsPinUpdateListener implements Preference.OnPreferenceClickListener {
@Override
public boolean onPreferenceClick(Preference preference) {
startActivityForResult(CreateKbsPinActivity.getIntentForPinChangeFromSettings(requireContext()), CreateKbsPinActivity.REQUEST_NEW_PIN);
return true;
}
}
private class KbsPinCreateListener implements Preference.OnPreferenceClickListener {
@Override
public boolean onPreferenceClick(Preference preference) {
startActivityForResult(CreateKbsPinActivity.getIntentForPinCreate(requireContext()), CreateKbsPinActivity.REQUEST_NEW_PIN);
return true;
}
}
private class AccountLockClickListener implements Preference.OnPreferenceClickListener {
@Override
public boolean onPreferenceClick(Preference preference) {
Context context = requireContext();
if (TextSecurePreferences.isV1RegistrationLockEnabled(context) || SignalStore.kbsValues().isV2RegistrationLockEnabled()) {
if (PinUtil.userHasPin(context)) {
RegistrationLockDialog.showRegistrationUnlockPrompt(context, (SwitchPreferenceCompat)preference);
} else {
RegistrationLockDialog.showRegistrationLockPrompt(context, (SwitchPreferenceCompat)preference);
@@ -219,10 +262,11 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment
}
public static CharSequence getSummary(Context context) {
final int privacySummaryResId = R.string.ApplicationPreferencesActivity_privacy_summary;
final int privacySummaryResId = FeatureFlags.pinsForAll() ? R.string.ApplicationPreferencesActivity_privacy_summary_screen_lock
: R.string.ApplicationPreferencesActivity_privacy_summary;
final String onRes = context.getString(R.string.ApplicationPreferencesActivity_on);
final String offRes = context.getString(R.string.ApplicationPreferencesActivity_off);
boolean registrationLockEnabled = TextSecurePreferences.isV1RegistrationLockEnabled(context) || SignalStore.kbsValues().isV2RegistrationLockEnabled();
boolean registrationLockEnabled = PinUtil.userHasPin(context);
if (TextSecurePreferences.isPasswordDisabled(context) && !TextSecurePreferences.isScreenLockEnabled(context)) {
if (registrationLockEnabled) {

View File

@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.preferences;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Bundle;
import android.text.TextUtils;
@@ -52,6 +51,8 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment {
.setOnPreferenceClickListener(new BackupClickListener());
findPreference(TextSecurePreferences.BACKUP_NOW)
.setOnPreferenceClickListener(new BackupCreateListener());
findPreference(TextSecurePreferences.BACKUP_PASSPHRASE_VERIFY)
.setOnPreferenceClickListener(new BackupVerifyListener());
initializeListSummary((ListPreference) findPreference(TextSecurePreferences.MESSAGE_BODY_TEXT_SIZE_PREF));
@@ -145,7 +146,6 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment {
}
private class BackupCreateListener implements Preference.OnPreferenceClickListener {
@SuppressLint("StaticFieldLeak")
@Override
public boolean onPreferenceClick(Preference preference) {
Permissions.with(ChatsPreferenceFragment.this)
@@ -162,6 +162,14 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment {
}
}
private class BackupVerifyListener implements Preference.OnPreferenceClickListener {
@Override
public boolean onPreferenceClick(Preference preference) {
BackupDialog.showVerifyBackupPassphraseDialog(requireContext());
return true;
}
}
private class MediaDownloadChangeListener implements Preference.OnPreferenceChangeListener {
@SuppressWarnings("unchecked")
@Override public boolean onPreferenceChange(Preference preference, Object newValue) {

View File

@@ -19,6 +19,7 @@ public class EditProfileActivity extends BaseActionBarActivity implements EditPr
public static final String EXCLUDE_SYSTEM = "exclude_system";
public static final String DISPLAY_USERNAME = "display_username";
public static final String NEXT_BUTTON_TEXT = "next_button_text";
public static final String SHOW_TOOLBAR = "show_back_arrow";
private final DynamicTheme dynamicTheme = new DynamicRegistrationTheme();
@@ -30,8 +31,10 @@ public class EditProfileActivity extends BaseActionBarActivity implements EditPr
setContentView(R.layout.profile_create_activity);
NavGraph graph = Navigation.findNavController(this, R.id.nav_host_fragment).getGraph();
Navigation.findNavController(this, R.id.nav_host_fragment).setGraph(graph, getIntent().getExtras());
if (bundle == null) {
NavGraph graph = Navigation.findNavController(this, R.id.nav_host_fragment).getGraph();
Navigation.findNavController(this, R.id.nav_host_fragment).setGraph(graph, getIntent().getExtras());
}
}
@Override

View File

@@ -23,6 +23,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.StringRes;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import androidx.navigation.NavDirections;
@@ -34,6 +35,7 @@ import com.dd.CircularProgressButton;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.AvatarSelection;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.permissions.Permissions;
@@ -52,11 +54,15 @@ import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.DISPL
import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.EXCLUDE_SYSTEM;
import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.NEXT_BUTTON_TEXT;
import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.NEXT_INTENT;
import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.SHOW_TOOLBAR;
public class EditProfileFragment extends Fragment {
private static final String TAG = Log.tag(EditProfileFragment.class);
private static final String TAG = Log.tag(EditProfileFragment.class);
private static final String AVATAR_STATE = "avatar";
private Toolbar toolbar;
private View title;
private ImageView avatar;
private CircularProgressButton finishButton;
private EditText givenName;
@@ -111,7 +117,7 @@ public class EditProfileFragment extends Fragment {
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
initializeResources(view);
initializeViewModel(requireArguments().getBoolean(EXCLUDE_SYSTEM, false));
initializeViewModel(requireArguments().getBoolean(EXCLUDE_SYSTEM, false), savedInstanceState != null);
initializeProfileName();
initializeProfileAvatar();
initializeUsername();
@@ -119,6 +125,20 @@ public class EditProfileFragment extends Fragment {
requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING);
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
outState.putByteArray(AVATAR_STATE, viewModel.getAvatarSnapshot());
}
@Override
public void onViewStateRestored(@Nullable Bundle savedInstanceState) {
super.onViewStateRestored(savedInstanceState);
if (savedInstanceState != null && savedInstanceState.containsKey(AVATAR_STATE)) {
viewModel.setAvatar(savedInstanceState.getByteArray(AVATAR_STATE));
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
@@ -177,9 +197,9 @@ public class EditProfileFragment extends Fragment {
}
}
private void initializeViewModel(boolean excludeSystem) {
EditProfileRepository repository = new EditProfileRepository(requireContext(), excludeSystem);
EditProfileViewModel.Factory factory = new EditProfileViewModel.Factory(repository);
private void initializeViewModel(boolean excludeSystem, boolean hasSavedInstanceState) {
EditProfileRepository repository = new EditProfileRepository(requireContext(), excludeSystem);
EditProfileViewModel.Factory factory = new EditProfileViewModel.Factory(repository, hasSavedInstanceState);
viewModel = ViewModelProviders.of(this, factory).get(EditProfileViewModel.class);
}
@@ -187,6 +207,8 @@ public class EditProfileFragment extends Fragment {
private void initializeResources(@NonNull View view) {
Bundle arguments = requireArguments();
this.toolbar = view.findViewById(R.id.toolbar);
this.title = view.findViewById(R.id.title);
this.avatar = view.findViewById(R.id.avatar);
this.givenName = view.findViewById(R.id.given_name);
this.familyName = view.findViewById(R.id.family_name);
@@ -231,20 +253,26 @@ public class EditProfileFragment extends Fragment {
NavDirections action = EditProfileFragmentDirections.actionEditUsername();
Navigation.findNavController(v).navigate(action);
});
if (arguments.getBoolean(SHOW_TOOLBAR, true)) {
this.toolbar.setVisibility(View.VISIBLE);
this.toolbar.setNavigationOnClickListener(v -> requireActivity().finish());
this.title.setVisibility(View.GONE);
}
}
private void initializeProfileName() {
viewModel.profileName().observe(this, profileName -> {
viewModel.givenName().observe(this, givenName -> updateFieldIfNeeded(this.givenName, givenName));
updateFieldIfNeeded(givenName, profileName.getGivenName());
updateFieldIfNeeded(familyName, profileName.getFamilyName());
viewModel.familyName().observe(this, familyName -> updateFieldIfNeeded(this.familyName, familyName));
viewModel.profileName().observe(this, profileName -> {
preview.setText(profileName.toString());
boolean validEntry = !profileName.isGivenNameEmpty();
finishButton.setEnabled(validEntry);
finishButton.setAlpha(validEntry ? 1f : 0.5f);
preview.setText(profileName.toString());
});
}
@@ -294,6 +322,9 @@ public class EditProfileFragment extends Fragment {
Log.w(TAG, "Failed to delete capture file " + captureFile);
}
}
SignalStore.registrationValues().setRegistrationComplete();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) handleFinishedLollipop();
else handleFinishedLegacy();
} else {

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.profiles.edit;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.core.util.Consumer;
import androidx.lifecycle.LiveData;
@@ -22,19 +23,30 @@ class EditProfileViewModel extends ViewModel {
private final MutableLiveData<Optional<String>> internalUsername = new MutableLiveData<>();
private final EditProfileRepository repository;
private EditProfileViewModel(@NonNull EditProfileRepository repository) {
private EditProfileViewModel(@NonNull EditProfileRepository repository, boolean hasInstanceState) {
this.repository = repository;
repository.getCurrentUsername(internalUsername::postValue);
repository.getCurrentProfileName(name -> {
givenName.setValue(name.getGivenName());
familyName.setValue(name.getFamilyName());
});
repository.getCurrentAvatar(internalAvatar::setValue);
if (!hasInstanceState) {
repository.getCurrentProfileName(name -> {
givenName.setValue(name.getGivenName());
familyName.setValue(name.getFamilyName());
});
repository.getCurrentAvatar(internalAvatar::setValue);
}
}
public LiveData<String> givenName() {
return Transformations.distinctUntilChanged(givenName);
}
public LiveData<String> familyName() {
return Transformations.distinctUntilChanged(familyName);
}
public LiveData<ProfileName> profileName() {
return internalProfileName;
return Transformations.distinctUntilChanged(internalProfileName);
}
public LiveData<byte[]> avatar() {
@@ -49,6 +61,11 @@ class EditProfileViewModel extends ViewModel {
return internalAvatar.getValue() != null;
}
@MainThread
public byte[] getAvatarSnapshot() {
return internalAvatar.getValue();
}
public void setGivenName(String givenName) {
this.givenName.setValue(givenName);
}
@@ -73,16 +90,18 @@ class EditProfileViewModel extends ViewModel {
static class Factory implements ViewModelProvider.Factory {
private final EditProfileRepository repository;
private final boolean hasInstanceState;
Factory(EditProfileRepository repository) {
this.repository = repository;
Factory(@NonNull EditProfileRepository repository, boolean hasInstanceState) {
this.repository = repository;
this.hasInstanceState = hasInstanceState;
}
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection unchecked
return (T) new EditProfileViewModel(repository);
return (T) new EditProfileViewModel(repository, hasInstanceState);
}
}
}

View File

@@ -7,8 +7,11 @@ import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.util.ThemeUtil;
@@ -19,6 +22,7 @@ import java.util.List;
final class ReactionEmojiCountAdapter extends RecyclerView.Adapter<ReactionEmojiCountAdapter.ViewHolder> {
private List<EmojiCount> emojiCountList = Collections.emptyList();
private int totalCount = 0;
private int selectedPosition = -1;
private final OnEmojiCountSelectedListener onEmojiCountSelectedListener;
@@ -28,9 +32,10 @@ final class ReactionEmojiCountAdapter extends RecyclerView.Adapter<ReactionEmoji
}
void updateData(@NonNull List<EmojiCount> newEmojiCount) {
if (selectedPosition != -1) {
EmojiCount oldSelection = emojiCountList.get(selectedPosition);
int newPosition = -1;
if (selectedPosition != -1 && selectedPosition != 0) {
int emojiPosition = selectedPosition - 1;
EmojiCount oldSelection = emojiCountList.get(emojiPosition);
int newPosition = -1;
for (int i = 0; i < newEmojiCount.size(); i++) {
if (newEmojiCount.get(i).getEmoji().equals(oldSelection.getEmoji())) {
@@ -41,17 +46,19 @@ final class ReactionEmojiCountAdapter extends RecyclerView.Adapter<ReactionEmoji
if (newPosition == -1 && !newEmojiCount.isEmpty()) {
selectedPosition = 0;
onEmojiCountSelectedListener.onSelected(newEmojiCount.get(0));
onEmojiCountSelectedListener.onSelected(null);
} else {
selectedPosition = newPosition;
selectedPosition = newPosition + 1;
}
} else if (!newEmojiCount.isEmpty()) {
selectedPosition = 0;
onEmojiCountSelectedListener.onSelected(newEmojiCount.get(0));
onEmojiCountSelectedListener.onSelected(null);
}
this.emojiCountList = newEmojiCount;
this.totalCount = Stream.of(emojiCountList).reduce(0, (sum, e) -> sum + e.getCount());
notifyDataSetChanged();
}
@@ -59,7 +66,7 @@ final class ReactionEmojiCountAdapter extends RecyclerView.Adapter<ReactionEmoji
public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.reactions_bottom_sheet_dialog_fragment_emoji_item, parent, false), position -> {
if (position != -1 && position != selectedPosition) {
onEmojiCountSelectedListener.onSelected(emojiCountList.get(position));
onEmojiCountSelectedListener.onSelected(position == 0 ? null : emojiCountList.get(position - 1).getEmoji());
int oldPosition = selectedPosition;
selectedPosition = position;
@@ -72,33 +79,44 @@ final class ReactionEmojiCountAdapter extends RecyclerView.Adapter<ReactionEmoji
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.bind(emojiCountList.get(position), selectedPosition);
if (position == 0) {
holder.bind(null, totalCount, selectedPosition == position);
} else {
EmojiCount item = emojiCountList.get(position - 1);
holder.bind(item.getEmoji(), item.getCount(), selectedPosition == position);
}
}
@Override
public int getItemCount() {
return emojiCountList.size();
return 1 + emojiCountList.size();
}
static final class ViewHolder extends RecyclerView.ViewHolder {
private final Drawable selected;
private final Drawable selectedBackground;
private final EmojiTextView emojiView;
private final TextView countView;
public ViewHolder(@NonNull View itemView, @NonNull OnViewHolderClickListener onClickListener) {
ViewHolder(@NonNull View itemView, @NonNull OnViewHolderClickListener onClickListener) {
super(itemView);
emojiView = itemView.findViewById(R.id.reactions_bottom_view_emoji_item_emoji);
countView = itemView.findViewById(R.id.reactions_bottom_view_emoji_item_text);
selected = ThemeUtil.getThemedDrawable(itemView.getContext(), R.attr.reactions_bottom_dialog_fragment_emoji_selected);
emojiView = itemView.findViewById(R.id.reactions_bottom_view_emoji_item_emoji);
countView = itemView.findViewById(R.id.reactions_bottom_view_emoji_item_text );
selectedBackground = ThemeUtil.getThemedDrawable(itemView.getContext(), R.attr.reactions_bottom_dialog_fragment_emoji_selected);
itemView.setOnClickListener(v -> onClickListener.onClick(getAdapterPosition()));
}
void bind(@NonNull EmojiCount emojiCount, int selectedPosition) {
emojiView.setText(emojiCount.getEmoji());
countView.setText(String.valueOf(emojiCount.getCount()));
itemView.setBackground(getAdapterPosition() == selectedPosition ? selected : null);
void bind(@Nullable String emoji, int count, boolean selected) {
if (emoji != null) {
emojiView.setVisibility(View.VISIBLE);
emojiView.setText(emoji);
countView.setText(String.valueOf(count));
} else {
emojiView.setVisibility(View.GONE);
countView.setText(itemView.getContext().getString(R.string.ReactionsBottomSheetDialogFragment_all, count));
}
itemView.setBackground(selected ? selectedBackground : null);
}
}
@@ -107,7 +125,6 @@ final class ReactionEmojiCountAdapter extends RecyclerView.Adapter<ReactionEmoji
}
interface OnEmojiCountSelectedListener {
void onSelected(@NonNull EmojiCount emojiCount);
void onSelected(@Nullable String emoji);
}
}

View File

@@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.reactions.ReactionsLoader.Reaction;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.AvatarUtil;
@@ -19,9 +20,9 @@ import java.util.List;
final class ReactionRecipientsAdapter extends RecyclerView.Adapter<ReactionRecipientsAdapter.ViewHolder> {
private List<Recipient> data = Collections.emptyList();
private List<Reaction> data = Collections.emptyList();
public void updateData(List<Recipient> newData) {
public void updateData(List<Reaction> newData) {
data = newData;
notifyDataSetChanged();
}
@@ -48,21 +49,24 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter<ReactionRecip
private final AvatarImageView avatar;
private final TextView recipient;
private final TextView emoji;
public ViewHolder(@NonNull View itemView) {
super(itemView);
avatar = itemView.findViewById(R.id.reactions_bottom_view_recipient_avatar);
recipient = itemView.findViewById(R.id.reactions_bottom_view_recipient_name);
emoji = itemView.findViewById(R.id.reactions_bottom_view_recipient_emoji);
}
void bind(Recipient recipient) {
this.recipient.setText(recipient.getDisplayName(itemView.getContext()));
void bind(@NonNull Reaction reaction) {
this.recipient.setText(reaction.getSender().getDisplayName(itemView.getContext()));
this.emoji.setText(reaction.getEmoji());
if (recipient.equals(Recipient.self())) {
AvatarUtil.loadIconIntoImageView(recipient, avatar);
if (reaction.getSender().equals(Recipient.self())) {
AvatarUtil.loadIconIntoImageView(reaction.getSender(), avatar);
} else {
this.avatar.setAvatar(GlideApp.with(avatar), recipient, false);
this.avatar.setAvatar(GlideApp.with(avatar), reaction.getSender(), false);
}
}
}

View File

@@ -88,7 +88,7 @@ public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogF
}
private void setUpEmojiRecyclerView() {
emojiCountAdapter = new ReactionEmojiCountAdapter((emojiCount -> viewModel.setFilterEmoji(emojiCount.getEmoji())));
emojiCountAdapter = new ReactionEmojiCountAdapter((emoji -> viewModel.setFilterEmoji(emoji)));
emojiRecyclerView.setAdapter(emojiCountAdapter);
}

View File

@@ -0,0 +1,198 @@
package org.thoughtcrime.securesms.reactions;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class ReactionsConversationView extends LinearLayout {
private static final int OUTER_MARGIN = ViewUtil.dpToPx(6);
private boolean outgoing;
private List<ReactionRecord> records;
public ReactionsConversationView(Context context) {
super(context);
init(null);
}
public ReactionsConversationView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
private void init(@Nullable AttributeSet attrs) {
records = new ArrayList<>();
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ReactionsConversationView, 0, 0);
outgoing = typedArray.getBoolean(R.styleable.ReactionsConversationView_rcv_outgoing, false);
}
}
public void clear() {
removeAllViews();
}
public void setReactions(@NonNull List<ReactionRecord> records, int bubbleWidth) {
if (records.equals(this.records)) {
return;
}
this.records.clear();
this.records.addAll(records);
List<Reaction> reactions = buildSortedReactionsList(records);
removeAllViews();
for (Reaction reaction : reactions) {
addView(buildPill(getContext(), this, reaction));
}
measure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
int railWidth = getMeasuredWidth();
if (railWidth < (bubbleWidth - OUTER_MARGIN)) {
int margin = (bubbleWidth - railWidth - OUTER_MARGIN);
if (outgoing) {
ViewUtil.setRightMargin(this, margin);
} else {
ViewUtil.setLeftMargin(this, margin);
}
} else {
if (outgoing) {
ViewUtil.setRightMargin(this, OUTER_MARGIN);
} else {
ViewUtil.setLeftMargin(this, OUTER_MARGIN);
}
}
}
private static @NonNull List<Reaction> buildSortedReactionsList(@NonNull List<ReactionRecord> records) {
Map<String, Reaction> counters = new LinkedHashMap<>();
RecipientId selfId = Recipient.self().getId();
for (ReactionRecord record : records) {
Reaction info = counters.get(record.getEmoji());
if (info == null) {
info = new Reaction(record.getEmoji(), 1, record.getDateReceived(), selfId.equals(record.getAuthor()));
} else {
info.update(record.getDateReceived(), selfId.equals(record.getAuthor()));
}
counters.put(record.getEmoji(), info);
}
List<Reaction> reactions = new ArrayList<>(counters.values());
Collections.sort(reactions, Collections.reverseOrder());
if (reactions.size() > 3) {
List<Reaction> shortened = new ArrayList<>(3);
shortened.add(reactions.get(0));
shortened.add(reactions.get(1));
shortened.add(Stream.of(reactions).skip(2).reduce(new Reaction(null, 0, 0, false), Reaction::merge));
return shortened;
} else {
return reactions;
}
}
private static View buildPill(@NonNull Context context, @NonNull ViewGroup parent, @NonNull Reaction reaction) {
View root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false);
TextView emojiView = root.findViewById(R.id.reactions_pill_emoji);
TextView countView = root.findViewById(R.id.reactions_pill_count);
View spacer = root.findViewById(R.id.reactions_pill_spacer);
if (reaction.emoji != null) {
emojiView.setText(reaction.emoji);
if (reaction.count > 1) {
countView.setText(String.valueOf(reaction.count));
} else {
countView.setVisibility(GONE);
spacer.setVisibility(GONE);
}
} else {
emojiView.setVisibility(GONE);
spacer.setVisibility(GONE);
countView.setText(context.getString(R.string.ReactionsConversationView_plus, reaction.count));
}
if (reaction.userWasSender) {
root.setBackground(ThemeUtil.getThemedDrawable(context, R.attr.reactions_pill_selected_background));
countView.setTextColor(ThemeUtil.getThemedColor(context, R.attr.reactions_pill_selected_text_color));
} else {
root.setBackground(ThemeUtil.getThemedDrawable(context, R.attr.reactions_pill_background));
}
return root;
}
private static class Reaction implements Comparable<Reaction> {
private String emoji;
private int count;
private long lastSeen;
private boolean userWasSender;
Reaction(@Nullable String emoji, int count, long lastSeen, boolean userWasSender) {
this.emoji = emoji;
this.count = count;
this.lastSeen = lastSeen;
this.userWasSender = userWasSender;
}
void update(long lastSeen, boolean userWasSender) {
this.count = this.count + 1;
this.lastSeen = Math.max(this.lastSeen, lastSeen);
this.userWasSender = this.userWasSender || userWasSender;
}
@NonNull Reaction merge(@NonNull Reaction other) {
this.count = this.count + other.count;
this.lastSeen = Math.max(this.lastSeen, other.lastSeen);
this.userWasSender = this.userWasSender || other.userWasSender;
return this;
}
@Override
public int compareTo(Reaction rhs) {
Reaction lhs = this;
if (lhs.count != rhs.count) {
return Integer.compare(lhs.count, rhs.count);
}
return Long.compare(lhs.lastSeen, rhs.lastSeen);
}
}
}

View File

@@ -0,0 +1,37 @@
package org.thoughtcrime.securesms.reactions;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.megaphone.Megaphone;
import org.thoughtcrime.securesms.megaphone.MegaphoneListener;
public class ReactionsMegaphoneView extends FrameLayout {
private View closeButton;
public ReactionsMegaphoneView(Context context) {
super(context);
initialize(context);
}
public ReactionsMegaphoneView(Context context, AttributeSet attrs) {
super(context, attrs);
initialize(context);
}
private void initialize(@NonNull Context context) {
inflate(context, R.layout.reactions_megaphone, this);
this.closeButton = findViewById(R.id.reactions_megaphone_x);
}
public void present(@NonNull Megaphone megaphone, @NonNull MegaphoneListener listener) {
this.closeButton.setOnClickListener(v -> listener.onMegaphoneCompleted(megaphone));
}
}

View File

@@ -9,9 +9,6 @@ import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@@ -26,12 +23,12 @@ public class ReactionsViewModel extends ViewModel {
this.repository = repository;
}
public @NonNull LiveData<List<Recipient>> getRecipients() {
public @NonNull LiveData<List<Reaction>> getRecipients() {
return Transformations.switchMap(filterEmoji,
emoji -> Transformations.map(repository.getReactions(),
reactions -> Stream.of(reactions)
.filter(reaction -> reaction.getEmoji().equals(emoji))
.map(Reaction::getSender).toList()));
.filter(reaction -> emoji == null || reaction.getEmoji().equals(emoji))
.toList()));
}
public @NonNull LiveData<List<EmojiCount>> getEmojiCounts() {

View File

@@ -130,7 +130,7 @@ public class RecipientDetails {
this.blocked = false;
this.expireMessages = 0;
this.participants = new LinkedList<>();
this.profileName = null;
this.profileName = ProfileName.EMPTY;
this.insightsBannerTier = InsightsBannerTier.TIER_TWO;
this.defaultSubscriptionId = Optional.absent();
this.registered = RegisteredState.UNKNOWN;

View File

@@ -0,0 +1,64 @@
package org.thoughtcrime.securesms.registration.fragments;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import java.util.concurrent.TimeUnit;
public class AccountLockedFragment extends BaseRegistrationFragment {
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.account_locked_fragment, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
TextView description = view.findViewById(R.id.account_locked_description);
getModel().getTimeRemaining().observe(getViewLifecycleOwner(),
t -> description.setText(getString(R.string.AccountLockedFragment__your_account_has_been_locked_to_protect_your_privacy, durationToDays(t)))
);
view.findViewById(R.id.account_locked_next).setOnClickListener(v -> onNext());
view.findViewById(R.id.account_locked_learn_more).setOnClickListener(v -> learnMore());
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
onNext();
}
});
}
private void learnMore() {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse(getString(R.string.AccountLockedFragment__learn_more_url)));
startActivity(intent);
}
private static long durationToDays(Long duration) {
return duration != null ? getLockoutDays(duration) : 7;
}
private static int getLockoutDays(long timeRemainingMs) {
return (int) TimeUnit.MILLISECONDS.toDays(timeRemainingMs) + 1;
}
private void onNext() {
requireActivity().finish();
}
}

View File

@@ -121,25 +121,38 @@ public final class EnterCodeFragment extends BaseRegistrationFragment {
}
@Override
public void onIncorrectRegistrationLockPin(long timeRemaining, String storageCredentials) {
model.setStorageCredentials(storageCredentials);
public void onV1RegistrationLockPinRequiredOrIncorrect(long timeRemaining) {
model.setTimeRemaining(timeRemaining);
keyboard.displayLocked().addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean r) {
Navigation.findNavController(requireView())
.navigate(EnterCodeFragmentDirections.actionRequireRegistrationLockPin(timeRemaining));
.navigate(EnterCodeFragmentDirections.actionRequireKbsLockPin(timeRemaining));
}
});
}
@Override
public void onIncorrectKbsRegistrationLockPin(@NonNull TokenResponse triesRemaining) {
// Unexpected, because at this point, no pin has been provided by the user.
throw new AssertionError();
public void onKbsRegistrationLockPinRequired(long timeRemaining, @NonNull TokenResponse tokenResponse, @NonNull String kbsStorageCredentials) {
model.setTimeRemaining(timeRemaining);
model.setStorageCredentials(kbsStorageCredentials);
model.setKeyBackupCurrentToken(tokenResponse);
keyboard.displayLocked().addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean r) {
Navigation.findNavController(requireView())
.navigate(EnterCodeFragmentDirections.actionRequireKbsLockPin(timeRemaining));
}
});
}
@Override
public void onTooManyAttempts() {
public void onIncorrectKbsRegistrationLockPin(@NonNull TokenResponse tokenResponse) {
throw new AssertionError("Unexpected, user has made no pin guesses");
}
@Override
public void onRateLimited() {
keyboard.displayFailure().addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean r) {
@@ -157,6 +170,14 @@ public final class EnterCodeFragment extends BaseRegistrationFragment {
});
}
@Override
public void onKbsAccountLocked(@Nullable Long timeRemaining) {
if (timeRemaining != null) {
model.setTimeRemaining(timeRemaining);
}
Navigation.findNavController(requireView()).navigate(RegistrationLockFragmentDirections.actionAccountLocked());
}
@Override
public void onError() {
Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show();

View File

@@ -14,7 +14,10 @@ import androidx.navigation.ActivityNavigator;
import org.thoughtcrime.securesms.MainActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
import org.thoughtcrime.securesms.lock.v2.PinUtil;
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
import org.thoughtcrime.securesms.util.FeatureFlags;
public final class RegistrationCompleteFragment extends BaseRegistrationFragment {
@@ -30,17 +33,27 @@ public final class RegistrationCompleteFragment extends BaseRegistrationFragment
FragmentActivity activity = requireActivity();
if (!isReregister()) {
activity.startActivity(getRoutedIntent(activity, EditProfileActivity.class, new Intent(activity, MainActivity.class)));
final Intent main = new Intent(activity, MainActivity.class);
final Intent next = chainIntents(new Intent(activity, EditProfileActivity.class), main);
next.putExtra(EditProfileActivity.SHOW_TOOLBAR, false);
Context context = requireContext();
if (FeatureFlags.pinsForAll() && !PinUtil.userHasPin(context)) {
activity.startActivity(chainIntents(CreateKbsPinActivity.getIntentForPinCreate(context), next));
} else {
activity.startActivity(next);
}
}
activity.finish();
ActivityNavigator.applyPopAnimationsToPendingTransition(activity);
}
private static Intent getRoutedIntent(@NonNull Context context, Class<?> destination, @Nullable Intent nextIntent) {
final Intent intent = new Intent(context, destination);
if (nextIntent != null) intent.putExtra("next_intent", nextIntent);
return intent;
private static Intent chainIntents(@NonNull Intent sourceIntent, @Nullable Intent nextIntent) {
if (nextIntent != null) sourceIntent.putExtra("next_intent", nextIntent);
return sourceIntent;
}
}

View File

@@ -1,45 +1,51 @@
package org.thoughtcrime.securesms.registration.fragments;
import android.content.res.Resources;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.InputType;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.navigation.Navigation;
import com.dd.CircularProgressButton;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.registration.service.CodeVerificationRequest;
import org.thoughtcrime.securesms.registration.service.RegistrationService;
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
public final class RegistrationLockFragment extends BaseRegistrationFragment {
private static final String TAG = Log.tag(RegistrationLockFragment.class);
/** Applies to both V1 and V2 pins, because some V2 pins may have been migrated from V1. */
private static final int MINIMUM_PIN_LENGTH = 4;
private EditText pinEntry;
private View forgotPin;
private CircularProgressButton pinButton;
private TextView errorLabel;
private TextView keyboardToggle;
private long timeRemaining;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_registration_lock, container, false);
}
@@ -47,38 +53,18 @@ public final class RegistrationLockFragment extends BaseRegistrationFragment {
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
setDebugLogSubmitMultiTapView(view.findViewById(R.id.verify_header));
setDebugLogSubmitMultiTapView(view.findViewById(R.id.kbs_lock_pin_title));
pinEntry = view.findViewById(R.id.pin);
pinButton = view.findViewById(R.id.pinButton);
View clarificationLabel = view.findViewById(R.id.clarification_label);
View subHeader = view.findViewById(R.id.verify_subheader);
View pinForgotButton = view.findViewById(R.id.forgot_button);
String code = getModel().getTextCodeEntered();
pinEntry = view.findViewById(R.id.kbs_lock_pin_input);
pinButton = view.findViewById(R.id.kbs_lock_pin_confirm);
errorLabel = view.findViewById(R.id.kbs_lock_pin_input_label);
keyboardToggle = view.findViewById(R.id.kbs_lock_keyboard_toggle);
forgotPin = view.findViewById(R.id.kbs_lock_forgot_pin);
timeRemaining = RegistrationLockFragmentArgs.fromBundle(requireArguments()).getTimeRemaining();
pinForgotButton.setOnClickListener(v -> handleForgottenPin(timeRemaining));
pinEntry.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable s) {
boolean matchesTextCode = s != null && s.toString().equals(code);
clarificationLabel.setVisibility(matchesTextCode ? View.VISIBLE : View.INVISIBLE);
subHeader.setVisibility(matchesTextCode ? View.INVISIBLE : View.VISIBLE);
}
});
forgotPin.setVisibility(View.GONE);
forgotPin.setOnClickListener(v -> handleForgottenPin(timeRemaining));
pinEntry.setImeOptions(EditorInfo.IME_ACTION_DONE);
pinEntry.setOnEditorActionListener((v, actionId, event) -> {
@@ -95,97 +81,141 @@ public final class RegistrationLockFragment extends BaseRegistrationFragment {
handlePinEntry();
});
RegistrationViewModel model = getModel();
model.getTokenResponseCredentialsPair()
.observe(this, pair -> {
TokenResponse token = pair.first();
String credentials = pair.second();
updateContinueText(token, credentials);
});
keyboardToggle.setOnClickListener((v) -> {
PinKeyboardType keyboardType = getPinEntryKeyboardType();
model.onRegistrationLockFragmentCreate();
}
updateKeyboard(keyboardType.getOther());
keyboardToggle.setText(resolveKeyboardToggleText(keyboardType));
});
private void updateContinueText(@Nullable TokenResponse tokenResponse, @Nullable String storageCredentials) {
if (tokenResponse == null) {
if (storageCredentials == null) {
pinButton.setIdleText(getString(R.string.RegistrationActivity_continue));
} else {
// TODO: This is the case where we can determine they are locked out
// no token, but do have storage credentials. Might want to change text.
pinButton.setIdleText(getString(R.string.RegistrationActivity_continue));
PinKeyboardType keyboardType = getPinEntryKeyboardType().getOther();
keyboardToggle.setText(resolveKeyboardToggleText(keyboardType));
getModel().getTimeRemaining()
.observe(getViewLifecycleOwner(), t -> timeRemaining = t);
TokenResponse keyBackupCurrentToken = getModel().getKeyBackupCurrentToken();
if (keyBackupCurrentToken != null) {
int triesRemaining = keyBackupCurrentToken.getTries();
if (triesRemaining <= 3) {
int daysRemaining = getLockoutDays(timeRemaining);
new AlertDialog.Builder(requireContext())
.setTitle(R.string.RegistrationLockFragment__not_many_tries_left)
.setMessage(getTriesRemainingDialogMessage(triesRemaining, daysRemaining))
.setPositiveButton(android.R.string.ok, null)
.show();
}
} else {
int triesRemaining = tokenResponse.getTries();
if (triesRemaining == 1) {
pinButton.setIdleText(getString(R.string.RegistrationActivity_continue_last_attempt));
} else {
pinButton.setIdleText(getString(R.string.RegistrationActivity_continue_d_attempts_left, triesRemaining));
if (triesRemaining < 5) {
errorLabel.setText(requireContext().getResources().getQuantityString(R.plurals.RegistrationLockFragment__d_attempts_remaining, triesRemaining, triesRemaining));
}
}
pinButton.setText(pinButton.getIdleText());
}
private String getTriesRemainingDialogMessage(int triesRemaining, int daysRemaining) {
Resources resources = requireContext().getResources();
String tries = resources.getQuantityString(R.plurals.RegistrationLockFragment__you_have_d_attempts_remaining, triesRemaining, triesRemaining);
String days = resources.getQuantityString(R.plurals.RegistrationLockFragment__if_you_run_out_of_attempts_your_account_will_be_locked_for_d_days, daysRemaining, daysRemaining);
return tries + " " + days;
}
private PinKeyboardType getPinEntryKeyboardType() {
boolean isNumeric = (pinEntry.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER;
return isNumeric ? PinKeyboardType.NUMERIC : PinKeyboardType.ALPHA_NUMERIC;
}
private void handlePinEntry() {
final String pin = pinEntry.getText().toString();
if (TextUtils.isEmpty(pin) || TextUtils.isEmpty(pin.replace(" ", ""))) {
int trimmedLength = pin.replace(" ", "").length();
if (trimmedLength == 0) {
Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show();
return;
}
RegistrationViewModel model = getModel();
RegistrationService registrationService = RegistrationService.getInstance(model.getNumber().getE164Number(), model.getRegistrationSecret());
String storageCredentials = model.getBasicStorageCredentials();
TokenResponse tokenResponse = model.getKeyBackupCurrentToken();
if (trimmedLength < MINIMUM_PIN_LENGTH) {
Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show();
return;
}
RegistrationViewModel model = getModel();
RegistrationService registrationService = RegistrationService.getInstance(model.getNumber().getE164Number(), model.getRegistrationSecret());
TokenResponse tokenResponse = model.getKeyBackupCurrentToken();
String basicStorageCredentials = model.getBasicStorageCredentials();
setSpinning(pinButton);
registrationService.verifyAccount(requireActivity(),
model.getFcmToken(),
model.getTextCodeEntered(),
pin, storageCredentials, tokenResponse,
pin,
basicStorageCredentials,
tokenResponse,
new CodeVerificationRequest.VerifyCallback() {
@Override
public void onSuccessfulRegistration() {
cancelSpinning(pinButton);
SignalStore.kbsValues().setKeyboardType(getPinEntryKeyboardType());
Navigation.findNavController(requireView()).navigate(RegistrationLockFragmentDirections.actionSuccessfulRegistration());
}
@Override
public void onIncorrectRegistrationLockPin(long timeRemaining, String storageCredentials) {
model.setStorageCredentials(storageCredentials);
cancelSpinning(pinButton);
public void onV1RegistrationLockPinRequiredOrIncorrect(long timeRemaining) {
getModel().setTimeRemaining(timeRemaining);
pinEntry.setText("");
Toast.makeText(requireContext(), R.string.RegistrationActivity_incorrect_registration_lock_pin, Toast.LENGTH_LONG).show();
cancelSpinning(pinButton);
pinEntry.getText().clear();
errorLabel.setText(R.string.RegistrationLockFragment__incorrect_pin);
}
@Override
public void onKbsRegistrationLockPinRequired(long timeRemaining, @NonNull TokenResponse kbsTokenResponse, @NonNull String kbsStorageCredentials) {
throw new AssertionError("Not expected after a pin guess");
}
@Override
public void onIncorrectKbsRegistrationLockPin(@NonNull TokenResponse tokenResponse) {
cancelSpinning(pinButton);
pinEntry.getText().clear();
model.setKeyBackupCurrentToken(tokenResponse);
int triesRemaining = tokenResponse.getTries();
if (triesRemaining == 0) {
handleForgottenPin(timeRemaining);
Log.w(TAG, "Account locked. User out of attempts on KBS.");
onAccountLocked();
return;
}
new AlertDialog.Builder(requireContext())
.setTitle(R.string.RegistrationActivity_pin_incorrect)
.setMessage(getString(R.string.RegistrationActivity_you_have_d_tries_remaining, triesRemaining))
.setPositiveButton(android.R.string.ok, null)
.show();
if (triesRemaining == 3) {
int daysRemaining = getLockoutDays(timeRemaining);
new AlertDialog.Builder(requireContext())
.setTitle(R.string.RegistrationLockFragment__incorrect_pin)
.setMessage(getTriesRemainingDialogMessage(triesRemaining, daysRemaining))
.setPositiveButton(android.R.string.ok, null)
.show();
}
if (triesRemaining > 5) {
errorLabel.setText(R.string.RegistrationLockFragment__incorrect_pin_try_again);
} else {
errorLabel.setText(requireContext().getResources().getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, triesRemaining, triesRemaining));
forgotPin.setVisibility(View.VISIBLE);
}
}
@Override
public void onTooManyAttempts() {
public void onRateLimited() {
cancelSpinning(pinButton);
new AlertDialog.Builder(requireContext())
@@ -195,6 +225,15 @@ public final class RegistrationLockFragment extends BaseRegistrationFragment {
.show();
}
@Override
public void onKbsAccountLocked(@Nullable Long timeRemaining) {
if (timeRemaining != null) {
model.setTimeRemaining(timeRemaining);
}
onAccountLocked();
}
@Override
public void onError() {
cancelSpinning(pinButton);
@@ -205,10 +244,36 @@ public final class RegistrationLockFragment extends BaseRegistrationFragment {
}
private void handleForgottenPin(long timeRemainingMs) {
int lockoutDays = getLockoutDays(timeRemainingMs);
new AlertDialog.Builder(requireContext())
.setTitle(R.string.RegistrationActivity_oh_no)
.setMessage(getString(R.string.RegistrationActivity_registration_of_this_phone_number_will_be_possible_without_your_registration_lock_pin_after_seven_days_have_passed, (TimeUnit.MILLISECONDS.toDays(timeRemainingMs) + 1)))
.setTitle(R.string.RegistrationLockFragment__forgot_your_pin)
.setMessage(requireContext().getResources().getQuantityString(R.plurals.RegistrationLockFragment__for_your_privacy_and_security_there_is_no_way_to_recover, lockoutDays, lockoutDays))
.setPositiveButton(android.R.string.ok, null)
.show();
}
private static int getLockoutDays(long timeRemainingMs) {
return (int) TimeUnit.MILLISECONDS.toDays(timeRemainingMs) + 1;
}
private void onAccountLocked() {
Navigation.findNavController(requireView()).navigate(RegistrationLockFragmentDirections.actionAccountLocked());
}
private void updateKeyboard(@NonNull PinKeyboardType keyboard) {
boolean isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC;
pinEntry.setInputType(isAlphaNumeric ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD
: InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
pinEntry.getText().clear();
}
private @StringRes static int resolveKeyboardToggleText(@NonNull PinKeyboardType keyboard) {
if (keyboard == PinKeyboardType.ALPHA_NUMERIC) {
return R.string.RegistrationLockFragment__enter_alphanumeric_pin;
} else {
return R.string.RegistrationLockFragment__enter_numeric_pin;
}
}
}

View File

@@ -281,7 +281,7 @@ public final class RestoreBackupFragment extends BaseRegistrationFragment {
FAILURE_UNKNOWN
}
private static class PassphraseAsYouTypeFormatter implements TextWatcher {
public static class PassphraseAsYouTypeFormatter implements TextWatcher {
private static final int GROUP_SIZE = 5;
@@ -292,7 +292,7 @@ public final class RestoreBackupFragment extends BaseRegistrationFragment {
addSpans(editable);
}
private void removeSpans(Editable editable) {
private static void removeSpans(Editable editable) {
SpaceSpan[] paddingSpans = editable.getSpans(0, editable.length(), SpaceSpan.class);
for (SpaceSpan span : paddingSpans) {
@@ -300,15 +300,15 @@ public final class RestoreBackupFragment extends BaseRegistrationFragment {
}
}
private void addSpans(Editable editable) {
private static void addSpans(Editable editable) {
final int length = editable.length();
for (int i = GROUP_SIZE; i < length; i += GROUP_SIZE) {
editable.setSpan(new SpaceSpan(), i - 1, i, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (editable.length() > 30) {
editable.delete(30, editable.length());
if (editable.length() > BackupUtil.PASSPHRASE_LENGTH) {
editable.delete(BackupUtil.PASSPHRASE_LENGTH, editable.length());
}
}

View File

@@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.push.AccountManagerFactory;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.libsignal.IdentityKeyPair;
import org.whispersystems.libsignal.state.PreKeyRecord;
@@ -36,6 +35,7 @@ import org.whispersystems.libsignal.util.KeyHelper;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.KeyBackupService;
import org.whispersystems.signalservice.api.KeyBackupServicePinException;
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException;
import org.whispersystems.signalservice.api.RegistrationLockData;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.kbs.HashedPin;
@@ -53,16 +53,12 @@ public final class CodeVerificationRequest {
private static final String TAG = Log.tag(CodeVerificationRequest.class);
static TokenResponse getToken(@Nullable String basicStorageCredentials) throws IOException {
if (basicStorageCredentials == null) return null;
return ApplicationDependencies.getKeyBackupService().getToken(basicStorageCredentials);
}
private enum Result {
SUCCESS,
PIN_LOCKED,
KBS_WRONG_PIN,
RATE_LIMITED,
KBS_ACCOUNT_LOCKED,
ERROR
}
@@ -87,17 +83,42 @@ public final class CodeVerificationRequest {
{
new AsyncTask<Void, Void, Result>() {
private volatile LockedException lockedException;
private volatile KeyBackupSystemWrongPinException keyBackupSystemWrongPinException;
private volatile LockedException lockedException;
private volatile TokenResponse kbsToken;
@Override
protected Result doInBackground(Void... voids) {
final boolean pinSupplied = pin != null;
final boolean tryKbs = kbsTokenResponse != null;
try {
verifyAccount(context, credentials, code, pin, basicStorageCredentials, kbsTokenResponse, fcmToken);
kbsToken = kbsTokenResponse;
verifyAccount(context, credentials, code, pin, kbsTokenResponse, basicStorageCredentials, fcmToken);
return Result.SUCCESS;
} catch (KeyBackupSystemNoDataException e) {
Log.w(TAG, "No data found on KBS");
return Result.KBS_ACCOUNT_LOCKED;
} catch (KeyBackupSystemWrongPinException e) {
kbsToken = e.getTokenResponse();
return Result.KBS_WRONG_PIN;
} catch (LockedException e) {
if (pinSupplied && tryKbs) {
throw new AssertionError("KBS Pin appeared to matched but reg lock still failed!");
}
Log.w(TAG, e);
lockedException = e;
if (e.getBasicStorageCredentials() != null) {
try {
kbsToken = getToken(e.getBasicStorageCredentials());
if (kbsToken == null || kbsToken.getTries() == 0) {
return Result.KBS_ACCOUNT_LOCKED;
}
} catch (IOException ex) {
Log.w(TAG, e);
return Result.ERROR;
}
}
return Result.PIN_LOCKED;
} catch (RateLimitException e) {
Log.w(TAG, e);
@@ -105,9 +126,6 @@ public final class CodeVerificationRequest {
} catch (IOException e) {
Log.w(TAG, e);
return Result.ERROR;
} catch (KeyBackupSystemWrongPinException e) {
keyBackupSystemWrongPinException = e;
return Result.KBS_WRONG_PIN;
}
}
@@ -119,22 +137,41 @@ public final class CodeVerificationRequest {
callback.onSuccessfulRegistration();
break;
case PIN_LOCKED:
callback.onIncorrectRegistrationLockPin(lockedException.getTimeRemaining(), lockedException.getBasicStorageCredentials());
if (kbsToken != null) {
if (lockedException.getBasicStorageCredentials() == null) {
throw new AssertionError("KBS Token set, but no storage credentials supplied.");
}
Log.w(TAG, "Reg Locked: V2 pin needed for registration");
callback.onKbsRegistrationLockPinRequired(lockedException.getTimeRemaining(), kbsToken, lockedException.getBasicStorageCredentials());
} else {
Log.w(TAG, "Reg Locked: V1 pin needed for registration");
callback.onV1RegistrationLockPinRequiredOrIncorrect(lockedException.getTimeRemaining());
}
break;
case RATE_LIMITED:
callback.onTooManyAttempts();
callback.onRateLimited();
break;
case ERROR:
callback.onError();
break;
case KBS_WRONG_PIN:
callback.onIncorrectKbsRegistrationLockPin(keyBackupSystemWrongPinException.getTokenResponse());
Log.w(TAG, "KBS Pin was wrong");
callback.onIncorrectKbsRegistrationLockPin(kbsToken);
break;
case KBS_ACCOUNT_LOCKED:
Log.w(TAG, "KBS Account is locked");
callback.onKbsAccountLocked(lockedException != null ? lockedException.getTimeRemaining() : null);
break;
}
}
}.execute();
}
private static TokenResponse getToken(@Nullable String basicStorageCredentials) throws IOException {
if (basicStorageCredentials == null) return null;
return ApplicationDependencies.getKeyBackupService().getToken(basicStorageCredentials);
}
private static void handleSuccessfulRegistration(@NonNull Context context) {
JobManager jobManager = ApplicationDependencies.getJobManager();
jobManager.add(new DirectoryRefreshJob(false));
@@ -148,11 +185,12 @@ public final class CodeVerificationRequest {
@NonNull Credentials credentials,
@NonNull String code,
@Nullable String pin,
@Nullable String basicStorageCredentials,
@Nullable TokenResponse kbsTokenResponse,
@Nullable String kbsStorageCredentials,
@Nullable String fcmToken)
throws IOException, KeyBackupSystemWrongPinException
throws IOException, KeyBackupSystemWrongPinException, KeyBackupSystemNoDataException
{
boolean isV2KbsPin = kbsTokenResponse != null;
int registrationId = KeyHelper.generateRegistrationId(false);
byte[] unidentifiedAccessKey = UnidentifiedAccessUtil.getSelfUnidentifiedAccessKey(context);
boolean universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context);
@@ -161,10 +199,10 @@ public final class CodeVerificationRequest {
SessionUtil.archiveAllSessions(context);
SignalServiceAccountManager accountManager = AccountManagerFactory.createUnauthenticated(context, credentials.getE164number(), credentials.getPassword());
RegistrationLockData kbsData = restoreMasterKey(pin, basicStorageCredentials, kbsTokenResponse);
RegistrationLockData kbsData = isV2KbsPin ? restoreMasterKey(pin, kbsStorageCredentials, kbsTokenResponse) : null;
String registrationLock = kbsData != null ? kbsData.getMasterKey().deriveRegistrationLock() : null;
boolean present = fcmToken != null;
String pinForServer = basicStorageCredentials == null ? pin : null;
String pinForServer = isV2KbsPin ? null : pin;
UUID uuid = accountManager.verifyAccountWithCode(code, null, registrationId, !present,
pinForServer, registrationLock,
@@ -212,12 +250,6 @@ public final class CodeVerificationRequest {
TextSecurePreferences.setDeprecatedRegistrationLockPin(context, pin);
//noinspection deprecation Only acceptable place to write the old pin enabled state.
TextSecurePreferences.setV1RegistrationLockEnabled(context, pin != null);
if (pin != null) {
if (FeatureFlags.kbs()) {
Log.i(TAG, "Pin V1 successfully entered during registration, scheduling a migration to Pin V2");
ApplicationDependencies.getJobManager().add(new RegistrationPinV2MigrationJob());
}
}
} else {
SignalStore.kbsValues().setRegistrationLockMasterKey(kbsData, PinHashing.localPinHash(pin));
repostPinToResetTries(context, pin, kbsData);
@@ -251,14 +283,13 @@ public final class CodeVerificationRequest {
private static @Nullable RegistrationLockData restoreMasterKey(@Nullable String pin,
@Nullable String basicStorageCredentials,
@Nullable TokenResponse tokenResponse)
throws IOException, KeyBackupSystemWrongPinException
@NonNull TokenResponse tokenResponse)
throws IOException, KeyBackupSystemWrongPinException, KeyBackupSystemNoDataException
{
if (pin == null) return null;
if (basicStorageCredentials == null) {
Log.i(TAG, "No storage credentials supplied, pin is not on KBS");
return null;
throw new AssertionError("Cannot restore KBS key, no storage credentials supplied");
}
KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService();
@@ -273,7 +304,7 @@ public final class CodeVerificationRequest {
if (kbsData != null) {
Log.i(TAG, "Found registration lock token on KBS.");
} else {
Log.i(TAG, "No KBS data found.");
throw new AssertionError("Null not expected");
}
return kbsData;
} catch (UnauthenticatedResponseException e) {
@@ -290,13 +321,32 @@ public final class CodeVerificationRequest {
void onSuccessfulRegistration();
/**
* The account is locked with a V1 (non-KBS) pin.
*
* @param timeRemaining Time until pin expires and number can be reused.
*/
void onIncorrectRegistrationLockPin(long timeRemaining, String storageCredentials);
void onV1RegistrationLockPinRequiredOrIncorrect(long timeRemaining);
/**
* The account is locked with a V2 (KBS) pin. Called before any user pin guesses.
*/
void onKbsRegistrationLockPinRequired(long timeRemaining, @NonNull TokenResponse kbsTokenResponse, @NonNull String kbsStorageCredentials);
/**
* The account is locked with a V2 (KBS) pin. Called after a user pin guess.
* <p>
* i.e. an attempt has likely been used.
*/
void onIncorrectKbsRegistrationLockPin(@NonNull TokenResponse kbsTokenResponse);
void onTooManyAttempts();
/**
* V2 (KBS) pin is set, but there is no data on KBS.
*
* @param timeRemaining Non-null if known.
*/
void onKbsAccountLocked(@Nullable Long timeRemaining);
void onRateLimited();
void onError();
}

View File

@@ -45,8 +45,4 @@ public final class RegistrationService {
{
CodeVerificationRequest.verifyAccount(activity, credentials, fcmToken, code, pin, basicStorageCredentials, tokenResponse, callback);
}
public @Nullable TokenResponse getToken(@Nullable String basicStorageCredentials) throws IOException {
return CodeVerificationRequest.getToken(basicStorageCredentials);
}
}

View File

@@ -6,15 +6,10 @@ import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.SavedStateHandle;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.registration.service.RegistrationService;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.livedata.LiveDataPair;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
import org.whispersystems.signalservice.internal.util.JsonUtil;
@@ -34,8 +29,7 @@ public final class RegistrationViewModel extends ViewModel {
private final MutableLiveData<Integer> successfulCodeRequestAttempts;
private final MutableLiveData<LocalCodeRequestRateLimiter> requestLimiter;
private final MutableLiveData<String> keyBackupcurrentTokenJson;
private final LiveData<TokenResponse> keyBackupcurrentToken;
private final LiveData<Pair<TokenResponse, String>> tokenResponseCredentialsPair;
private final MutableLiveData<Long> timeRemaining;
public RegistrationViewModel(@NonNull SavedStateHandle savedStateHandle) {
secret = loadValue(savedStateHandle, "REGISTRATION_SECRET", Util.getSecret(18));
@@ -49,19 +43,7 @@ public final class RegistrationViewModel extends ViewModel {
successfulCodeRequestAttempts = savedStateHandle.getLiveData("SUCCESSFUL_CODE_REQUEST_ATTEMPTS", 0);
requestLimiter = savedStateHandle.getLiveData("REQUEST_RATE_LIMITER", new LocalCodeRequestRateLimiter(60_000));
keyBackupcurrentTokenJson = savedStateHandle.getLiveData("KBS_TOKEN");
keyBackupcurrentToken = Transformations.map(keyBackupcurrentTokenJson, json ->
{
if (json == null) return null;
try {
return JsonUtil.fromJson(json, TokenResponse.class);
} catch (IOException e) {
Log.w(TAG, e);
return null;
}
});
tokenResponseCredentialsPair = new LiveDataPair<>(keyBackupcurrentToken, basicStorageCredentials);
timeRemaining = savedStateHandle.getLiveData("TIME_REMAINING", 0L);
}
private static <T> T loadValue(@NonNull SavedStateHandle savedStateHandle, @NonNull String key, @NonNull T initialValue) {
@@ -179,26 +161,26 @@ public final class RegistrationViewModel extends ViewModel {
}
public @Nullable TokenResponse getKeyBackupCurrentToken() {
return keyBackupcurrentToken.getValue();
String json = keyBackupcurrentTokenJson.getValue();
if (json == null) return null;
try {
return JsonUtil.fromJson(json, TokenResponse.class);
} catch (IOException e) {
Log.w(TAG, e);
return null;
}
}
public void setKeyBackupCurrentToken(TokenResponse tokenResponse) {
keyBackupcurrentTokenJson.setValue(tokenResponse == null ? null : JsonUtil.toJson(tokenResponse));
String json = tokenResponse == null ? null : JsonUtil.toJson(tokenResponse);
keyBackupcurrentTokenJson.setValue(json);
}
public LiveData<Pair<TokenResponse, String>> getTokenResponseCredentialsPair() {
return tokenResponseCredentialsPair;
public LiveData<Long> getTimeRemaining() {
return timeRemaining;
}
public void onRegistrationLockFragmentCreate() {
SimpleTask.run(() -> {
RegistrationService registrationService = RegistrationService.getInstance(getNumber().getE164Number(), getRegistrationSecret());
try {
return registrationService.getToken(getBasicStorageCredentials());
} catch (IOException e) {
Log.w(TAG, e);
return null;
}
}, this::setKeyBackupCurrentToken);
public void setTimeRemaining(long timeRemaining) {
this.timeRemaining.setValue(timeRemaining);
}
}

View File

@@ -23,6 +23,8 @@ public class BackupUtil {
private static final String TAG = BackupUtil.class.getSimpleName();
public static final int PASSPHRASE_LENGTH = 30;
public static @NonNull String getLastBackupTime(@NonNull Context context, @NonNull Locale locale) {
try {
BackupInfo backup = getLatestBackup();

View File

@@ -46,13 +46,13 @@ public final class FeatureFlags {
private static final String PREFIX = "android.";
private static final long FETCH_INTERVAL = TimeUnit.HOURS.toMillis(2);
private static final String UUIDS = generateKey("uuids");
private static final String PROFILE_DISPLAY = generateKey("profileDisplay");
private static final String MESSAGE_REQUESTS = generateKey("messageRequests");
private static final String USERNAMES = generateKey("usernames");
private static final String KBS = generateKey("kbs");
private static final String STORAGE_SERVICE = generateKey("storageService");
private static final String REACTION_SENDING = generateKey("reactionSending");
private static final String UUIDS = generateKey("uuids");
private static final String PROFILE_DISPLAY = generateKey("profileDisplay");
private static final String MESSAGE_REQUESTS = generateKey("messageRequests");
private static final String USERNAMES = generateKey("usernames");
private static final String STORAGE_SERVICE = generateKey("storageService");
private static final String PINS_FOR_ALL = generateKey("beta.pinsForAll"); // TODO [alex] remove beta prefix
private static final String PINS_MEGAPHONE_KILL_SWITCH = generateKey("pinsMegaphoneKillSwitch");
/**
* Values in this map will take precedence over any value. If you do not wish to have any sort of
@@ -75,13 +75,14 @@ public final class FeatureFlags {
* more burden on the reader to ensure that the app experience remains consistent.
*/
private static final Set<String> HOT_SWAPPABLE = Sets.newHashSet(
KBS
PINS_MEGAPHONE_KILL_SWITCH
);
/**
* Flags in this set will stay true forever once they receive a true value from a remote config.
*/
private static final Set<String> STICKY = Sets.newHashSet(
PINS_FOR_ALL // TODO [alex] -- add android.beta.pinsForAll to sticky set when we remove prefix
);
private static final Map<String, Boolean> REMOTE_VALUES = new TreeMap<>();
@@ -141,21 +142,19 @@ public final class FeatureFlags {
return value;
}
/** Set or migrate PIN to KBS */
public static boolean kbs() {
return getValue(KBS, false);
}
/** Storage service. Requires {@link #kbs()}. */
/** Storage service. */
public static boolean storageService() {
boolean value = getValue(STORAGE_SERVICE, false);
if (value && !kbs()) throw new MissingFlagRequirementError();
return value;
return getValue(STORAGE_SERVICE, false);
}
/** Send support for reactions. */
public static synchronized boolean reactionSending() {
return getValue(REACTION_SENDING, false);
/** Enables new KBS UI and notices but does not require user to set a pin */
public static boolean pinsForAll() {
return SignalStore.registrationValues().pinWasRequiredAtRegistration() || getValue(PINS_FOR_ALL, false);
}
/** Safety flag to disable Pins for All Megaphone */
public static boolean pinsForAllMegaphoneKillSwitch() {
return getValue(PINS_MEGAPHONE_KILL_SWITCH, false);
}
/** Only for rendering debug info. */

View File

@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.util;
public final class RequestCodes {
public static final int NOT_SET = -1;
private RequestCodes() { }
}

View File

@@ -150,6 +150,7 @@ public class TextSecurePreferences {
private static final String ENCRYPTED_BACKUP_PASSPHRASE = "pref_encrypted_backup_passphrase";
private static final String BACKUP_TIME = "pref_backup_next_time";
public static final String BACKUP_NOW = "pref_backup_create";
public static final String BACKUP_PASSPHRASE_VERIFY = "pref_backup_passphrase_verify";
public static final String SCREEN_LOCK = "pref_android_screen_lock";
public static final String SCREEN_LOCK_TIMEOUT = "pref_android_screen_lock_timeout";
@@ -159,10 +160,11 @@ public class TextSecurePreferences {
@Deprecated
private static final String REGISTRATION_LOCK_PIN_PREF_V1 = "pref_registration_lock_pin";
private static final String REGISTRATION_LOCK_LAST_REMINDER_TIME = "pref_registration_lock_last_reminder_time";
private static final String REGISTRATION_LOCK_LAST_REMINDER_TIME_POST_KBS = "pref_registration_lock_last_reminder_time_post_kbs";
private static final String REGISTRATION_LOCK_NEXT_REMINDER_INTERVAL = "pref_registration_lock_next_reminder_interval";
public static final String KBS_PIN_CHANGE = "pref_kbs_change";
private static final String SERVICE_OUTAGE = "pref_service_outage";
private static final String LAST_OUTAGE_CHECK_TIME = "pref_last_outage_check_time";
@@ -269,16 +271,11 @@ public class TextSecurePreferences {
}
public static long getRegistrationLockLastReminderTime(@NonNull Context context) {
return getLongPreference(context, getAppropriateReminderKey(), 0);
return getLongPreference(context, REGISTRATION_LOCK_LAST_REMINDER_TIME_POST_KBS, 0);
}
public static void setRegistrationLockLastReminderTime(@NonNull Context context, long time) {
setLongPreference(context, getAppropriateReminderKey(), time);
}
private static String getAppropriateReminderKey() {
return FeatureFlags.kbs() ? REGISTRATION_LOCK_LAST_REMINDER_TIME_POST_KBS
: REGISTRATION_LOCK_LAST_REMINDER_TIME;
setLongPreference(context, REGISTRATION_LOCK_LAST_REMINDER_TIME_POST_KBS, time);
}
public static long getRegistrationLockNextReminderInterval(@NonNull Context context) {

View File

@@ -509,6 +509,10 @@ public class Util {
return Math.min(Math.max(value, min), max);
}
public static long clamp(long value, long min, long max) {
return Math.min(Math.max(value, min), max);
}
public static float clamp(float value, float min, float max) {
return Math.min(Math.max(value, min), max);
}

View File

@@ -233,6 +233,16 @@ public class ViewUtil {
view.requestLayout();
}
public static void setRightMargin(@NonNull View view, int margin) {
if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin = margin;
} else {
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin = margin;
}
view.forceLayout();
view.requestLayout();
}
public static void setTopMargin(@NonNull View view, int margin) {
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).topMargin = margin;
view.requestLayout();

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