Compare commits

..

31 Commits

Author SHA1 Message Date
Greyson Parrelli
82305ce2b3 Bump version to 4.56.4 2020-03-02 09:55:04 -05:00
Greyson Parrelli
eaf73edcad Updated language translations. 2020-03-02 09:54:35 -05:00
Greyson Parrelli
543a4ee177 Fix backup restore crash. 2020-03-02 09:48:18 -05:00
Greyson Parrelli
fd2a464bae Use internal contact viewer for avatar clicks. 2020-03-02 08:33:25 -05:00
Alex Hart
b06152ba58 Fix and simplify Y translation calculation for Conversation. 2020-03-02 08:57:11 -04:00
Greyson Parrelli
c24d285cd3 Bump version to 4.56.3 2020-02-28 17:35:13 -05:00
Greyson Parrelli
be39cd653e Updated language translations. 2020-02-28 17:35:13 -05:00
Greyson Parrelli
6813f47bc1 Remove feature flags that are no longer remote capable. 2020-02-28 17:35:10 -05:00
Greyson Parrelli
8e795c4177 Read the sticker length during backup import. 2020-02-28 16:58:47 -05:00
Greyson Parrelli
9c96afee09 Fix storage service crash with new users. 2020-02-28 16:58:47 -05:00
Greyson Parrelli
d507be0ab0 Don't backup the KeyValueDatabase. 2020-02-28 16:58:47 -05:00
Greyson Parrelli
75a52f801a Implement storage service protocol changes. 2020-02-28 16:58:47 -05:00
Greyson Parrelli
d3b123f3a9 Fix StorageSyncHelperTest. 2020-02-28 08:07:36 -05:00
Greyson Parrelli
da3cdd984b Bump version to 4.56.2 2020-02-26 17:57:36 -05:00
Greyson Parrelli
6184e5f828 Update the storage service. 2020-02-26 17:11:34 -05:00
Curt Brune
133bd44b85 Update ringrtc to v1.0.2 2020-02-26 17:11:34 -05:00
Greyson Parrelli
0c254c9621 Improve debuglog submission. 2020-02-26 17:11:34 -05:00
Greyson Parrelli
1faf196f82 Implement additional message request improvements. 2020-02-26 17:11:29 -05:00
Greyson Parrelli
81c7887d47 Switch language string to 'System default.' 2020-02-26 17:08:27 -05:00
Greyson Parrelli
e62e630987 Fix theming issue in CameraContactSelectionFragment. 2020-02-26 17:08:27 -05:00
Alex Hart
739e38a047 Fix reaction details bottom sheet scrolling. 2020-02-26 17:08:27 -05:00
Greyson Parrelli
8c23b17517 Fix some state issues post backup restore. 2020-02-26 17:08:27 -05:00
Greyson Parrelli
fda8f3e1ce Refer to yourself as 'you' in reactions and group membership. 2020-02-26 17:08:27 -05:00
Alex Hart
9e5f64c431 Improve message requests, add megaphone. 2020-02-26 17:08:27 -05:00
Alex Hart
dc689d325b Various PIN bug fixes. 2020-02-26 17:06:21 -05:00
Greyson Parrelli
0a883dc234 Fix issue with mimeType resolution in share flow. 2020-02-26 17:06:06 -05:00
Greyson Parrelli
3824e90997 Improve prekey refresh logic. 2020-02-26 17:06:06 -05:00
Greyson Parrelli
5158a15379 Disable PIN requirement for new registrations. 2020-02-26 17:06:06 -05:00
Curt Brune
1bae79af5b Check callManager reference is still valid in ListenableFutureTask callbacks. 2020-02-26 17:06:04 -05:00
Curt Brune
58b7612987 Drop requests to deny stale incoming calls.
This is not an illegal state, as the remote side could have hung-up
a microsecond before the local side tries to deny the call.
2020-02-26 17:06:02 -05:00
Curt Brune
9506da6dd3 Validate activePeer during Bluetooth and Speaker audio state transitions. 2020-02-26 17:05:54 -05:00
269 changed files with 15967 additions and 5158 deletions

View File

@@ -1,6 +1,6 @@
[main]
host = https://www.transifex.com
lang_map = da_DK:da-rDK,he:iw,id:in,kn_IN:kn-rIN,pt_BR:pt-rBR,pt_PT:pt,qu_EC:qu-rEC,sv_SE:sv-rSE,zh_CN:zh-rCN,zh_HK:zh-rHK,zh_TW:zh-rTW
lang_map = da_DK:da-rDK,fil:tl,he:iw,id:in,kn_IN:kn-rIN,pa_PK:pa-rPK,pt_BR:pt-rBR,pt_PT:pt,qu_EC:qu-rEC,sv_SE:sv-rSE,zh_CN:zh-rCN,zh_HK:zh-rHK,zh_TW:zh-rTW
[signal-android.master]
file_filter = src/main/res/values-<lang>/strings.xml

View File

@@ -119,7 +119,7 @@ dependencies {
implementation 'org.signal:argon2:13.1@aar'
implementation 'org.signal:ringrtc-android:1.0.1'
implementation 'org.signal:ringrtc-android:1.0.2'
implementation "me.leolin:ShortcutBadger:1.1.16"
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
@@ -188,8 +188,8 @@ dependencyVerification {
configuration = '(play|website)(Debug|Release)RuntimeClasspath'
}
def canonicalVersionCode = 607
def canonicalVersionName = "4.56.1"
def canonicalVersionCode = 610
def canonicalVersionName = "4.56.4"
def postFixSize = 10
def abiPostFix = ['universal' : 0,

View File

@@ -337,12 +337,12 @@
android:label="@string/AndroidManifest__linked_devices"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".LogSubmitActivity"
<activity android:name=".logsubmit.SubmitDebugLogActivity"
android:label="@string/AndroidManifest__log_submit"
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".MediaPreviewActivity"
<activity android:name=".MediaPreviewActivity"
android:label="@string/AndroidManifest__media_preview"
android:windowSoftInputMode="stateHidden"
android:launchMode="singleTask"
@@ -442,6 +442,11 @@
</intent-filter>
</activity>
<activity android:name=".messagerequests.MessageRequestMegaphoneActivity"
android:theme="@style/TextSecure.LightRegistrationTheme"
android:windowSoftInputMode="adjustResize"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".contactshare.ContactShareEditActivity"
android:theme="@style/TextSecure.LightTheme"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.insights.InsightsOptOut;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.stickers.BlessedPacks;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
/**
* Rule of thumb: if there's something you want to do on the first app launch that involves
* persisting state to the database, you'll almost certainly *also* want to do it post backup
* restore, since a backup restore will wipe the current state of the database.
*/
public final class AppInitialization {
private static final String TAG = Log.tag(AppInitialization.class);
private AppInitialization() {}
public static void onFirstEverAppLaunch(@NonNull Context context) {
Log.i(TAG, "onFirstEverAppLaunch()");
InsightsOptOut.userRequestedOptOut(context);
TextSecurePreferences.setAppMigrationVersion(context, ApplicationMigrations.CURRENT_VERSION);
TextSecurePreferences.setJobManagerVersion(context, JobManager.CURRENT_VERSION);
TextSecurePreferences.setLastExperienceVersionCode(context, Util.getCanonicalVersionCode());
TextSecurePreferences.setHasSeenStickerIntroTooltip(context, true);
ApplicationDependencies.getMegaphoneRepository().onFirstEverAppLaunch();
SignalStore.onFirstEverAppLaunch();
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false));
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.BANDIT.getPackId(), BlessedPacks.BANDIT.getPackKey(), false));
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_HANDS.getPackId(), BlessedPacks.SWOON_HANDS.getPackKey()));
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_FACES.getPackId(), BlessedPacks.SWOON_FACES.getPackKey()));
}
public static void onPostBackupRestore(@NonNull Context context) {
Log.i(TAG, "onPostBackupRestore()");
ApplicationDependencies.getMegaphoneRepository().onFirstEverAppLaunch();
SignalStore.onFirstEverAppLaunch();
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false));
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.BANDIT.getPackId(), BlessedPacks.BANDIT.getPackKey(), false));
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_HANDS.getPackId(), BlessedPacks.SWOON_HANDS.getPackKey()));
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_FACES.getPackId(), BlessedPacks.SWOON_FACES.getPackKey()));
}
}

View File

@@ -42,15 +42,12 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
import org.thoughtcrime.securesms.gcm.FcmJobService;
import org.thoughtcrime.securesms.insights.InsightsOptOut;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob;
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
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.jobs.RefreshPreKeysJob;
import org.thoughtcrime.securesms.jobs.StorageSyncJob;
import org.thoughtcrime.securesms.logging.AndroidLogger;
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
import org.thoughtcrime.securesms.logging.Log;
@@ -58,10 +55,12 @@ import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.logging.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.migrations.StorageServiceMigrationJob;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
@@ -72,10 +71,8 @@ import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager;
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
import org.thoughtcrime.securesms.stickers.BlessedPacks;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
import org.webrtc.voiceengine.WebRtcAudioManager;
@@ -138,6 +135,8 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
initializeCameraX();
FeatureFlags.init();
NotificationChannels.create(this);
RefreshPreKeysJob.scheduleIfNecessary();
StorageSyncJob.scheduleIfNecessary();
ProcessLifecycleOwner.get().getLifecycle().addObserver(this);
if (Build.VERSION.SDK_INT < 21) {
@@ -151,7 +150,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
public void onStart(@NonNull LifecycleOwner owner) {
isAppVisible = true;
Log.i(TAG, "App is now visible.");
FeatureFlags.refresh();
FeatureFlags.refreshIfNecessary();
ApplicationDependencies.getRecipientCache().warmUp();
executePendingContactSync();
KeyCachingService.onAppForegrounded(this);
@@ -244,18 +243,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
if (TextSecurePreferences.getFirstInstallVersion(this) == -1) {
if (!SQLCipherOpenHelper.databaseFileExists(this)) {
Log.i(TAG, "First ever app launch!");
InsightsOptOut.userRequestedOptOut(this);
TextSecurePreferences.setAppMigrationVersion(this, ApplicationMigrations.CURRENT_VERSION);
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));
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_HANDS.getPackId(), BlessedPacks.SWOON_HANDS.getPackKey()));
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_FACES.getPackId(), BlessedPacks.SWOON_FACES.getPackKey()));
AppInitialization.onFirstEverAppLaunch(this);
}
Log.i(TAG, "Setting first install version to " + BuildConfig.CANONICAL_VERSION_CODE);

View File

@@ -197,7 +197,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity
recipientsEditor.setHint(R.string.recipients_panel__add_members);
recipientsPanel.setPanelChangeListener(this);
findViewById(R.id.contacts_button).setOnClickListener(new AddRecipientButtonListener());
avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_group_outline_40, R.drawable.ic_group_outline_20).asDrawable(this, ContactColors.UNKNOWN_COLOR.toConversationColor(this)));
avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_group_outline_34, R.drawable.ic_group_outline_20).asDrawable(this, ContactColors.UNKNOWN_COLOR.toConversationColor(this)));
avatar.setOnClickListener(view -> AvatarSelection.startAvatarSelection(this, false, false));
}

View File

@@ -102,7 +102,7 @@ public class GroupMembersDialog extends AsyncTask<Void, Void, List<Recipient>> {
for (Recipient recipient : members) {
if (recipient.isLocalNumber()) {
recipientStrings.add(context.getString(R.string.GroupMembersDialog_me));
recipientStrings.add(context.getString(R.string.GroupMembersDialog_you));
} else {
String name = getRecipientName(recipient);
recipientStrings.add(name);

View File

@@ -1,78 +0,0 @@
package org.thoughtcrime.securesms;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.os.Bundle;
import androidx.fragment.app.FragmentTransaction;
import org.thoughtcrime.securesms.logging.Log;
import android.view.MenuItem;
import android.widget.Toast;
import org.thoughtcrime.securesms.logsubmit.SubmitLogFragment;
import org.thoughtcrime.securesms.util.DynamicTheme;
/**
* Activity for submitting logcat logs to a pastebin service.
*/
public class LogSubmitActivity extends BaseActionBarActivity implements SubmitLogFragment.OnLogSubmittedListener {
private static final String TAG = LogSubmitActivity.class.getSimpleName();
private DynamicTheme dynamicTheme = new DynamicTheme();
@Override
protected void onCreate(Bundle icicle) {
dynamicTheme.onCreate(this);
super.onCreate(icicle);
setContentView(R.layout.log_submit_activity);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
SubmitLogFragment fragment = SubmitLogFragment.newInstance();
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
transaction.replace(R.id.fragment_container, fragment);
transaction.commit();
}
@Override
protected void onResume() {
dynamicTheme.onResume(this);
super.onResume();
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
super.onOptionsItemSelected(item);
switch (item.getItemId()) {
case android.R.id.home:
finish();
return true;
}
return false;
}
@Override
public void onSuccess() {
Toast.makeText(getApplicationContext(), R.string.log_submit_activity__thanks, Toast.LENGTH_LONG).show();
finish();
}
@Override
public void onFailure() {
Toast.makeText(getApplicationContext(), R.string.log_submit_activity__log_fetch_failed, Toast.LENGTH_LONG).show();
finish();
}
@Override
public void onCancel() {
finish();
}
@Override
public void startActivity(Intent intent) {
try {
super.startActivity(intent);
} catch (ActivityNotFoundException e) {
Log.w(TAG, e);
Toast.makeText(this, R.string.log_submit_activity__no_browser_installed, Toast.LENGTH_LONG).show();
}
}
}

View File

@@ -55,6 +55,7 @@ import org.thoughtcrime.securesms.components.AnimatingToggle;
import org.thoughtcrime.securesms.crypto.InvalidPassphraseException;
import org.thoughtcrime.securesms.crypto.MasterSecret;
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity;
import org.thoughtcrime.securesms.util.DynamicIntroTheme;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -164,7 +165,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
}
private void handleLogSubmit() {
Intent intent = new Intent(this, LogSubmitActivity.class);
Intent intent = new Intent(this, SubmitDebugLogActivity.class);
startActivity(intent);
}

View File

@@ -185,7 +185,9 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA
}
private boolean userMustSetKbsPin() {
return !SignalStore.registrationValues().isRegistrationComplete() && !PinUtil.userHasPin(this);
// TODO [greyson] [pins] Maybe re-enable in the future
// return !SignalStore.registrationValues().isRegistrationComplete() && !PinUtil.userHasPin(this);
return false;
}
private boolean userMustSetProfileName() {

View File

@@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.conversation.ConversationActivity;
import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.IdentityDatabase;
@@ -95,8 +96,7 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
{
private static final String TAG = RecipientPreferenceActivity.class.getSimpleName();
public static final String RECIPIENT_ID = "recipient_address";
public static final String CAN_HAVE_SAFETY_NUMBER_EXTRA = "can_have_safety_number";
public static final String RECIPIENT_ID = "recipient";
private static final String PREFERENCE_MUTED = "pref_key_recipient_mute";
private static final String PREFERENCE_MESSAGE_TONE = "pref_key_recipient_ringtone";
@@ -119,6 +119,13 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
private ThreadPhotoRailView threadPhotoRailView;
private CollapsingToolbarLayout toolbarLayout;
public static @NonNull Intent getLaunchIntent(@NonNull Context context, @NonNull RecipientId id) {
Intent intent = new Intent(context, RecipientPreferenceActivity.class);
intent.putExtra(RecipientPreferenceActivity.RECIPIENT_ID, id);
return intent;
}
@Override
public void onPreCreate() {
dynamicTheme.onCreate(this);
@@ -166,12 +173,6 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
return false;
}
@Override
public void onBackPressed() {
finish();
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out);
}
private void initializeToolbar() {
this.toolbarLayout = ViewUtil.findById(this, R.id.collapsing_toolbar);
this.avatar = ViewUtil.findById(this, R.id.avatar);
@@ -284,8 +285,7 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
initializeRecipients();
this.canHaveSafetyNumber = getActivity().getIntent()
.getBooleanExtra(RecipientPreferenceActivity.CAN_HAVE_SAFETY_NUMBER_EXTRA, false);
this.canHaveSafetyNumber = recipient.get().isRegistered() && !recipient.get().isLocalNumber();
Preference customNotificationsPref = this.findPreference(PREFERENCE_CUSTOM_NOTIFICATIONS);

View File

@@ -44,6 +44,7 @@ import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.StorageSyncJob;
import org.thoughtcrime.securesms.logging.Log;
import android.view.ContextMenu;
import android.view.ContextMenu.ContextMenuInfo;
@@ -604,6 +605,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActionBarActivity
remoteIdentity,
isChecked ? VerifiedStatus.VERIFIED :
VerifiedStatus.DEFAULT));
ApplicationDependencies.getJobManager().add(new StorageSyncJob());
IdentityUtil.markIdentityVerified(getActivity(), recipient.get(), isChecked, false);
}

View File

@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
import org.thoughtcrime.securesms.database.JobDatabase;
import org.thoughtcrime.securesms.database.KeyValueDatabase;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase;
@@ -74,7 +75,8 @@ public class FullBackupExporter extends FullBackupBase {
SearchDatabase.MMS_FTS_TABLE_NAME,
JobDatabase.JOBS_TABLE_NAME,
JobDatabase.CONSTRAINTS_TABLE_NAME,
JobDatabase.DEPENDENCIES_TABLE_NAME
JobDatabase.DEPENDENCIES_TABLE_NAME,
KeyValueDatabase.TABLE_NAME
);
public static void export(@NonNull Context context,

View File

@@ -165,6 +165,7 @@ public class FullBackupImporter extends FullBackupBase {
ContentValues contentValues = new ContentValues();
contentValues.put(StickerDatabase.FILE_PATH, dataFile.getAbsolutePath());
contentValues.put(StickerDatabase.FILE_LENGTH, sticker.getLength());
contentValues.put(StickerDatabase.FILE_RANDOM, output.first);
db.update(StickerDatabase.TABLE_NAME, contentValues,

View File

@@ -16,6 +16,8 @@ import androidx.appcompat.widget.AppCompatImageView;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.RecipientPreferenceActivity;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.contacts.avatars.ContactColors;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
@@ -49,10 +51,11 @@ public final class AvatarImageView extends AppCompatImageView {
DARK_THEME_OUTLINE_PAINT.setAntiAlias(true);
}
private int size;
private boolean inverted;
private Paint outlinePaint;
private OnClickListener listener;
private int size;
private boolean inverted;
private Paint outlinePaint;
private OnClickListener listener;
private Recipient.FallbackPhotoProvider fallbackPhotoProvider;
private @Nullable RecipientContactPhoto recipientContactPhoto;
private @NonNull Drawable unknownRecipientDrawable;
@@ -102,6 +105,10 @@ public final class AvatarImageView extends AppCompatImageView {
super.setOnClickListener(listener);
}
public void setFallbackPhotoProvider(Recipient.FallbackPhotoProvider fallbackPhotoProvider) {
this.fallbackPhotoProvider = fallbackPhotoProvider;
}
public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, boolean quickContactEnabled) {
if (recipient != null) {
RecipientContactPhoto photo = new RecipientContactPhoto(recipient);
@@ -111,8 +118,8 @@ public final class AvatarImageView extends AppCompatImageView {
recipientContactPhoto = photo;
Drawable fallbackContactPhotoDrawable = size == SIZE_SMALL
? photo.recipient.getSmallFallbackContactPhotoDrawable(getContext(), inverted)
: photo.recipient.getFallbackContactPhotoDrawable(getContext(), inverted);
? photo.recipient.getSmallFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider)
: photo.recipient.getFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider);
if (photo.contactPhoto != null) {
requestManager.load(photo.contactPhoto)
@@ -130,19 +137,21 @@ public final class AvatarImageView extends AppCompatImageView {
} else {
recipientContactPhoto = null;
requestManager.clear(this);
setImageDrawable(unknownRecipientDrawable);
if (fallbackPhotoProvider != null) {
setImageDrawable(fallbackPhotoProvider.getPhotoForRecipientWithoutName()
.asDrawable(getContext(), MaterialColor.STEEL.toAvatarColor(getContext()), inverted));
} else {
setImageDrawable(unknownRecipientDrawable);
}
super.setOnClickListener(listener);
}
}
private void setAvatarClickHandler(final Recipient recipient, boolean quickContactEnabled) {
super.setOnClickListener(v -> {
if (!recipient.isGroup() && quickContactEnabled) {
if (recipient.getContactUri() != null) {
ContactsContract.QuickContact.showQuickContact(getContext(), AvatarImageView.this, recipient.getContactUri(), ContactsContract.QuickContact.MODE_LARGE, null);
} else {
getContext().startActivity(RecipientExporter.export(recipient).asAddContactIntent());
}
if (quickContactEnabled) {
getContext().startActivity(RecipientPreferenceActivity.getLaunchIntent(getContext(), recipient.getId()));
} else if (listener != null) {
listener.onClick(v);
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.HorizontalScrollView;
import androidx.annotation.Nullable;
/**
* Unfortunately {@link HorizontalScrollView#setOnScrollChangeListener(OnScrollChangeListener)}
* wasn't added until API 23, so now we have to do this ourselves.
*/
public class ListenableHorizontalScrollView extends HorizontalScrollView {
private OnScrollListener listener;
public ListenableHorizontalScrollView(Context context) {
super(context);
}
public ListenableHorizontalScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public void setOnScrollListener(@Nullable OnScrollListener listener) {
this.listener = listener;
}
@Override
protected void onScrollChanged(int newLeft, int newTop, int oldLeft, int oldTop) {
if (listener != null) {
listener.onScroll(newLeft, oldLeft);
}
super.onScrollChanged(newLeft, newTop, oldLeft, oldTop);
}
public interface OnScrollListener {
void onScroll(int newLeft, int oldLeft);
}
}

View File

@@ -21,6 +21,7 @@ public class MaskView extends View {
private ViewGroup activityContentView;
private Paint maskPaint;
private Rect drawingRect = new Rect();
private float targetParentTranslationY;
private final ViewTreeObserver.OnDrawListener onDrawListener = this::invalidate;
@@ -63,6 +64,10 @@ public class MaskView extends View {
invalidate();
}
public void setTargetParentTranslationY(float targetParentTranslationY) {
this.targetParentTranslationY = targetParentTranslationY;
}
@Override
protected void onDraw(@NonNull Canvas canvas) {
super.onDraw(canvas);
@@ -75,6 +80,8 @@ public class MaskView extends View {
activityContentView.offsetDescendantRectToMyCoords(target, drawingRect);
drawingRect.bottom = Math.min(drawingRect.bottom, getBottom() - getPaddingBottom());
drawingRect.top += targetParentTranslationY;
drawingRect.bottom += targetParentTranslationY;
Bitmap mask = Bitmap.createBitmap(target.getWidth(), drawingRect.height(), Bitmap.Config.ARGB_8888);
Canvas maskCanvas = new Canvas(mask);

View File

@@ -11,7 +11,6 @@ import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import java.util.ArrayList;

View File

@@ -8,6 +8,8 @@ import androidx.annotation.WorkerThread;
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.StorageSyncJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.FeatureFlags;
@@ -15,8 +17,15 @@ import java.io.IOException;
public class DirectoryHelper {
private static final String TAG = Log.tag(DirectoryHelper.class);
@WorkerThread
public static void refreshDirectory(@NonNull Context context, boolean notifyOfNewUsers) throws IOException {
if (!SignalStore.storageServiceValues().hasFirstStorageSyncCompleted()) {
Log.i(TAG, "First storage sync has not completed. Skipping.");
return;
}
if (FeatureFlags.uuids()) {
// TODO [greyson] Create a DirectoryHelperV2 when appropriate.
DirectoryHelperV1.refreshDirectory(context, notifyOfNewUsers);
@@ -24,9 +33,7 @@ public class DirectoryHelper {
DirectoryHelperV1.refreshDirectory(context, notifyOfNewUsers);
}
if (FeatureFlags.storageService()) {
ApplicationDependencies.getJobManager().add(new StorageSyncJob());
}
ApplicationDependencies.getJobManager().add(new StorageSyncJob());
}
@WorkerThread
@@ -41,7 +48,7 @@ public class DirectoryHelper {
newRegisteredState = DirectoryHelperV1.refreshDirectoryFor(context, recipient, notifyOfNewUsers);
}
if (FeatureFlags.storageService() && newRegisteredState != originalRegisteredState) {
if (newRegisteredState != originalRegisteredState) {
ApplicationDependencies.getJobManager().add(new StorageSyncJob());
}

View File

@@ -4,18 +4,22 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.SetUtil;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
import org.whispersystems.signalservice.api.storage.SignalContactRecord.IdentityState;
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record;
import org.whispersystems.signalservice.api.storage.SignalStorageManifest;
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
import org.whispersystems.signalservice.api.util.OptionalUtil;
@@ -28,11 +32,14 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import javax.crypto.KeyGenerator;
public final class StorageSyncHelper {
private static final String TAG = Log.tag(StorageSyncHelper.class);
@@ -42,7 +49,7 @@ public final class StorageSyncHelper {
private static KeyGenerator testKeyGenerator = null;
/**
* Given the local state of pending storage mutatations, this will generate a result that will
* Given the local state of pending storage mutations, this will generate a result that will
* include that data that needs to be written to the storage service, as well as any changes you
* need to write back to local storage (like storage keys that might have changed for updated
* contacts).
@@ -64,17 +71,17 @@ public final class StorageSyncHelper {
@NonNull List<RecipientSettings> deletes)
{
Set<ByteBuffer> completeKeys = new LinkedHashSet<>(Stream.of(currentLocalKeys).map(ByteBuffer::wrap).toList());
Set<SignalContactRecord> contactInserts = new LinkedHashSet<>();
Set<ByteBuffer> contactDeletes = new LinkedHashSet<>();
Set<SignalStorageRecord> storageInserts = new LinkedHashSet<>();
Set<ByteBuffer> storageDeletes = new LinkedHashSet<>();
Map<RecipientId, byte[]> storageKeyUpdates = new HashMap<>();
for (RecipientSettings insert : inserts) {
contactInserts.add(localToRemoteContact(insert));
storageInserts.add(localToRemoteRecord(insert));
}
for (RecipientSettings delete : deletes) {
byte[] key = Objects.requireNonNull(delete.getStorageKey());
contactDeletes.add(ByteBuffer.wrap(key));
storageDeletes.add(ByteBuffer.wrap(key));
completeKeys.remove(ByteBuffer.wrap(key));
}
@@ -82,21 +89,20 @@ public final class StorageSyncHelper {
byte[] oldKey = Objects.requireNonNull(update.getStorageKey());
byte[] newKey = generateKey();
contactInserts.add(localToRemoteContact(update, newKey));
contactDeletes.add(ByteBuffer.wrap(oldKey));
storageInserts.add(localToRemoteRecord(update, newKey));
storageDeletes.add(ByteBuffer.wrap(oldKey));
completeKeys.remove(ByteBuffer.wrap(oldKey));
completeKeys.add(ByteBuffer.wrap(newKey));
storageKeyUpdates.put(update.getId(), newKey);
}
if (contactInserts.isEmpty() && contactDeletes.isEmpty()) {
if (storageInserts.isEmpty() && storageDeletes.isEmpty()) {
return Optional.absent();
} else {
List<SignalStorageRecord> storageInserts = Stream.of(contactInserts).map(c -> SignalStorageRecord.forContact(c.getKey(), c)).toList();
List<byte[]> contactDeleteBytes = Stream.of(contactDeletes).map(ByteBuffer::array).toList();
List<byte[]> contactDeleteBytes = Stream.of(storageDeletes).map(ByteBuffer::array).toList();
List<byte[]> completeKeysBytes = Stream.of(completeKeys).map(ByteBuffer::array).toList();
SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, completeKeysBytes);
WriteOperationResult writeOperationResult = new WriteOperationResult(manifest, storageInserts, contactDeleteBytes);
WriteOperationResult writeOperationResult = new WriteOperationResult(manifest, new ArrayList<>(storageInserts), contactDeleteBytes);
return Optional.of(new LocalWriteResult(writeOperationResult, storageKeyUpdates));
}
@@ -142,17 +148,35 @@ public final class StorageSyncHelper {
List<SignalContactRecord> remoteOnlyContacts = Stream.of(remoteOnlyRecords).filter(r -> r.getContact().isPresent()).map(r -> r.getContact().get()).toList();
List<SignalContactRecord> localOnlyContacts = Stream.of(localOnlyRecords).filter(r -> r.getContact().isPresent()).map(r -> r.getContact().get()).toList();
List<SignalGroupV1Record> remoteOnlyGroupV1 = Stream.of(remoteOnlyRecords).filter(r -> r.getGroupV1().isPresent()).map(r -> r.getGroupV1().get()).toList();
List<SignalGroupV1Record> localOnlyGroupV1 = Stream.of(localOnlyRecords).filter(r -> r.getGroupV1().isPresent()).map(r -> r.getGroupV1().get()).toList();
List<SignalStorageRecord> remoteOnlyUnknowns = Stream.of(remoteOnlyRecords).filter(SignalStorageRecord::isUnknown).toList();
List<SignalStorageRecord> localOnlyUnknowns = Stream.of(localOnlyRecords).filter(SignalStorageRecord::isUnknown).toList();
ContactRecordMergeResult contactMergeResult = resolveContactConflict(remoteOnlyContacts, localOnlyContacts);
GroupV1RecordMergeResult groupV1MergeResult = resolveGroupV1Conflict(remoteOnlyGroupV1, localOnlyGroupV1);
Set<SignalStorageRecord> remoteInserts = new HashSet<>();
remoteInserts.addAll(Stream.of(contactMergeResult.remoteInserts).map(SignalStorageRecord::forContact).toList());
remoteInserts.addAll(Stream.of(groupV1MergeResult.remoteInserts).map(SignalStorageRecord::forGroupV1).toList());
Set<RecordUpdate> remoteUpdates = new HashSet<>();
remoteUpdates.addAll(Stream.of(contactMergeResult.remoteUpdates)
.map(c -> new RecordUpdate(SignalStorageRecord.forContact(c.getOld()), SignalStorageRecord.forContact(c.getNew())))
.toList());
remoteUpdates.addAll(Stream.of(groupV1MergeResult.remoteUpdates)
.map(c -> new RecordUpdate(SignalStorageRecord.forGroupV1(c.getOld()), SignalStorageRecord.forGroupV1(c.getNew())))
.toList());
return new MergeResult(contactMergeResult.localInserts,
contactMergeResult.localUpdates,
contactMergeResult.remoteInserts,
contactMergeResult.remoteUpdates,
groupV1MergeResult.localInserts,
groupV1MergeResult.localUpdates,
new LinkedHashSet<>(remoteOnlyUnknowns),
new LinkedHashSet<>(localOnlyUnknowns));
new LinkedHashSet<>(localOnlyUnknowns),
remoteInserts,
remoteUpdates);
}
/**
@@ -169,7 +193,11 @@ public final class StorageSyncHelper {
completeKeys.add(ByteBuffer.wrap(insert.getKey()));
}
for (SignalContactRecord insert : mergeResult.getRemoteContactInserts()) {
for (SignalGroupV1Record insert : mergeResult.getLocalGroupV1Inserts()) {
completeKeys.add(ByteBuffer.wrap(insert.getKey()));
}
for (SignalStorageRecord insert : mergeResult.getRemoteInserts()) {
completeKeys.add(ByteBuffer.wrap(insert.getKey()));
}
@@ -178,34 +206,47 @@ public final class StorageSyncHelper {
}
for (ContactUpdate update : mergeResult.getLocalContactUpdates()) {
completeKeys.remove(ByteBuffer.wrap(update.getOldContact().getKey()));
completeKeys.add(ByteBuffer.wrap(update.getNewContact().getKey()));
completeKeys.remove(ByteBuffer.wrap(update.getOld().getKey()));
completeKeys.add(ByteBuffer.wrap(update.getNew().getKey()));
}
for (ContactUpdate update : mergeResult.getRemoteContactUpdates()) {
completeKeys.remove(ByteBuffer.wrap(update.getOldContact().getKey()));
completeKeys.add(ByteBuffer.wrap(update.getNewContact().getKey()));
for (GroupV1Update update : mergeResult.getLocalGroupV1Updates()) {
completeKeys.remove(ByteBuffer.wrap(update.getOld().getKey()));
completeKeys.add(ByteBuffer.wrap(update.getNew().getKey()));
}
for (RecordUpdate update : mergeResult.getRemoteUpdates()) {
completeKeys.remove(ByteBuffer.wrap(update.getOld().getKey()));
completeKeys.add(ByteBuffer.wrap(update.getNew().getKey()));
}
SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, Stream.of(completeKeys).map(ByteBuffer::array).toList());
List<SignalContactRecord> contactInserts = new ArrayList<>();
contactInserts.addAll(mergeResult.getRemoteContactInserts());
contactInserts.addAll(Stream.of(mergeResult.getRemoteContactUpdates()).map(ContactUpdate::getNewContact).toList());
List<SignalStorageRecord> inserts = new ArrayList<>();
inserts.addAll(mergeResult.getRemoteInserts());
inserts.addAll(Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getNew).toList());
List<SignalStorageRecord> inserts = Stream.of(contactInserts).map(c -> SignalStorageRecord.forContact(c.getKey(), c)).toList();
List<byte[]> deletes = Stream.of(mergeResult.getRemoteContactUpdates()).map(ContactUpdate::getOldContact).map(SignalContactRecord::getKey).toList();
List<byte[]> deletes = Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getOld).map(SignalStorageRecord::getKey).toList();
return new WriteOperationResult(manifest, inserts, deletes);
}
public static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientSettings recipient) {
if (recipient.getStorageKey() == null) {
public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings) {
if (settings.getStorageKey() == null) {
throw new AssertionError("Must have a storage key!");
}
return localToRemoteContact(recipient, recipient.getStorageKey());
return localToRemoteRecord(settings, settings.getStorageKey());
}
public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings, @NonNull byte[] key) {
if (settings.getGroupType() == RecipientDatabase.GroupType.NONE) {
return SignalStorageRecord.forContact(localToRemoteContact(settings, key));
} else if (settings.getGroupType() == RecipientDatabase.GroupType.SIGNAL_V1) {
return SignalStorageRecord.forGroupV1(localToRemoteGroupV1(settings, key));
} else {
throw new AssertionError("Unsupported type!");
}
}
private static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientSettings recipient, byte[] storageKey) {
@@ -215,7 +256,8 @@ public final class StorageSyncHelper {
return new SignalContactRecord.Builder(storageKey, new SignalServiceAddress(recipient.getUuid(), recipient.getE164()))
.setProfileKey(recipient.getProfileKey())
.setProfileName(recipient.getProfileName().serialize())
.setGivenName(recipient.getProfileName().getGivenName())
.setFamilyName(recipient.getProfileName().getFamilyName())
.setBlocked(recipient.isBlocked())
.setProfileSharingEnabled(recipient.isProfileSharing())
.setIdentityKey(recipient.getIdentityKey())
@@ -223,6 +265,17 @@ public final class StorageSyncHelper {
.build();
}
private static @NonNull SignalGroupV1Record localToRemoteGroupV1(@NonNull RecipientSettings recipient, byte[] storageKey) {
if (recipient.getGroupId() == null) {
throw new AssertionError("Must have a groupId!");
}
return new SignalGroupV1Record.Builder(storageKey, GroupUtil.getDecodedIdOrThrow(recipient.getGroupId()))
.setBlocked(recipient.isBlocked())
.setProfileSharingEnabled(recipient.isProfileSharing())
.build();
}
public static @NonNull IdentityDatabase.VerifiedStatus remoteToLocalIdentityStatus(@NonNull IdentityState identityState) {
switch (identityState) {
case VERIFIED: return IdentityDatabase.VerifiedStatus.VERIFIED;
@@ -246,16 +299,17 @@ public final class StorageSyncHelper {
UUID uuid = remote.getAddress().getUuid().or(local.getAddress().getUuid()).orNull();
String e164 = remote.getAddress().getNumber().or(local.getAddress().getNumber()).orNull();
SignalServiceAddress address = new SignalServiceAddress(uuid, e164);
String profileName = remote.getProfileName().or(local.getProfileName()).orNull();
String givenName = remote.getGivenName().or(local.getGivenName()).or("");
String familyName = remote.getFamilyName().or(local.getFamilyName()).or("");
byte[] profileKey = remote.getProfileKey().or(local.getProfileKey()).orNull();
String username = remote.getUsername().or(local.getUsername()).orNull();
String username = remote.getUsername().or(local.getUsername()).or("");
IdentityState identityState = remote.getIdentityState();
byte[] identityKey = remote.getIdentityKey().or(local.getIdentityKey()).orNull();
String nickname = local.getNickname().orNull(); // TODO [greyson] Update this when we add real nickname support
String nickname = local.getNickname().or(""); // TODO [greyson] Update this when we add real nickname support
boolean blocked = remote.isBlocked();
boolean profileSharing = remote.isProfileSharingEnabled() | local.isProfileSharingEnabled();
boolean matchesRemote = doParamsMatchContact(remote, address, profileName, profileKey, username, identityState, identityKey, blocked, profileSharing, nickname);
boolean matchesLocal = doParamsMatchContact(local, address, profileName, profileKey, username, identityState, identityKey, blocked, profileSharing, nickname);
boolean profileSharing = remote.isProfileSharingEnabled() || local.isProfileSharingEnabled();
boolean matchesRemote = doParamsMatchContact(remote, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, nickname);
boolean matchesLocal = doParamsMatchContact(local, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, nickname);
if (remote.getProtoVersion() > 0) {
Log.w(TAG, "Inbound model has version " + remote.getProtoVersion() + ", but our version is 0.");
@@ -267,15 +321,38 @@ public final class StorageSyncHelper {
return local;
} else {
return new SignalContactRecord.Builder(generateKey(), address)
.setProfileName(profileName)
.setProfileKey(profileKey)
.setUsername(username)
.setIdentityState(identityState)
.setIdentityKey(identityKey)
.setBlocked(blocked)
.setProfileSharingEnabled(profileSharing)
.setNickname(nickname)
.build();
.setGivenName(givenName)
.setFamilyName(familyName)
.setProfileKey(profileKey)
.setUsername(username)
.setIdentityState(identityState)
.setIdentityKey(identityKey)
.setBlocked(blocked)
.setProfileSharingEnabled(profileSharing)
.setNickname(nickname)
.build();
}
}
@VisibleForTesting
static @NonNull SignalGroupV1Record mergeGroupV1(@NonNull SignalGroupV1Record remote,
@NonNull SignalGroupV1Record local)
{
boolean blocked = remote.isBlocked();
boolean profileSharing = remote.isProfileSharingEnabled() || local.isProfileSharingEnabled();
boolean matchesRemote = blocked == remote.isBlocked() && profileSharing == remote.isProfileSharingEnabled();
boolean matchesLocal = blocked == local.isBlocked() && profileSharing == local.isProfileSharingEnabled();
if (matchesRemote) {
return remote;
} else if (matchesLocal) {
return local;
} else {
return new SignalGroupV1Record.Builder(generateKey(), remote.getGroupId())
.setBlocked(blocked)
.setProfileSharingEnabled(blocked)
.build();
}
}
@@ -294,7 +371,8 @@ public final class StorageSyncHelper {
private static boolean doParamsMatchContact(@NonNull SignalContactRecord contact,
@NonNull SignalServiceAddress address,
@Nullable String profileName,
@Nullable String givenName,
@Nullable String familyName,
@Nullable byte[] profileKey,
@Nullable String username,
@Nullable IdentityState identityState,
@@ -303,15 +381,16 @@ public final class StorageSyncHelper {
boolean profileSharing,
@Nullable String nickname)
{
return Objects.equals(contact.getAddress(), address) &&
Objects.equals(contact.getProfileName().orNull(), profileName) &&
Arrays.equals(contact.getProfileKey().orNull(), profileKey) &&
Objects.equals(contact.getUsername().orNull(), username) &&
Objects.equals(contact.getIdentityState(), identityState) &&
Arrays.equals(contact.getIdentityKey().orNull(), identityKey) &&
contact.isBlocked() == blocked &&
contact.isProfileSharingEnabled() == profileSharing &&
Objects.equals(contact.getNickname().orNull(), nickname);
return Objects.equals(contact.getAddress(), address) &&
Objects.equals(contact.getGivenName().or(""), givenName) &&
Objects.equals(contact.getFamilyName().or(""), familyName) &&
Arrays.equals(contact.getProfileKey().orNull(), profileKey) &&
Objects.equals(contact.getUsername().or(""), username) &&
Objects.equals(contact.getIdentityState(), identityState) &&
Arrays.equals(contact.getIdentityKey().orNull(), identityKey) &&
contact.isBlocked() == blocked &&
contact.isProfileSharingEnabled() == profileSharing &&
Objects.equals(contact.getNickname().or(""), nickname);
}
private static @NonNull ContactRecordMergeResult resolveContactConflict(@NonNull Collection<SignalContactRecord> remoteOnlyRecords,
@@ -359,6 +438,40 @@ public final class StorageSyncHelper {
return new ContactRecordMergeResult(localInserts, localUpdates, remoteInserts, remoteUpdates);
}
private static @NonNull GroupV1RecordMergeResult resolveGroupV1Conflict(@NonNull Collection<SignalGroupV1Record> remoteOnlyRecords,
@NonNull Collection<SignalGroupV1Record> localOnlyRecords)
{
Map<String, SignalGroupV1Record> remoteByGroupId = Stream.of(remoteOnlyRecords).collect(Collectors.toMap(g -> GroupUtil.getEncodedId(g.getGroupId(), false), g -> g));
Map<String, SignalGroupV1Record> localByGroupId = Stream.of(localOnlyRecords).collect(Collectors.toMap(g -> GroupUtil.getEncodedId(g.getGroupId(), false), g -> g));
Set<SignalGroupV1Record> localInserts = new LinkedHashSet<>(remoteOnlyRecords);
Set<SignalGroupV1Record> remoteInserts = new LinkedHashSet<>(localOnlyRecords);
Set<GroupV1Update> localUpdates = new LinkedHashSet<>();
Set<GroupV1Update> remoteUpdates = new LinkedHashSet<>();
for (Map.Entry<String, SignalGroupV1Record> entry : remoteByGroupId.entrySet()) {
SignalGroupV1Record remote = entry.getValue();
SignalGroupV1Record local = localByGroupId.get(entry.getKey());
if (local != null) {
SignalGroupV1Record merged = mergeGroupV1(remote, local);
if (!merged.equals(remote)) {
remoteUpdates.add(new GroupV1Update(remote, merged));
}
if (!merged.equals(local)) {
localUpdates.add(new GroupV1Update(local, merged));
}
localInserts.remove(remote);
remoteInserts.remove(local);
}
}
return new GroupV1RecordMergeResult(localInserts, localUpdates, remoteInserts, remoteUpdates);
}
public static final class ContactUpdate {
private final SignalContactRecord oldContact;
private final SignalContactRecord newContact;
@@ -368,13 +481,11 @@ public final class StorageSyncHelper {
this.newContact = newContact;
}
public @NonNull
SignalContactRecord getOldContact() {
public @NonNull SignalContactRecord getOld() {
return oldContact;
}
public @NonNull
SignalContactRecord getNewContact() {
public @NonNull SignalContactRecord getNew() {
return newContact;
}
@@ -397,6 +508,72 @@ public final class StorageSyncHelper {
}
}
public static final class GroupV1Update {
private final SignalGroupV1Record oldGroup;
private final SignalGroupV1Record newGroup;
public GroupV1Update(@NonNull SignalGroupV1Record oldGroup, @NonNull SignalGroupV1Record newGroup) {
this.oldGroup = oldGroup;
this.newGroup = newGroup;
}
public @NonNull SignalGroupV1Record getOld() {
return oldGroup;
}
public @NonNull SignalGroupV1Record getNew() {
return newGroup;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
GroupV1Update that = (GroupV1Update) o;
return oldGroup.equals(that.oldGroup) &&
newGroup.equals(that.newGroup);
}
@Override
public int hashCode() {
return Objects.hash(oldGroup, newGroup);
}
}
@VisibleForTesting
static class RecordUpdate {
private final SignalStorageRecord oldRecord;
private final SignalStorageRecord newRecord;
RecordUpdate(@NonNull SignalStorageRecord oldRecord, @NonNull SignalStorageRecord newRecord) {
this.oldRecord = oldRecord;
this.newRecord = newRecord;
}
public @NonNull SignalStorageRecord getOld() {
return oldRecord;
}
public @NonNull SignalStorageRecord getNew() {
return newRecord;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
RecordUpdate that = (RecordUpdate) o;
return oldRecord.equals(that.oldRecord) &&
newRecord.equals(that.newRecord);
}
@Override
public int hashCode() {
return Objects.hash(oldRecord, newRecord);
}
}
public static final class KeyDifferenceResult {
private final List<byte[]> remoteOnlyKeys;
private final List<byte[]> localOnlyKeys;
@@ -422,25 +599,31 @@ public final class StorageSyncHelper {
public static final class MergeResult {
private final Set<SignalContactRecord> localContactInserts;
private final Set<ContactUpdate> localContactUpdates;
private final Set<SignalContactRecord> remoteContactInserts;
private final Set<ContactUpdate> remoteContactUpdates;
private final Set<SignalGroupV1Record> localGroupV1Inserts;
private final Set<GroupV1Update> localGroupV1Updates;
private final Set<SignalStorageRecord> localUnknownInserts;
private final Set<SignalStorageRecord> localUnknownDeletes;
private final Set<SignalStorageRecord> remoteInserts;
private final Set<RecordUpdate> remoteUpdates;
@VisibleForTesting
MergeResult(@NonNull Set<SignalContactRecord> localContactInserts,
@NonNull Set<ContactUpdate> localContactUpdates,
@NonNull Set<SignalContactRecord> remoteContactInserts,
@NonNull Set<ContactUpdate> remoteContactUpdates,
@NonNull Set<ContactUpdate> localContactUpdates,
@NonNull Set<SignalGroupV1Record> localGroupV1Inserts,
@NonNull Set<GroupV1Update> localGroupV1Updates,
@NonNull Set<SignalStorageRecord> localUnknownInserts,
@NonNull Set<SignalStorageRecord> localUnknownDeletes)
@NonNull Set<SignalStorageRecord> localUnknownDeletes,
@NonNull Set<SignalStorageRecord> remoteInserts,
@NonNull Set<RecordUpdate> remoteUpdates)
{
this.localContactInserts = localContactInserts;
this.localContactUpdates = localContactUpdates;
this.remoteContactInserts = remoteContactInserts;
this.remoteContactUpdates = remoteContactUpdates;
this.localGroupV1Inserts = localGroupV1Inserts;
this.localGroupV1Updates = localGroupV1Updates;
this.localUnknownInserts = localUnknownInserts;
this.localUnknownDeletes = localUnknownDeletes;
this.remoteInserts = remoteInserts;
this.remoteUpdates = remoteUpdates;
}
public @NonNull Set<SignalContactRecord> getLocalContactInserts() {
@@ -451,12 +634,12 @@ public final class StorageSyncHelper {
return localContactUpdates;
}
public @NonNull Set<SignalContactRecord> getRemoteContactInserts() {
return remoteContactInserts;
public @NonNull Set<SignalGroupV1Record> getLocalGroupV1Inserts() {
return localGroupV1Inserts;
}
public @NonNull Set<ContactUpdate> getRemoteContactUpdates() {
return remoteContactUpdates;
public @NonNull Set<GroupV1Update> getLocalGroupV1Updates() {
return localGroupV1Updates;
}
public @NonNull Set<SignalStorageRecord> getLocalUnknownInserts() {
@@ -466,6 +649,21 @@ public final class StorageSyncHelper {
public @NonNull Set<SignalStorageRecord> getLocalUnknownDeletes() {
return localUnknownDeletes;
}
public @NonNull Set<SignalStorageRecord> getRemoteInserts() {
return remoteInserts;
}
public @NonNull Set<RecordUpdate> getRemoteUpdates() {
return remoteUpdates;
}
@Override
public @NonNull String toString() {
return String.format(Locale.ENGLISH,
"localContactInserts: %d, localContactUpdates: %d, localGroupInserts: %d, localGroupUpdates: %d, localUnknownInserts: %d, localUnknownDeletes: %d, remoteInserts: %d, remoteUpdates: %d",
localContactInserts.size(), localContactUpdates.size(), localGroupV1Inserts.size(), localGroupV1Updates.size(), localUnknownInserts.size(), localUnknownDeletes.size(), remoteInserts.size(), remoteUpdates.size());
}
}
public static final class WriteOperationResult {
@@ -493,6 +691,20 @@ public final class StorageSyncHelper {
public @NonNull List<byte[]> getDeletes() {
return deletes;
}
public boolean isEmpty() {
return inserts.isEmpty() && deletes.isEmpty();
}
@Override
public @NonNull String toString() {
return String.format(Locale.ENGLISH,
"ManifestVersion: %d, Total Keys: %d, Inserts: %d, Deletes: %d",
manifest.getVersion(),
manifest.getStorageKeys().size(),
inserts.size(),
deletes.size());
}
}
public static class LocalWriteResult {
@@ -531,6 +743,24 @@ public final class StorageSyncHelper {
}
}
private static final class GroupV1RecordMergeResult {
final Set<SignalGroupV1Record> localInserts;
final Set<GroupV1Update> localUpdates;
final Set<SignalGroupV1Record> remoteInserts;
final Set<GroupV1Update> remoteUpdates;
GroupV1RecordMergeResult(@NonNull Set<SignalGroupV1Record> localInserts,
@NonNull Set<GroupV1Update> localUpdates,
@NonNull Set<SignalGroupV1Record> remoteInserts,
@NonNull Set<GroupV1Update> remoteUpdates)
{
this.localInserts = localInserts;
this.localUpdates = localUpdates;
this.remoteInserts = remoteInserts;
this.remoteUpdates = remoteUpdates;
}
}
interface KeyGenerator {
@NonNull byte[] generate();
}

View File

@@ -56,7 +56,6 @@ import android.view.View.OnKeyListener;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.TextView;
import android.widget.Toast;
@@ -72,6 +71,7 @@ import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Stream;
@@ -151,6 +151,7 @@ import org.thoughtcrime.securesms.giph.ui.GiphyActivity;
import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.invites.InviteReminderModel;
import org.thoughtcrime.securesms.invites.InviteReminderRepository;
import org.thoughtcrime.securesms.jobs.LeaveGroupJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
@@ -162,8 +163,8 @@ import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult;
import org.thoughtcrime.securesms.messagerequests.MessageRequestFragment;
import org.thoughtcrime.securesms.messagerequests.MessageRequestFragmentViewModel;
import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel;
import org.thoughtcrime.securesms.messagerequests.MessageRequestsBottomView;
import org.thoughtcrime.securesms.mms.AttachmentManager;
import org.thoughtcrime.securesms.mms.AttachmentManager.MediaType;
import org.thoughtcrime.securesms.mms.AudioSlide;
@@ -308,7 +309,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private TypingStatusTextWatcher typingTextWatcher;
private ConversationSearchBottomBar searchNav;
private MenuItem searchViewItem;
private FrameLayout messageRequestOverlay;
private MessageRequestsBottomView messageRequestBottomView;
private ConversationReactionOverlay reactionOverlay;
private AttachmentManager attachmentManager;
@@ -319,6 +320,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
protected HidingLinearLayout quickAttachmentToggle;
protected HidingLinearLayout inlineAttachmentToggle;
private InputPanel inputPanel;
private View panelParent;
private LinkPreviewViewModel linkPreviewViewModel;
private ConversationSearchViewModel searchViewModel;
@@ -331,9 +333,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private int distributionType;
private boolean archived;
private boolean isSecureText;
private boolean isDefaultSms = true;
private boolean isMmsEnabled = true;
private boolean isSecurityInitialized = false;
private boolean isDefaultSms = true;
private boolean isMmsEnabled = true;
private boolean isSecurityInitialized = false;
private boolean shouldDisplayMessageRequestUi = true;
private final IdentityRecordList identityRecords = new IdentityRecordList();
private final DynamicTheme dynamicTheme = new DynamicDarkToolbarTheme();
@@ -687,6 +690,20 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
MenuInflater inflater = this.getMenuInflater();
menu.clear();
if (isInMessageRequest()) {
if (isActiveGroup()) {
inflater.inflate(R.menu.conversation_message_requests_group, menu);
}
inflater.inflate(R.menu.conversation_message_requests, menu);
if (recipient != null && recipient.get().isMuted()) inflater.inflate(R.menu.conversation_muted, menu);
else inflater.inflate(R.menu.conversation_unmuted, menu);
super.onPrepareOptionsMenu(menu);
return true;
}
if (isSecureText) {
if (recipient.get().getExpireMessages() > 0) {
inflater.inflate(R.menu.conversation_expiring_on, menu);
@@ -932,11 +949,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
private void handleConversationSettings() {
Intent intent = new Intent(ConversationActivity.this, RecipientPreferenceActivity.class);
intent.putExtra(RecipientPreferenceActivity.RECIPIENT_ID, recipient.getId());
intent.putExtra(RecipientPreferenceActivity.CAN_HAVE_SAFETY_NUMBER_EXTRA,
isSecureText && !isSelfConversation());
if (isInMessageRequest()) return;
Intent intent = RecipientPreferenceActivity.getLaunchIntent(this, recipient.getId());
startActivitySceneTransition(intent, titleView.findViewById(R.id.contact_photo_image), "avatar");
}
@@ -967,13 +982,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
.setMessage(bodyRes)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.ConversationActivity_unblock, (dialog, which) -> {
SimpleTask.run(() -> {
SignalExecutors.BOUNDED.execute(() -> {
RecipientUtil.unblock(ConversationActivity.this, recipient.get());
return RecipientUtil.isRecipientMessageRequestAccepted(ConversationActivity.this, recipient.get());
}, messageRequestAccepted -> {
if (!messageRequestAccepted) {
onMessageRequest();
}
});
}).show();
}
@@ -1105,7 +1115,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
Optional<OutgoingGroupMediaMessage> leaveMessage = GroupUtil.createGroupLeaveMessage(this, groupRecipient);
if (threadId != -1 && leaveMessage.isPresent()) {
MessageSender.send(this, leaveMessage.get(), threadId, false, null);
ApplicationDependencies.getJobManager().add(LeaveGroupJob.create(groupRecipient));
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(this);
String groupId = groupRecipient.requireGroupId();
@@ -1200,7 +1210,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
private boolean handleDisplayQuickContact() {
if (recipient.get().isGroup()) return false;
if (isInMessageRequest() || recipient.get().isGroup()) return false;
if (recipient.get().getContactUri() != null) {
ContactsContract.QuickContact.showQuickContact(ConversationActivity.this, titleView, recipient.get().getContactUri(), ContactsContract.QuickContact.MODE_LARGE, null);
@@ -1612,28 +1622,29 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
private void initializeViews() {
titleView = findViewById(R.id.conversation_title_view);
buttonToggle = ViewUtil.findById(this, R.id.button_toggle);
sendButton = ViewUtil.findById(this, R.id.send_button);
attachButton = ViewUtil.findById(this, R.id.attach_button);
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);
composePanel = ViewUtil.findById(this, R.id.bottom_panel);
container = ViewUtil.findById(this, R.id.layout_container);
reminderView = ViewUtil.findStubById(this, R.id.reminder_stub);
unverifiedBannerView = ViewUtil.findStubById(this, R.id.unverified_banner_stub);
groupShareProfileView = ViewUtil.findStubById(this, R.id.group_share_profile_view_stub);
quickAttachmentToggle = ViewUtil.findById(this, R.id.quick_attachment_toggle);
inlineAttachmentToggle = ViewUtil.findById(this, R.id.inline_attachment_container);
inputPanel = ViewUtil.findById(this, R.id.bottom_panel);
searchNav = ViewUtil.findById(this, R.id.conversation_search_nav);
messageRequestOverlay = ViewUtil.findById(this, R.id.fragment_overlay_container);
reactionOverlay = ViewUtil.findById(this, R.id.conversation_reaction_scrubber);
titleView = findViewById(R.id.conversation_title_view);
buttonToggle = ViewUtil.findById(this, R.id.button_toggle);
sendButton = ViewUtil.findById(this, R.id.send_button);
attachButton = ViewUtil.findById(this, R.id.attach_button);
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);
composePanel = ViewUtil.findById(this, R.id.bottom_panel);
container = ViewUtil.findById(this, R.id.layout_container);
reminderView = ViewUtil.findStubById(this, R.id.reminder_stub);
unverifiedBannerView = ViewUtil.findStubById(this, R.id.unverified_banner_stub);
groupShareProfileView = ViewUtil.findStubById(this, R.id.group_share_profile_view_stub);
quickAttachmentToggle = ViewUtil.findById(this, R.id.quick_attachment_toggle);
inlineAttachmentToggle = ViewUtil.findById(this, R.id.inline_attachment_container);
inputPanel = ViewUtil.findById(this, R.id.bottom_panel);
panelParent = ViewUtil.findById(this, R.id.conversation_activity_panel_parent);
searchNav = ViewUtil.findById(this, R.id.conversation_search_nav);
messageRequestBottomView = ViewUtil.findById(this, R.id.conversation_activity_message_request_bottom_bar);
reactionOverlay = ViewUtil.findById(this, R.id.conversation_reaction_scrubber);
ImageButton quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle);
ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button);
@@ -2027,7 +2038,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
private void setBlockedUserState(Recipient recipient, boolean isSecureText, boolean isDefaultSms) {
if (recipient.isBlocked()) {
if (recipient.isBlocked() && !FeatureFlags.messageRequests()) {
unblockButton.setVisibility(View.VISIBLE);
composePanel.setVisibility(View.GONE);
makeDefaultSmsButton.setVisibility(View.GONE);
@@ -2051,7 +2062,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
private void setGroupShareProfileReminder(@NonNull Recipient recipient) {
if (!FeatureFlags.messageRequests() && recipient.isPushGroup() && !recipient.isProfileSharing()) {
if (FeatureFlags.messageRequests()) {
return;
}
if (recipient.isPushGroup() && !recipient.isProfileSharing()) {
groupShareProfileView.get().setRecipient(recipient);
groupShareProfileView.get().setVisibility(View.VISIBLE);
} else if (groupShareProfileView.resolved()) {
@@ -2095,6 +2110,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
}
private boolean isInMessageRequest() {
return messageRequestBottomView.getVisibility() == View.VISIBLE;
}
private boolean isSingleConversation() {
return getRecipient() != null && !getRecipient().isGroup();
@@ -2265,7 +2283,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
long id = fragment.stageOutgoingMessage(message);
SimpleTask.run(() -> {
if (initiating) {
if (!FeatureFlags.messageRequests() && initiating) {
DatabaseFactory.getRecipientDatabase(this).setProfileSharing(recipient.getId(), true);
}
@@ -2278,7 +2296,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}, this::sendComplete);
}
private void sendMediaMessage(final boolean forceSms, final long expiresIn, final boolean viewOnce, final int subscriptionId, boolean initiating)
private void sendMediaMessage(final boolean forceSms, final long expiresIn, final boolean viewOnce, final int subscriptionId, final boolean initiating)
throws InvalidMessageException
{
Log.i(TAG, "Sending media message...");
@@ -2339,8 +2357,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
final long id = fragment.stageOutgoingMessage(outgoingMessage);
SimpleTask.run(() -> {
if (initiating) {
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipient.getId(), true);
if (!FeatureFlags.messageRequests() && initiating) {
DatabaseFactory.getRecipientDatabase(this).setProfileSharing(recipient.getId(), true);
}
return MessageSender.send(context, outgoingMessage, threadId, forceSms, () -> fragment.releaseOutgoingMessage(id));
@@ -2355,7 +2373,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
return future;
}
private void sendTextMessage(final boolean forceSms, final long expiresIn, final int subscriptionId, final boolean initiatingConversation)
private void sendTextMessage(final boolean forceSms, final long expiresIn, final int subscriptionId, final boolean initiating)
throws InvalidMessageException
{
if (!isDefaultSms && (!isSecureText || forceSms)) {
@@ -2386,7 +2404,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
new AsyncTask<OutgoingTextMessage, Void, Long>() {
@Override
protected Long doInBackground(OutgoingTextMessage... messages) {
if (initiatingConversation) {
if (!FeatureFlags.messageRequests() && initiating) {
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipient.getId(), true);
}
@@ -2499,9 +2517,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
@Override
public void onSuccess(final @NonNull Pair<Uri, Long> result) {
boolean forceSms = sendButton.isManualSelection() && sendButton.getSelectedTransport().isSms();
boolean initiating = threadId == -1;
int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1);
long expiresIn = recipient.get().getExpireMessages() * 1000L;
boolean initiating = threadId == -1;
AudioSlide audioSlide = new AudioSlide(ConversationActivity.this, result.first(), result.second(), MediaUtil.AUDIO_AAC, true);
SlideDeck slideDeck = new SlideDeck();
slideDeck.addSlide(audioSlide);
@@ -2757,37 +2775,18 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
@Override
public void onMessageRequest() {
long threadId = getIntent().getLongExtra(THREAD_ID_EXTRA, -1);
RecipientId recipientId = getIntent().getParcelableExtra(RECIPIENT_EXTRA);
public void onMessageRequest(@NonNull MessageRequestViewModel viewModel) {
messageRequestBottomView.setAcceptOnClickListener(v -> viewModel.onAccept());
messageRequestBottomView.setDeleteOnClickListener(v -> onMessageRequestDeleteClicked(viewModel));
messageRequestBottomView.setBlockOnClickListener(v -> onMessageRequestBlockClicked(viewModel));
messageRequestBottomView.setUnblockOnClickListener(v -> onMessageRequestUnblockClicked(viewModel));
if (threadId == -1) {
throw new IllegalStateException("MessageRequest is not supported here");
}
if (recipientId == null) {
Log.w(TAG, "onMessageRequest: " + threadId + ": null recipient. finishing...");
finish();
}
Log.i(TAG, "onMessageRequest: " + threadId + ", " + recipientId.serialize());
MessageRequestFragmentViewModel.Factory factory = new MessageRequestFragmentViewModel.Factory(this, threadId, recipientId);
MessageRequestFragmentViewModel viewModel = ViewModelProviders.of(this, factory).get(MessageRequestFragmentViewModel.class);
MessageRequestFragment fragment = new MessageRequestFragment();
messageRequestOverlay.setVisibility(View.VISIBLE);
container.setVisibility(View.GONE);
getSupportFragmentManager().beginTransaction()
.add(R.id.fragment_overlay_container, fragment)
.commit();
viewModel.getState().observe(this, state -> {
switch (state.messageRequestState) {
viewModel.getRecipient().observe(this, this::presentMessageRequestBottomViewTo);
viewModel.getMessageRequestDisplayState().observe(this, this::presentMessageRequestDisplayState);
viewModel.getMessageRequestStatus().observe(this, status -> {
switch (status) {
case ACCEPTED:
getSupportFragmentManager().popBackStack();
messageRequestOverlay.setVisibility(View.GONE);
container.setVisibility(View.VISIBLE);
messageRequestBottomView.setVisibility(View.GONE);
return;
case DELETED:
case BLOCKED:
@@ -2796,6 +2795,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
});
}
@Override
public void handleReaction(@NonNull View maskTarget,
@NonNull MessageRecord messageRecord,
@NonNull Toolbar.OnMenuItemClickListener toolbarListener,
@@ -2803,7 +2803,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
{
reactionOverlay.setOnToolbarItemClickedListener(toolbarListener);
reactionOverlay.setOnHideListener(onHideListener);
reactionOverlay.show(this, maskTarget, messageRecord, inputPanel.getMeasuredHeight());
reactionOverlay.show(this, maskTarget, messageRecord, panelParent.getMeasuredHeight());
}
@Override
public void onListVerticalTranslationChanged(float translationY) {
reactionOverlay.setListVerticalTranslation(translationY);
}
@Override
@@ -2892,7 +2897,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
@Override
public void onForwardClicked() {
public void onForwardClicked() {
inputPanel.clearQuote();
}
@@ -2903,6 +2908,109 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
updateLinkPreviewState();
}
private void onMessageRequestDeleteClicked(@NonNull MessageRequestViewModel requestModel) {
Recipient recipient = requestModel.getRecipient().getValue();
if (recipient == null) {
Log.w(TAG, "[onMessageRequestDeleteClicked] No recipient!");
return;
}
AlertDialog.Builder builder = new AlertDialog.Builder(this)
.setNeutralButton(R.string.ConversationActivity_cancel, (d, w) -> d.dismiss());
if (recipient.isGroup() && recipient.isBlocked()) {
builder.setTitle(R.string.ConversationActivity_delete_conversation);
builder.setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices);
builder.setPositiveButton(R.string.ConversationActivity_delete, (d, w) -> requestModel.onDelete());
} else if (recipient.isGroup()) {
builder.setTitle(R.string.ConversationActivity_delete_and_leave_group);
builder.setMessage(R.string.ConversationActivity_you_will_leave_this_group_and_it_will_be_deleted_from_all_of_your_devices);
builder.setNegativeButton(R.string.ConversationActivity_delete_and_leave, (d, w) -> requestModel.onDelete());
} else {
builder.setTitle(R.string.ConversationActivity_delete_conversation);
builder.setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices);
builder.setNegativeButton(R.string.ConversationActivity_delete, (d, w) -> requestModel.onDelete());
}
builder.show();
}
private void onMessageRequestBlockClicked(@NonNull MessageRequestViewModel requestModel) {
Recipient recipient = requestModel.getRecipient().getValue();
if (recipient == null) {
Log.w(TAG, "[onMessageRequestBlockClicked] No recipient!");
return;
}
AlertDialog.Builder builder = new AlertDialog.Builder(this)
.setNeutralButton(R.string.ConversationActivity_cancel, (d, w) -> d.dismiss())
.setPositiveButton(R.string.ConversationActivity_block_and_delete, (d, w) -> requestModel.onBlockAndDelete())
.setNegativeButton(R.string.ConversationActivity_block, (d, w) -> requestModel.onBlock());
if (recipient.isGroup()) {
builder.setTitle(getString(R.string.ConversationActivity_block_and_leave_s, recipient.getDisplayName(this)));
builder.setMessage(R.string.ConversationActivity_you_will_leave_this_group_and_no_longer_receive_messages_or_updates);
} else {
builder.setTitle(getString(R.string.ConversationActivity_block_s, recipient.getDisplayName(this)));
builder.setMessage(R.string.ConversationActivity_blocked_people_will_not_be_able_to_call_you_or_send_you_messages);
}
builder.show();
}
private void onMessageRequestUnblockClicked(@NonNull MessageRequestViewModel requestModel) {
Recipient recipient = requestModel.getRecipient().getValue();
if (recipient == null) {
Log.w(TAG, "[onMessageRequestUnblockClicked] No recipient!");
return;
}
AlertDialog.Builder builder = new AlertDialog.Builder(this)
.setTitle(getString(R.string.ConversationActivity_unblock_s, recipient.getDisplayName(this)))
.setNeutralButton(R.string.ConversationActivity_cancel, (d, w) -> d.dismiss())
.setNegativeButton(R.string.ConversationActivity_unblock, (d, w) -> requestModel.onUnblock());
if (recipient.isGroup()) {
builder.setMessage(R.string.ConversationActivity_group_members_will_be_able_to_add_you_to_this_group_again);
} else {
builder.setMessage(R.string.ConversationActivity_you_will_be_able_to_message_and_call_each_other);
}
builder.show();
}
private void presentMessageRequestDisplayState(@NonNull MessageRequestViewModel.DisplayState displayState) {
if (getIntent().hasExtra(TEXT_EXTRA) || getIntent().hasExtra(MEDIA_EXTRA) || getIntent().hasExtra(STICKER_EXTRA) || (isPushGroupConversation() && !isActiveGroup())) {
Log.d(TAG, "[presentMessageRequestDisplayState] Have extra, so ignoring provided state.");
messageRequestBottomView.setVisibility(View.GONE);
} else {
Log.d(TAG, "[presentMessageRequestDisplayState] " + displayState);
switch (displayState) {
case DISPLAY_MESSAGE_REQUEST:
messageRequestBottomView.setVisibility(View.VISIBLE);
if (groupShareProfileView.resolved()) {
groupShareProfileView.get().setVisibility(View.GONE);
}
break;
case DISPLAY_LEGACY:
if (recipient.get().isGroup()) {
groupShareProfileView.get().setRecipient(recipient.get());
groupShareProfileView.get().setVisibility(View.VISIBLE);
}
messageRequestBottomView.setVisibility(View.GONE);
break;
case DISPLAY_NONE:
messageRequestBottomView.setVisibility(View.GONE);
if (groupShareProfileView.resolved()) {
groupShareProfileView.get().setVisibility(View.GONE);
}
break;
}
}
invalidateOptionsMenu();
}
private class UnverifiedDismissedListener implements UnverifiedBannerView.DismissListener {
@Override
public void onDismissed(final List<IdentityRecord> unverifiedIdentities) {
@@ -2996,4 +3104,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
}
}
private void presentMessageRequestBottomViewTo(@Nullable Recipient recipient) {
if (recipient == null) return;
messageRequestBottomView.setRecipient(recipient);
}
}

View File

@@ -0,0 +1,91 @@
package org.thoughtcrime.securesms.conversation;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
public class ConversationBannerView extends ConstraintLayout {
private AvatarImageView contactAvatar;
private TextView contactTitle;
private TextView contactSubtitle;
private TextView contactDescription;
public ConversationBannerView(Context context) {
this(context, null);
}
public ConversationBannerView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ConversationBannerView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
inflate(getContext(), R.layout.conversation_banner_view, this);
contactAvatar = findViewById(R.id.message_request_avatar);
contactTitle = findViewById(R.id.message_request_title);
contactSubtitle = findViewById(R.id.message_request_subtitle);
contactDescription = findViewById(R.id.message_request_description);
contactAvatar.setFallbackPhotoProvider(new FallbackPhotoProvider());
}
public void setAvatar(@NonNull GlideRequests requests, @Nullable Recipient recipient) {
contactAvatar.setAvatar(requests, recipient, false);
}
public void setTitle(@Nullable CharSequence title) {
contactTitle.setText(title);
}
public void setSubtitle(@Nullable CharSequence subtitle) {
contactSubtitle.setText(subtitle);
}
public void setDescription(@Nullable CharSequence description) {
contactDescription.setText(description);
}
public void hideSubtitle() {
contactSubtitle.setVisibility(View.GONE);
}
public void showDescription() {
contactDescription.setVisibility(View.VISIBLE);
}
public void hideDescription() {
contactDescription.setVisibility(View.GONE);
}
private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider {
@Override
public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() {
return new ResourceContactPhoto(R.drawable.ic_profile_80);
}
@Override
public @NonNull FallbackContactPhoto getPhotoForGroup() {
return new ResourceContactPhoto(R.drawable.ic_group_80);
}
@Override
public @NonNull FallbackContactPhoto getPhotoForLocalNumber() {
return new ResourceContactPhoto(R.drawable.ic_note_80);
}
}
}

View File

@@ -49,7 +49,9 @@ import androidx.appcompat.view.ActionMode;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.ActivityCompat;
import androidx.core.app.ActivityOptionsCompat;
import androidx.core.text.HtmlCompat;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.LinearLayoutManager;
@@ -88,6 +90,7 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.longmessage.LongMessageActivity;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.mms.PartAuthority;
@@ -100,12 +103,14 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.revealable.ViewOnceMessageActivity;
import org.thoughtcrime.securesms.revealable.ViewOnceUtil;
import org.thoughtcrime.securesms.sharing.ShareActivity;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.HtmlUtil;
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -162,6 +167,9 @@ public class ConversationFragment extends Fragment
private View composeDivider;
private View scrollToBottomButton;
private TextView scrollDateHeader;
private ConversationBannerView conversationBanner;
private ConversationBannerView emptyConversationBanner;
private MessageRequestViewModel messageRequestViewModel;
@Override
public void onCreate(Bundle icicle) {
@@ -172,10 +180,11 @@ public class ConversationFragment extends Fragment
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) {
final View view = inflater.inflate(R.layout.conversation_fragment, container, false);
list = ViewUtil.findById(view, android.R.id.list);
composeDivider = ViewUtil.findById(view, R.id.compose_divider);
scrollToBottomButton = ViewUtil.findById(view, R.id.scroll_to_bottom_button);
scrollDateHeader = ViewUtil.findById(view, R.id.scroll_date_header);
list = ViewUtil.findById(view, android.R.id.list);
composeDivider = ViewUtil.findById(view, R.id.compose_divider);
scrollToBottomButton = ViewUtil.findById(view, R.id.scroll_to_bottom_button);
scrollDateHeader = ViewUtil.findById(view, R.id.scroll_date_header);
emptyConversationBanner = ViewUtil.findById(view, R.id.empty_conversation_banner);
scrollToBottomButton.setOnClickListener(v -> scrollToBottom());
@@ -184,6 +193,10 @@ public class ConversationFragment extends Fragment
list.setLayoutManager(layoutManager);
list.setItemAnimator(null);
if (FeatureFlags.messageRequests()) {
conversationBanner = (ConversationBannerView) inflater.inflate(R.layout.conversation_item_banner, container, false);
}
topLoadMoreView = (ViewSwitcher) inflater.inflate(R.layout.load_more_header, container, false);
bottomLoadMoreView = (ViewSwitcher) inflater.inflate(R.layout.load_more_header, container, false);
initializeLoadMoreView(topLoadMoreView);
@@ -193,18 +206,53 @@ public class ConversationFragment extends Fragment
new ConversationItemSwipeCallback(
messageRecord -> actionMode == null &&
canReplyToMessage(isActionMessage(messageRecord), messageRecord),
canReplyToMessage(isActionMessage(messageRecord), messageRecord, messageRequestViewModel.shouldShowMessageRequest()),
this::handleReplyMessage
).attachToRecyclerView(list);
setupListLayoutListeners();
return view;
}
private void setupListLayoutListeners() {
if (!FeatureFlags.messageRequests()) {
return;
}
list.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> setListVerticalTranslation());
list.addOnChildAttachStateChangeListener(new RecyclerView.OnChildAttachStateChangeListener() {
@Override
public void onChildViewAttachedToWindow(@NonNull View view) {
setListVerticalTranslation();
}
@Override
public void onChildViewDetachedFromWindow(@NonNull View view) {
setListVerticalTranslation();
}
});
}
private void setListVerticalTranslation() {
if (list.canScrollVertically(1) || list.canScrollVertically(-1) || list.getChildCount() == 0) {
list.setTranslationY(0);
list.setOverScrollMode(RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS);
} else {
int chTop = list.getChildAt(list.getChildCount() - 1).getTop();
list.setTranslationY(Math.min(0, -chTop));
list.setOverScrollMode(RecyclerView.OVER_SCROLL_NEVER);
}
listener.onListVerticalTranslationChanged(list.getTranslationY());
}
@Override
public void onActivityCreated(Bundle bundle) {
super.onActivityCreated(bundle);
initializeResources();
initializeMessageRequestViewModel();
initializeListAdapter();
}
@@ -241,6 +289,7 @@ public class ConversationFragment extends Fragment
}
initializeResources();
messageRequestViewModel.setConversationInfo(recipient.getId(), threadId);
initializeListAdapter();
if (threadId == -1) {
@@ -267,6 +316,80 @@ public class ConversationFragment extends Fragment
scrollToLastSeenPosition(position);
}
private void initializeMessageRequestViewModel() {
MessageRequestViewModel.Factory factory = new MessageRequestViewModel.Factory(requireContext());
messageRequestViewModel = ViewModelProviders.of(requireActivity(), factory).get(MessageRequestViewModel.class);
messageRequestViewModel.setConversationInfo(recipient.getId(), threadId);
listener.onMessageRequest(messageRequestViewModel);
messageRequestViewModel.getRecipientInfo().observe(getViewLifecycleOwner(), recipientInfo -> {
presentMessageRequestProfileView(requireContext(), recipientInfo, conversationBanner);
presentMessageRequestProfileView(requireContext(), recipientInfo, emptyConversationBanner);
});
}
private static void presentMessageRequestProfileView(@NonNull Context context, @NonNull MessageRequestViewModel.RecipientInfo recipientInfo, @Nullable ConversationBannerView conversationBanner) {
if (conversationBanner == null) {
return;
}
Recipient recipient = recipientInfo.getRecipient();
boolean isSelf = Recipient.self().equals(recipient);
int memberCount = recipientInfo.getGroupMemberCount();
List<String> groups = recipientInfo.getSharedGroups();
if (recipient != null) {
conversationBanner.setAvatar(GlideApp.with(context), recipient);
String title = isSelf ? context.getString(R.string.note_to_self) : recipient.getDisplayName(context);
conversationBanner.setTitle(title);
if (recipient.isGroup()) {
conversationBanner.setSubtitle(context.getResources().getQuantityString(R.plurals.MessageRequestProfileView_members, memberCount, memberCount));
} else if (isSelf) {
conversationBanner.setSubtitle(context.getString(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation));
} else {
String subtitle = recipient.getUsername().or(recipient.getE164()).orNull();
if (subtitle == null || subtitle.equals(title)) {
conversationBanner.hideSubtitle();
} else {
conversationBanner.setSubtitle(subtitle);
}
}
}
if (groups.isEmpty() || isSelf) {
conversationBanner.hideDescription();
} else {
final String description;
switch (groups.size()) {
case 1:
description = context.getString(R.string.MessageRequestProfileView_member_of_one_group, HtmlUtil.bold(groups.get(0)));
break;
case 2:
description = context.getString(R.string.MessageRequestProfileView_member_of_two_groups, HtmlUtil.bold(groups.get(0)), HtmlUtil.bold(groups.get(1)));
break;
case 3:
description = context.getString(R.string.MessageRequestProfileView_member_of_many_groups, HtmlUtil.bold(groups.get(0)), HtmlUtil.bold(groups.get(1)), HtmlUtil.bold(groups.get(2)));
break;
default:
int others = groups.size() - 2;
description = context.getString(R.string.MessageRequestProfileView_member_of_many_groups,
HtmlUtil.bold(groups.get(0)),
HtmlUtil.bold(groups.get(1)),
context.getResources().getQuantityString(R.plurals.MessageRequestProfileView_member_of_others, others, others));
}
conversationBanner.setDescription(HtmlCompat.fromHtml(description, 0));
conversationBanner.showDescription();
}
}
private void initializeResources() {
this.recipient = Recipient.live(getActivity().getIntent().getParcelableExtra(ConversationActivity.RECIPIENT_EXTRA));
this.threadId = this.getActivity().getIntent().getLongExtra(ConversationActivity.THREAD_ID_EXTRA, -1);
@@ -288,6 +411,10 @@ public class ConversationFragment extends Fragment
setLastSeen(lastSeen);
getLoaderManager().restartLoader(0, Bundle.EMPTY, this);
emptyConversationBanner.setVisibility(View.GONE);
} else if (FeatureFlags.messageRequests() && threadId == -1) {
emptyConversationBanner.setVisibility(View.VISIBLE);
}
}
@@ -419,15 +546,16 @@ public class ConversationFragment extends Fragment
menu.findItem(R.id.menu_context_forward).setVisible(!actionMessage && !sharedContact && !viewOnce);
menu.findItem(R.id.menu_context_details).setVisible(!actionMessage);
menu.findItem(R.id.menu_context_reply).setVisible(canReplyToMessage(actionMessage, messageRecord));
menu.findItem(R.id.menu_context_reply).setVisible(canReplyToMessage(actionMessage, messageRecord, messageRequestViewModel.shouldShowMessageRequest()));
}
menu.findItem(R.id.menu_context_copy).setVisible(!actionMessage && hasText);
}
private static boolean canReplyToMessage(boolean actionMessage, MessageRecord messageRecord) {
return !actionMessage &&
!messageRecord.isPending() &&
!messageRecord.isFailed() &&
private static boolean canReplyToMessage(boolean actionMessage, MessageRecord messageRecord, boolean isDisplayingMessageRequest) {
return !actionMessage &&
!messageRecord.isPending() &&
!messageRecord.isFailed() &&
!isDisplayingMessageRequest &&
messageRecord.isSecure();
}
@@ -462,6 +590,7 @@ public class ConversationFragment extends Fragment
if (this.threadId != threadId) {
this.threadId = threadId;
messageRequestViewModel.setConversationInfo(recipient.getId(), threadId);
initializeListAdapter();
}
}
@@ -698,8 +827,8 @@ public class ConversationFragment extends Fragment
@Override
public void onLoadFinished(@NonNull Loader<Cursor> cursorLoader, Cursor cursor) {
int count = cursor.getCount();
ConversationLoader loader = (ConversationLoader)cursorLoader;
int count = cursor.getCount();
ConversationLoader loader = (ConversationLoader) cursorLoader;
ConversationAdapter adapter = getListAdapter();
if (adapter == null) {
@@ -708,6 +837,8 @@ public class ConversationFragment extends Fragment
if (cursor.getCount() >= PARTIAL_CONVERSATION_LIMIT && loader.hasLimit()) {
adapter.setFooterView(topLoadMoreView);
} else if (FeatureFlags.messageRequests()) {
adapter.setFooterView(conversationBanner);
} else {
adapter.setFooterView(null);
}
@@ -716,12 +847,8 @@ public class ConversationFragment extends Fragment
setLastSeen(loader.getLastSeen());
}
if (FeatureFlags.messageRequests()) {
if (!loader.hasSent() && !recipient.get().isSystemContact() && !recipient.get().isProfileSharing() && !recipient.get().isBlocked() && recipient.get().isRegistered()) {
listener.onMessageRequest();
} else {
clearHeaderIfNotTyping(adapter);
}
if (FeatureFlags.messageRequests() && !loader.hasPreMessageRequestMessages()) {
clearHeaderIfNotTyping(adapter);
} else {
if (!loader.hasSent() && !recipient.get().isSystemContact() && !recipient.get().isGroup() && recipient.get().getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) {
adapter.setHeaderView(unknownSenderView);
@@ -751,8 +878,10 @@ public class ConversationFragment extends Fragment
if (firstLoad) {
if (startingPosition >= 0) {
scrollToStartingPosition(startingPosition);
} else {
} else if (loader.isMessageRequestAccepted()) {
scrollToLastSeenPosition(lastSeenPosition);
} else if (FeatureFlags.messageRequests()) {
list.post(() -> getListLayoutManager().scrollToPosition(adapter.getItemCount() - 1));
}
firstLoad = false;
} else if (previousOffset > 0) {
@@ -898,12 +1027,13 @@ public class ConversationFragment extends Fragment
void handleReplyMessage(MessageRecord messageRecord);
void onMessageActionToolbarOpened();
void onForwardClicked();
void onMessageRequest();
void onMessageRequest(@NonNull MessageRequestViewModel viewModel);
void handleReaction(@NonNull View maskTarget,
@NonNull MessageRecord messageRecord,
@NonNull Toolbar.OnMenuItemClickListener toolbarListener,
@NonNull ConversationReactionOverlay.OnHideListener onHideListener);
void onCursorChanged();
void onListVerticalTranslationChanged(float translationY);
}
private class ConversationScrollListener extends OnScrollListener {
@@ -997,8 +1127,10 @@ public class ConversationFragment extends Fragment
if (actionMode != null) return;
if (messageRecord.isSecure() &&
!messageRecord.isUpdate() &&
if (messageRecord.isSecure() &&
!messageRecord.isUpdate() &&
!recipient.get().isBlocked() &&
!messageRequestViewModel.shouldShowMessageRequest() &&
((ConversationAdapter) list.getAdapter()).getSelectedItems().isEmpty())
{
isReacting = true;

View File

@@ -129,6 +129,10 @@ public final class ConversationReactionOverlay extends RelativeLayout {
initAnimators();
}
public void setListVerticalTranslation(float translationY) {
maskView.setTargetParentTranslationY(translationY);
}
public void show(@NonNull Activity activity, @NonNull View maskTarget, @NonNull MessageRecord messageRecord, int maskPaddingBottom) {
if (overlayState != OverlayState.HIDDEN) {

View File

@@ -40,6 +40,7 @@ import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
@@ -115,9 +116,11 @@ import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.AvatarUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
@@ -133,6 +136,8 @@ import java.util.List;
import java.util.Locale;
import java.util.Set;
import static android.app.Activity.RESULT_OK;
public class ConversationListFragment extends MainFragment implements LoaderManager.LoaderCallbacks<Cursor>,
ActionMode.Callback,
@@ -141,6 +146,10 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
MainNavigator.BackHandler,
MegaphoneActionController
{
public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562;
public static final short PROFILE_NAMES_REQUEST_CODE_CREATE_NAME = 18473;
public static final short PROFILE_NAMES_REQUEST_CODE_CONFIRM_NAME = 19563;
private static final String TAG = Log.tag(ConversationListFragment.class);
private static final int[] EMPTY_IMAGES = new int[] { R.drawable.empty_inbox_1,
@@ -310,14 +319,30 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
if (requestCode == CreateKbsPinActivity.REQUEST_NEW_PIN && resultCode == CreateKbsPinActivity.RESULT_OK) {
if (resultCode != RESULT_OK) {
return;
}
boolean isProfileCreatedRequestCode = requestCode == MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME ||
requestCode ==PROFILE_NAMES_REQUEST_CODE_CREATE_NAME;
if (requestCode == CreateKbsPinActivity.REQUEST_NEW_PIN) {
Snackbar.make(fab, R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).show();
viewModel.onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL);
} else if (isProfileCreatedRequestCode) {
Snackbar.make(fab, R.string.ConversationListFragment__your_profile_name_has_been_created, Snackbar.LENGTH_LONG).show();
if (requestCode == MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME) {
viewModel.onMegaphoneCompleted(Megaphones.Event.MESSAGE_REQUESTS);
}
} else if (requestCode == PROFILE_NAMES_REQUEST_CODE_CONFIRM_NAME) {
Snackbar.make(fab, R.string.ConversationListFragment__your_profile_name_has_been_saved, Snackbar.LENGTH_LONG).show();
}
}
@Override
public void onConversationClicked(@NonNull ThreadRecord threadRecord) {
hideKeyboard();
getNavigator().goToConversation(threadRecord.getRecipient().getId(),
threadRecord.getThreadId(),
threadRecord.getDistributionType(),
@@ -330,6 +355,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
return DatabaseFactory.getThreadDatabase(getContext()).getThreadIdIfExistsFor(contact);
}, threadId -> {
hideKeyboard();
getNavigator().goToConversation(contact.getId(),
threadId,
ThreadDatabase.DistributionTypes.DEFAULT,
@@ -344,6 +370,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
int startingPosition = DatabaseFactory.getMmsSmsDatabase(getContext()).getMessagePositionInConversation(message.threadId, message.receivedTimestampMs);
return Math.max(0, startingPosition);
}, startingPosition -> {
hideKeyboard();
getNavigator().goToConversation(message.conversationRecipient.getId(),
message.threadId,
ThreadDatabase.DistributionTypes.DEFAULT,
@@ -382,6 +409,11 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
viewModel.onMegaphoneCompleted(event);
}
private void hideKeyboard() {
InputMethodManager imm = ServiceUtil.getInputMethodManager(requireContext());
imm.hideSoftInputFromWindow(requireView().getWindowToken(), 0);
}
private void initializeProfileIcon(@NonNull Recipient recipient) {
ImageView icon = requireView().findViewById(R.id.toolbar_icon);

View File

@@ -70,6 +70,7 @@ public class ConversationListItem extends RelativeLayout
private Set<Long> selectedThreads;
private LiveRecipient recipient;
private LiveRecipient groupAddedBy;
private long threadId;
private GlideRequests glideRequests;
private View subjectContainer;
@@ -82,6 +83,7 @@ public class ConversationListItem extends RelativeLayout
private AlertView alertView;
private TextView unreadIndicator;
private long lastSeen;
private ThreadRecord thread;
private int unreadCount;
private AvatarImageView contactPhotoImage;
@@ -89,6 +91,12 @@ public class ConversationListItem extends RelativeLayout
private int distributionType;
private final RecipientForeverObserver groupAddedByObserver = adder -> {
if (isAttachedToWindow() && subjectView != null && thread != null) {
subjectView.setText(thread.getDisplayBody(getContext()));
}
};
public ConversationListItem(Context context) {
this(context, null);
}
@@ -137,6 +145,7 @@ public class ConversationListItem extends RelativeLayout
@Nullable String highlightSubstring)
{
if (this.recipient != null) this.recipient.removeForeverObserver(this);
if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver);
this.selectedThreads = selectedThreads;
this.recipient = thread.getRecipient().live();
@@ -145,6 +154,7 @@ public class ConversationListItem extends RelativeLayout
this.unreadCount = thread.getUnreadCount();
this.distributionType = thread.getDistributionType();
this.lastSeen = thread.getLastSeen();
this.thread = thread;
this.recipient.observeForever(this);
if (highlightSubstring != null) {
@@ -166,6 +176,12 @@ public class ConversationListItem extends RelativeLayout
this.subjectView.setVisibility(VISIBLE);
this.subjectView.setText(getTrimmedSnippet(thread.getDisplayBody(getContext())));
if (thread.getGroupAddedBy() != null) {
groupAddedBy = Recipient.live(thread.getGroupAddedBy());
groupAddedBy.observeForever(groupAddedByObserver);
}
this.subjectView.setTypeface(unreadCount == 0 ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
this.subjectView.setTextColor(unreadCount == 0 ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color)
: ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color));
@@ -199,6 +215,7 @@ public class ConversationListItem extends RelativeLayout
@Nullable String highlightSubstring)
{
if (this.recipient != null) this.recipient.removeForeverObserver(this);
if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver);
this.selectedThreads = Collections.emptySet();
this.recipient = contact.live();
@@ -227,6 +244,7 @@ public class ConversationListItem extends RelativeLayout
@Nullable String highlightSubstring)
{
if (this.recipient != null) this.recipient.removeForeverObserver(this);
if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver);
this.selectedThreads = Collections.emptySet();
this.recipient = messageResult.conversationRecipient.live();
@@ -255,6 +273,11 @@ public class ConversationListItem extends RelativeLayout
this.recipient = null;
contactPhotoImage.setAvatar(glideRequests, null, true);
}
if (this.groupAddedBy != null) {
this.groupAddedBy.removeForeverObserver(groupAddedByObserver);
this.groupAddedBy = null;
}
}
private void setBatchState(boolean batch) {

View File

@@ -19,16 +19,18 @@ package org.thoughtcrime.securesms.database;
import android.content.Context;
import android.database.Cursor;
import android.database.DataSetObserver;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.ViewHolder;
import org.thoughtcrime.securesms.R;
/**
* RecyclerView.Adapter that manages a Cursor, comparable to the CursorAdapter usable in ListView/GridView.
*/
@@ -47,8 +49,24 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
private @Nullable View footer;
private static class HeaderFooterViewHolder extends RecyclerView.ViewHolder {
public HeaderFooterViewHolder(View itemView) {
private ViewGroup container;
HeaderFooterViewHolder(@NonNull View itemView) {
super(itemView);
this.container = (ViewGroup) itemView;
}
void bind(@Nullable View view) {
unbind();
if (view != null) {
container.addView(view);
}
}
void unbind() {
container.removeAllViews();
}
}
@@ -135,6 +153,8 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
public final void onViewRecycled(@NonNull ViewHolder holder) {
if (!(holder instanceof HeaderFooterViewHolder)) {
onItemViewRecycled((VH)holder);
} else {
((HeaderFooterViewHolder) holder).unbind();
}
}
@@ -143,9 +163,11 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
@Override
public @NonNull final ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
switch (viewType) {
case HEADER_TYPE: return new HeaderFooterViewHolder(header);
case FOOTER_TYPE: return new HeaderFooterViewHolder(footer);
default: return onCreateItemViewHolder(parent, viewType);
case HEADER_TYPE:
case FOOTER_TYPE:
return new HeaderFooterViewHolder(LayoutInflater.from(context).inflate(R.layout.cursor_adapter_header_footer_view, parent, false));
default:
return onCreateItemViewHolder(parent, viewType);
}
}
@@ -154,7 +176,11 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
@SuppressWarnings("unchecked")
@Override
public final void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (!isHeaderPosition(position) && !isFooterPosition(position)) {
if (isHeaderPosition(position)) {
((HeaderFooterViewHolder) viewHolder).bind(header);
} else if (isFooterPosition(position)) {
((HeaderFooterViewHolder) viewHolder).bind(footer);
} else {
if (isFastAccessPosition(position)) onBindFastAccessItemViewHolder((VH)viewHolder, position);
else onBindItemViewHolder((VH)viewHolder, getCursorAtPositionOrThrow(position));
}

View File

@@ -165,9 +165,14 @@ public class DatabaseFactory {
}
public static void upgradeRestored(Context context, SQLiteDatabase database){
getInstance(context).databaseHelper.onUpgrade(database, database.getVersion(), -1);
getInstance(context).databaseHelper.markCurrent(database);
getInstance(context).mms.trimEntriesForExpiredMessages();
synchronized (lock) {
getInstance(context).databaseHelper.onUpgrade(database, database.getVersion(), -1);
getInstance(context).databaseHelper.markCurrent(database);
getInstance(context).mms.trimEntriesForExpiredMessages();
instance.databaseHelper.close();
instance = null;
}
}
private DatabaseFactory(@NonNull Context context) {

View File

@@ -15,7 +15,6 @@ import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.BitmapUtil;
@@ -165,12 +164,14 @@ public class GroupDatabase extends Database {
public List<String> getGroupNamesContainingMember(RecipientId recipientId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
String table = TABLE_NAME + " INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + RECIPIENT_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID;
List<String> groupNames = new LinkedList<>();
String[] projection = new String[]{TITLE, MEMBERS};
String query = MEMBERS + " LIKE ?";
String[] args = new String[]{"%" + recipientId.serialize() + "%"};
String orderBy = ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.DATE + " DESC";
try (Cursor cursor = database.query(TABLE_NAME, projection, query, args, null, null, null)) {
try (Cursor cursor = database.query(table, projection, query, args, null, null, orderBy)) {
while (cursor != null && cursor.moveToNext()) {
List<String> members = Util.split(cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS)), ",");

View File

@@ -16,7 +16,7 @@ import java.util.Map;
public class KeyValueDatabase extends Database {
private static final String TABLE_NAME = "key_value";
public static final String TABLE_NAME = "key_value";
private static final String ID = "_id";
private static final String KEY = "key";

View File

@@ -30,6 +30,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
public abstract class MessagingDatabase extends Database implements MmsSmsColumns {
@@ -72,6 +73,36 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
return getMessageCountForRecipientsAndType(getOutgoingSecureMessageClause());
}
final int getSecureMessageCount(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] projection = new String[] {"COUNT(*)"};
String query = getSecureMessageClause() + "AND " + MmsSmsColumns.THREAD_ID + " = ?";
String[] args = new String[]{String.valueOf(threadId)};
try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
} else {
return 0;
}
}
}
final int getOutgoingSecureMessageCount(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] projection = new String[] {"COUNT(*)"};
String query = getOutgoingSecureMessageClause() + "AND " + MmsSmsColumns.THREAD_ID + " = ? AND" + "(" + getTypeField() + " & " + Types.GROUP_QUIT_BIT + " = 0)";
String[] args = new String[]{String.valueOf(threadId)};
try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
} else {
return 0;
}
}
}
private int getMessageCountForRecipientsAndType(String typeClause) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
@@ -96,6 +127,14 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
return "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND (" + getTypeField() + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")";
}
private String getSecureMessageClause() {
String isSent = "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE;
String isReceived = "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_INBOX_TYPE;
String isSecure = "(" + getTypeField() + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")";
return String.format(Locale.ENGLISH, "(%s OR %s) AND %s", isSent, isReceived, isSecure);
}
public void setReactionsSeen(long threadId) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues();
@@ -432,14 +471,20 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
public static class MarkedMessageInfo {
private final long threadId;
private final SyncMessageId syncMessageId;
private final ExpirationInfo expirationInfo;
public MarkedMessageInfo(SyncMessageId syncMessageId, ExpirationInfo expirationInfo) {
public MarkedMessageInfo(long threadId, SyncMessageId syncMessageId, ExpirationInfo expirationInfo) {
this.threadId = threadId;
this.syncMessageId = syncMessageId;
this.expirationInfo = expirationInfo;
}
public long getThreadId() {
return threadId;
}
public SyncMessageId getSyncMessageId() {
return syncMessageId;
}

View File

@@ -20,11 +20,12 @@ import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.text.TextUtils;
import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.google.android.mms.pdu_alt.NotificationInd;
@@ -81,7 +82,6 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import static org.thoughtcrime.securesms.contactshare.Contact.Avatar;
@@ -244,18 +244,69 @@ public class MmsDatabase extends MessagingDatabase {
return MESSAGE_BOX;
}
public boolean isGroupQuitMessage(long messageId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] columns = new String[]{ID};
String query = ID + " = ? AND " + MESSAGE_BOX + " & ?";
long type = Types.getOutgoingEncryptedMessageType() | Types.GROUP_QUIT_BIT;
String[] args = new String[]{String.valueOf(messageId), String.valueOf(type)};
try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, null, null)) {
if (cursor.getCount() == 1) {
return true;
}
}
return false;
}
public long getLatestGroupQuitTimestamp(long threadId, long quitTimeBarrier) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] columns = new String[]{DATE_SENT};
String query = THREAD_ID + " = ? AND " + MESSAGE_BOX + " & ? AND " + DATE_SENT + " < ?";
long type = Types.getOutgoingEncryptedMessageType() | Types.GROUP_QUIT_BIT;
String[] args = new String[]{String.valueOf(threadId), String.valueOf(type), String.valueOf(quitTimeBarrier)};
String orderBy = DATE_SENT + " DESC";
String limit = "1";
try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, orderBy, limit)) {
if (cursor.moveToFirst()) {
return cursor.getLong(cursor.getColumnIndex(DATE_SENT));
}
}
return -1;
}
public int getMessageCountForThread(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = null;
try {
cursor = db.query(TABLE_NAME, new String[] {"COUNT(*)"}, THREAD_ID + " = ?", new String[] {threadId+""}, null, null, null);
String[] cols = new String[] {"COUNT(*)"};
String query = THREAD_ID + " = ?";
String[] args = new String[]{String.valueOf(threadId)};
if (cursor != null && cursor.moveToFirst())
try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
} finally {
if (cursor != null)
cursor.close();
}
}
return 0;
}
public int getMessageCountForThread(long threadId, long beforeTime) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] cols = new String[] {"COUNT(*)"};
String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < ?";
String[] args = new String[]{String.valueOf(threadId), String.valueOf(beforeTime)};
try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
}
}
return 0;
@@ -533,14 +584,20 @@ public class MmsDatabase extends MessagingDatabase {
database.beginTransaction();
try {
cursor = database.query(TABLE_NAME, new String[] {ID, RECIPIENT_ID, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED}, where, arguments, null, null, null);
cursor = database.query(TABLE_NAME, new String[] {ID, RECIPIENT_ID, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED, THREAD_ID}, where, arguments, null, null, null);
while(cursor != null && cursor.moveToNext()) {
if (Types.isSecureType(cursor.getLong(3))) {
SyncMessageId syncMessageId = new SyncMessageId(RecipientId.from(cursor.getLong(1)), cursor.getLong(2));
ExpirationInfo expirationInfo = new ExpirationInfo(cursor.getLong(0), cursor.getLong(4), cursor.getLong(5), true);
if (Types.isSecureType(cursor.getLong(cursor.getColumnIndex(MESSAGE_BOX)))) {
long threadId = cursor.getLong(cursor.getColumnIndex(THREAD_ID));
RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndex(RECIPIENT_ID)));
long dateSent = cursor.getLong(cursor.getColumnIndex(DATE_SENT));
long messageId = cursor.getLong(cursor.getColumnIndex(ID));
long expiresIn = cursor.getLong(cursor.getColumnIndex(EXPIRES_IN));
long expireStarted = cursor.getLong(cursor.getColumnIndex(EXPIRE_STARTED));
SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent);
ExpirationInfo expirationInfo = new ExpirationInfo(messageId, expiresIn, expireStarted, true);
result.add(new MarkedMessageInfo(syncMessageId, expirationInfo));
result.add(new MarkedMessageInfo(threadId, syncMessageId, expirationInfo));
}
}

View File

@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.libsignal.util.Pair;
import java.util.HashSet;
import java.util.Set;
@@ -84,6 +85,35 @@ public class MmsSmsDatabase extends Database {
super(context, databaseHelper);
}
/**
* @return The user that added you to the group, otherwise null.
*/
public @Nullable RecipientId getGroupAddedBy(long threadId) {
long lastQuitChecked = System.currentTimeMillis();
Pair<RecipientId, Long> pair;
do {
pair = getGroupAddedBy(threadId, lastQuitChecked);
if (pair.first() != null) {
return pair.first();
} else {
lastQuitChecked = pair.second();
}
} while (pair.second() != -1);
return null;
}
private @NonNull Pair<RecipientId, Long> getGroupAddedBy(long threadId, long lastQuitChecked) {
MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context);
long latestQuit = mmsDatabase.getLatestGroupQuitTimestamp(threadId, lastQuitChecked);
RecipientId id = smsDatabase.getOldestGroupUpdateSender(threadId, latestQuit);
return new Pair<>(id, latestQuit);
}
public @Nullable MessageRecord getMessageFor(long timestamp, RecipientId author) {
MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context);
@@ -166,6 +196,28 @@ public class MmsSmsDatabase extends Database {
}
}
public int getSecureConversationCount(long threadId) {
if (threadId == -1) {
return 0;
}
int count = DatabaseFactory.getSmsDatabase(context).getSecureMessageCount(threadId);
count += DatabaseFactory.getMmsDatabase(context).getSecureMessageCount(threadId);
return count;
}
public int getOutgoingSecureConversationCount(long threadId) {
if (threadId == -1L) {
return 0;
}
int count = DatabaseFactory.getSmsDatabase(context).getOutgoingSecureMessageCount(threadId);
count += DatabaseFactory.getMmsDatabase(context).getOutgoingSecureMessageCount(threadId);
return count;
}
public int getConversationCount(long threadId) {
int count = DatabaseFactory.getSmsDatabase(context).getMessageCountForThread(threadId);
count += DatabaseFactory.getMmsDatabase(context).getMessageCountForThread(threadId);
@@ -173,6 +225,11 @@ public class MmsSmsDatabase extends Database {
return count;
}
public int getConversationCount(long threadId, long beforeTime) {
return DatabaseFactory.getSmsDatabase(context).getMessageCountForThread(threadId, beforeTime) +
DatabaseFactory.getMmsDatabase(context).getMessageCountForThread(threadId, beforeTime);
}
public int getInsecureSentCount(long threadId) {
int count = DatabaseFactory.getSmsDatabase(context).getInsecureMessagesSentForThread(threadId);
count += DatabaseFactory.getMmsDatabase(context).getInsecureMessagesSentForThread(threadId);
@@ -194,6 +251,13 @@ public class MmsSmsDatabase extends Database {
return count;
}
public long getThreadForMessageId(long messageId) {
long id = DatabaseFactory.getSmsDatabase(context).getThreadIdForMessage(messageId);
if (id == -1) return DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(messageId);
else return id;
}
public void incrementDeliveryReceiptCount(SyncMessageId syncMessageId, long timestamp) {
DatabaseFactory.getSmsDatabase(context).incrementReceiptCount(syncMessageId, true, false);
DatabaseFactory.getMmsDatabase(context).incrementReceiptCount(syncMessageId, timestamp, true, false);

View File

@@ -21,21 +21,24 @@ import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.StorageSyncJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.SqlUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record;
import org.whispersystems.signalservice.api.storage.StorageKey;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.Closeable;
@@ -62,6 +65,7 @@ public class RecipientDatabase extends Database {
public static final String PHONE = "phone";
public static final String EMAIL = "email";
static final String GROUP_ID = "group_id";
private static final String GROUP_TYPE = "group_type";
private static final String BLOCKED = "blocked";
private static final String MESSAGE_RINGTONE = "message_ringtone";
private static final String MESSAGE_VIBRATE = "message_vibrate";
@@ -100,7 +104,7 @@ public class RecipientDatabase extends Database {
private static final String[] RECIPIENT_PROJECTION = new String[] {
UUID, USERNAME, PHONE, EMAIL, GROUP_ID,
UUID, USERNAME, PHONE, EMAIL, GROUP_ID, GROUP_TYPE,
BLOCKED, MESSAGE_RINGTONE, CALL_RINGTONE, MESSAGE_VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, MESSAGE_EXPIRATION_TIME, REGISTERED,
PROFILE_KEY, PROFILE_KEY_CREDENTIAL,
SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, SYSTEM_CONTACT_URI,
@@ -120,6 +124,7 @@ public class RecipientDatabase extends Database {
public static final String[] CREATE_INDEXS = new String[] {
"CREATE INDEX IF NOT EXISTS recipient_dirty_index ON " + TABLE_NAME + " (" + DIRTY + ");",
"CREATE INDEX IF NOT EXISTS recipient_group_type_index ON " + TABLE_NAME + " (" + GROUP_TYPE + ");",
};
private static final String[] ID_PROJECTION = new String[]{ID};
@@ -219,6 +224,24 @@ public class RecipientDatabase extends Database {
}
}
public enum GroupType {
NONE(0), MMS(1), SIGNAL_V1(2);
private final int id;
GroupType(int id) {
this.id = id;
}
int getId() {
return id;
}
public static GroupType fromId(int id) {
return values()[id];
}
}
public static final String CREATE_TABLE =
"CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
UUID + " TEXT UNIQUE DEFAULT NULL, " +
@@ -226,6 +249,7 @@ public class RecipientDatabase extends Database {
PHONE + " TEXT UNIQUE DEFAULT NULL, " +
EMAIL + " TEXT UNIQUE DEFAULT NULL, " +
GROUP_ID + " TEXT UNIQUE DEFAULT NULL, " +
GROUP_TYPE + " INTEGER DEFAULT " + GroupType.NONE.getId() + ", " +
BLOCKED + " INTEGER DEFAULT 0," +
MESSAGE_RINGTONE + " TEXT DEFAULT NULL, " +
MESSAGE_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " +
@@ -255,7 +279,7 @@ public class RecipientDatabase extends Database {
FORCE_SMS_SELECTION + " INTEGER DEFAULT 0, " +
UUID_SUPPORTED + " INTEGER DEFAULT 0, " +
STORAGE_SERVICE_KEY + " TEXT UNIQUE DEFAULT NULL, " +
DIRTY + " INTEGER DEFAULT 0);";
DIRTY + " INTEGER DEFAULT " + DirtyState.CLEAN.getId() + ");";
private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID +
" FROM " + TABLE_NAME +
@@ -305,19 +329,35 @@ public class RecipientDatabase extends Database {
}
public @NonNull RecipientId getOrInsertFromUuid(@NonNull UUID uuid) {
return getOrInsertByColumn(UUID, uuid.toString());
return getOrInsertByColumn(UUID, uuid.toString()).recipientId;
}
public @NonNull RecipientId getOrInsertFromE164(@NonNull String e164) {
return getOrInsertByColumn(PHONE, e164);
return getOrInsertByColumn(PHONE, e164).recipientId;
}
public RecipientId getOrInsertFromEmail(@NonNull String email) {
return getOrInsertByColumn(EMAIL, email);
public @NonNull RecipientId getOrInsertFromEmail(@NonNull String email) {
return getOrInsertByColumn(EMAIL, email).recipientId;
}
public RecipientId getOrInsertFromGroupId(@NonNull String groupId) {
return getOrInsertByColumn(GROUP_ID, groupId);
public @NonNull RecipientId getOrInsertFromGroupId(@NonNull String groupId) {
GetOrInsertResult result = getOrInsertByColumn(GROUP_ID, groupId);
if (result.neededInsert) {
ContentValues values = new ContentValues();
if (GroupUtil.isMmsGroup(groupId)) {
values.put(GROUP_TYPE, GroupType.MMS.getId());
} else {
values.put(GROUP_TYPE, GroupType.SIGNAL_V1.getId());
values.put(DIRTY, DirtyState.INSERT.getId());
values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey()));
}
update(result.recipientId, values);
}
return result.recipientId;
}
public Cursor getBlocked() {
@@ -355,15 +395,24 @@ public class RecipientDatabase extends Database {
}
public @NonNull List<RecipientSettings> getPendingRecipientSyncUpdates() {
return getRecipientSettings(DIRTY + " = ?", new String[] { String.valueOf(DirtyState.UPDATE.getId()) });
String query = DIRTY + " = ? AND " + STORAGE_SERVICE_KEY + " NOT NULL";
String[] args = new String[] { String.valueOf(DirtyState.UPDATE.getId()) };
return getRecipientSettings(query, args);
}
public @NonNull List<RecipientSettings> getPendingRecipientSyncInsertions() {
return getRecipientSettings(DIRTY + " = ?", new String[] { String.valueOf(DirtyState.INSERT.getId()) });
String query = DIRTY + " = ? AND " + STORAGE_SERVICE_KEY + " NOT NULL";
String[] args = new String[] { String.valueOf(DirtyState.INSERT.getId()) };
return getRecipientSettings(query, args);
}
public @NonNull List<RecipientSettings> getPendingRecipientSyncDeletions() {
return getRecipientSettings(DIRTY + " = ?", new String[] { String.valueOf(DirtyState.DELETE.getId()) });
String query = DIRTY + " = ? AND " + STORAGE_SERVICE_KEY + " NOT NULL";
String[] args = new String[] { String.valueOf(DirtyState.DELETE.getId()) };
return getRecipientSettings(query, args);
}
public @Nullable RecipientSettings getByStorageSyncKey(@NonNull byte[] key) {
@@ -396,8 +445,10 @@ public class RecipientDatabase extends Database {
}
}
public void applyStorageSyncUpdates(@NonNull Collection<SignalContactRecord> inserts,
@NonNull Collection<StorageSyncHelper.ContactUpdate> updates)
public void applyStorageSyncUpdates(@NonNull Collection<SignalContactRecord> contactInserts,
@NonNull Collection<StorageSyncHelper.ContactUpdate> contactUpdates,
@NonNull Collection<SignalGroupV1Record> groupV1Inserts,
@NonNull Collection<StorageSyncHelper.GroupV1Update> groupV1Updates)
{
SQLiteDatabase db = databaseHelper.getWritableDatabase();
IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context);
@@ -405,32 +456,50 @@ public class RecipientDatabase extends Database {
db.beginTransaction();
try {
for (SignalContactRecord insert : inserts) {
ContentValues values = getValuesForStorageContact(insert);
long id = db.insertOrThrow(TABLE_NAME, null, values);
RecipientId recipientId = RecipientId.from(id);
if (insert.getIdentityKey().isPresent()) {
try {
IdentityKey identityKey = new IdentityKey(insert.getIdentityKey().get(), 0);
for (SignalContactRecord insert : contactInserts) {
ContentValues values = getValuesForStorageContact(insert);
long id = db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE);
DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncHelper.remoteToLocalIdentityStatus(insert.getIdentityState()));
IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), true, true);
} catch (InvalidKeyException e) {
Log.w(TAG, "Failed to process identity key during insert! Skipping.", e);
if (id < 0) {
Log.w(TAG, "Failed to insert! It's likely that these were newly-registered users that were missed in the merge. Doing an update instead.");
if (insert.getAddress().getNumber().isPresent()) {
int count = db.update(TABLE_NAME, values, PHONE + " = ?", new String[] { insert.getAddress().getNumber().get() });
Log.w(TAG, "Updated " + count + " users by E164.");
} else {
int count = db.update(TABLE_NAME, values, UUID + " = ?", new String[] { insert.getAddress().getUuid().get().toString() });
Log.w(TAG, "Updated " + count + " users by UUID.");
}
} else {
RecipientId recipientId = RecipientId.from(id);
if (insert.getIdentityKey().isPresent()) {
try {
IdentityKey identityKey = new IdentityKey(insert.getIdentityKey().get(), 0);
DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncHelper.remoteToLocalIdentityStatus(insert.getIdentityState()));
IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), true, true);
} catch (InvalidKeyException e) {
Log.w(TAG, "Failed to process identity key during insert! Skipping.", e);
}
}
if (Recipient.self().getId().equals(recipientId)) {
TextSecurePreferences.setProfileName(context, ProfileName.fromParts(insert.getGivenName().orNull(), insert.getFamilyName().orNull()));
}
}
}
for (StorageSyncHelper.ContactUpdate update : updates) {
ContentValues values = getValuesForStorageContact(update.getNewContact());
int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_KEY + " = ?", new String[]{Base64.encodeBytes(update.getOldContact().getKey())});
for (StorageSyncHelper.ContactUpdate update : contactUpdates) {
ContentValues values = getValuesForStorageContact(update.getNew());
int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_KEY + " = ?", new String[]{Base64.encodeBytes(update.getOld().getKey())});
if (updateCount < 1) {
throw new AssertionError("Had an update, but it didn't match any rows!");
}
RecipientId recipientId = getByStorageKeyOrThrow(update.getNewContact().getKey());
RecipientId recipientId = getByStorageKeyOrThrow(update.getNew().getKey());
if (update.profileKeyChanged()) {
clearProfileKeyCredential(recipientId);
@@ -438,9 +507,11 @@ public class RecipientDatabase extends Database {
try {
Optional<IdentityRecord> oldIdentityRecord = identityDatabase.getIdentity(recipientId);
IdentityKey identityKey = update.getNewContact().getIdentityKey().isPresent() ? new IdentityKey(update.getNewContact().getIdentityKey().get(), 0) : null;
DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncHelper.remoteToLocalIdentityStatus(update.getNewContact().getIdentityState()));
if (update.getNew().getIdentityKey().isPresent()) {
IdentityKey identityKey = new IdentityKey(update.getNew().getIdentityKey().get(), 0);
DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncHelper.remoteToLocalIdentityStatus(update.getNew().getIdentityState()));
}
Optional<IdentityRecord> newIdentityRecord = identityDatabase.getIdentity(recipientId);
@@ -458,6 +529,19 @@ public class RecipientDatabase extends Database {
}
}
for (SignalGroupV1Record insert : groupV1Inserts) {
db.insertOrThrow(TABLE_NAME, null, getValuesForStorageGroupV1(insert));
}
for (StorageSyncHelper.GroupV1Update update : groupV1Updates) {
ContentValues values = getValuesForStorageGroupV1(update.getNew());
int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_KEY + " = ?", new String[]{Base64.encodeBytes(update.getOld().getKey())});
if (updateCount < 1) {
throw new AssertionError("Had an update, but it didn't match any rows!");
}
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
@@ -508,14 +592,14 @@ public class RecipientDatabase extends Database {
values.put(UUID, contact.getAddress().getUuid().get().toString());
}
ProfileName profileName = ProfileName.fromSerialized(contact.getProfileName().orNull());
ProfileName profileName = ProfileName.fromParts(contact.getGivenName().orNull(), contact.getFamilyName().orNull());
values.put(PHONE, contact.getAddress().getNumber().orNull());
values.put(PROFILE_GIVEN_NAME, profileName.getGivenName());
values.put(PROFILE_FAMILY_NAME, profileName.getFamilyName());
values.put(PROFILE_JOINED_NAME, profileName.toString());
values.put(PROFILE_KEY, contact.getProfileKey().orNull());
// TODO [greyson] Username
values.put(PROFILE_KEY, contact.getProfileKey().transform(Base64::encodeBytes).orNull());
values.put(USERNAME, contact.getUsername().orNull());
values.put(PROFILE_SHARING, contact.isProfileSharingEnabled() ? "1" : "0");
values.put(BLOCKED, contact.isBlocked() ? "1" : "0");
values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(contact.getKey()));
@@ -523,6 +607,17 @@ public class RecipientDatabase extends Database {
return values;
}
private static @NonNull ContentValues getValuesForStorageGroupV1(@NonNull SignalGroupV1Record groupV1) {
ContentValues values = new ContentValues();
values.put(GROUP_ID, GroupUtil.getEncodedId(groupV1.getGroupId(), false));
values.put(GROUP_TYPE, GroupType.SIGNAL_V1.getId());
values.put(PROFILE_SHARING, groupV1.isProfileSharingEnabled() ? "1" : "0");
values.put(BLOCKED, groupV1.isBlocked() ? "1" : "0");
values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(groupV1.getKey()));
values.put(DIRTY, DirtyState.CLEAN.getId());
return values;
}
private List<RecipientSettings> getRecipientSettings(@Nullable String query, @Nullable String[] args) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String table = TABLE_NAME + " LEFT OUTER JOIN " + IdentityDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + ID + " = " + IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.RECIPIENT_ID;
@@ -555,27 +650,24 @@ public class RecipientDatabase extends Database {
try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID, STORAGE_SERVICE_KEY }, query, args, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID)));
RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID)));
String encodedKey = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_SERVICE_KEY));
try {
out.put(id, Base64.decode(encodedKey));
} catch (IOException e) {
throw new AssertionError(e);
}
out.put(id, Base64.decodeOrThrow(encodedKey));
}
}
return out;
}
@NonNull RecipientSettings getRecipientSettings(@NonNull Cursor cursor) {
private @NonNull RecipientSettings getRecipientSettings(@NonNull Cursor cursor) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID));
UUID uuid = UuidUtil.parseOrNull(cursor.getString(cursor.getColumnIndexOrThrow(UUID)));
String username = cursor.getString(cursor.getColumnIndexOrThrow(USERNAME));
String e164 = cursor.getString(cursor.getColumnIndexOrThrow(PHONE));
String email = cursor.getString(cursor.getColumnIndexOrThrow(EMAIL));
String groupId = cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID));
int groupType = cursor.getInt(cursor.getColumnIndexOrThrow(GROUP_TYPE));
boolean blocked = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCKED)) == 1;
String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(MESSAGE_RINGTONE));
String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE));
@@ -634,23 +726,12 @@ public class RecipientDatabase extends Database {
}
}
byte[] storageKey = null;
try {
storageKey = storageKeyRaw != null ? Base64.decode(storageKeyRaw) : null;
} catch (IOException e) {
throw new AssertionError(e);
}
byte[] identityKey = null;
try {
identityKey = identityKeyRaw != null ? Base64.decode(identityKeyRaw) : null;
} catch (IOException e) {
throw new AssertionError(e);
}
byte[] storageKey = storageKeyRaw != null ? Base64.decodeOrThrow(storageKeyRaw) : null;
byte[] identityKey = identityKeyRaw != null ? Base64.decodeOrThrow(identityKeyRaw) : null;
IdentityDatabase.VerifiedStatus identityStatus = IdentityDatabase.VerifiedStatus.forState(identityStatusRaw);
return new RecipientSettings(RecipientId.from(id), uuid, username, e164, email, groupId, blocked, muteUntil,
return new RecipientSettings(RecipientId.from(id), uuid, username, e164, email, groupId, GroupType.fromId(groupType), blocked, muteUntil,
VibrateState.fromId(messageVibrateState),
VibrateState.fromId(callVibrateState),
Util.uri(messageRingtone), Util.uri(callRingtone),
@@ -820,6 +901,7 @@ public class RecipientDatabase extends Database {
if (update(updateQuery, valuesToSet)) {
markDirty(id, DirtyState.UPDATE);
Recipient.live(id).refresh();
ApplicationDependencies.getJobManager().add(new StorageSyncJob());
return true;
} else {
return false;
@@ -865,6 +947,7 @@ public class RecipientDatabase extends Database {
if (update(id, contentValues)) {
markDirty(id, DirtyState.UPDATE);
Recipient.live(id).refresh();
ApplicationDependencies.getJobManager().add(new StorageSyncJob());
}
}
@@ -882,6 +965,7 @@ public class RecipientDatabase extends Database {
if (update(id, contentValues)) {
markDirty(id, DirtyState.UPDATE);
Recipient.live(id).refresh();
ApplicationDependencies.getJobManager().add(new StorageSyncJob());
}
}
@@ -947,7 +1031,6 @@ public class RecipientDatabase extends Database {
ContentValues contentValues = new ContentValues(3);
contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId());
contentValues.put(UUID, uuid.toString().toLowerCase());
contentValues.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey()));
if (update(id, contentValues)) {
markDirty(id, DirtyState.INSERT);
Recipient.live(id).refresh();
@@ -962,7 +1045,6 @@ public class RecipientDatabase extends Database {
public void markRegistered(@NonNull RecipientId id) {
ContentValues contentValues = new ContentValues(2);
contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId());
contentValues.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey()));
if (update(id, contentValues)) {
markDirty(id, DirtyState.INSERT);
Recipient.live(id).refresh();
@@ -1010,10 +1092,22 @@ public class RecipientDatabase extends Database {
@Deprecated
public void setRegistered(@NonNull RecipientId id, RegisteredState registeredState) {
ContentValues contentValues = new ContentValues(1);
ContentValues contentValues = new ContentValues(2);
contentValues.put(REGISTERED, registeredState.getId());
update(id, contentValues);
Recipient.live(id).refresh();
if (registeredState == RegisteredState.REGISTERED) {
contentValues.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey()));
}
if (update(id, contentValues)) {
if (registeredState == RegisteredState.REGISTERED) {
markDirty(id, DirtyState.INSERT);
} else if (registeredState == RegisteredState.NOT_REGISTERED) {
markDirty(id, DirtyState.DELETE);
}
Recipient.live(id).refresh();
}
}
@Deprecated
@@ -1021,10 +1115,11 @@ public class RecipientDatabase extends Database {
@NonNull Collection<RecipientId> inactiveIds)
{
for (RecipientId activeId : activeIds) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId());
ContentValues registeredValues = new ContentValues(1);
registeredValues.put(REGISTERED, RegisteredState.REGISTERED.getId());
if (update(activeId, contentValues)) {
if (update(activeId, registeredValues)) {
markDirty(activeId, DirtyState.INSERT);
Recipient.live(activeId).refresh();
}
}
@@ -1034,6 +1129,7 @@ public class RecipientDatabase extends Database {
contentValues.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId());
if (update(inactiveId, contentValues)) {
markDirty(inactiveId, DirtyState.DELETE);
Recipient.live(inactiveId).refresh();
}
}
@@ -1222,6 +1318,7 @@ public class RecipientDatabase extends Database {
ContentValues setBlocked = new ContentValues();
setBlocked.put(BLOCKED, 1);
setBlocked.put(PROFILE_SHARING, 0);
for (String e164 : blockedE164) {
db.update(TABLE_NAME, setBlocked, PHONE + " = ?", new String[] { e164 });
@@ -1281,14 +1378,27 @@ public class RecipientDatabase extends Database {
}
void markDirty(@NonNull RecipientId recipientId, @NonNull DirtyState dirtyState) {
if (!FeatureFlags.storageService()) return;
ContentValues contentValues = new ContentValues(1);
contentValues.put(DIRTY, dirtyState.getId());
String query = ID + " = ? AND (" + UUID + " NOT NULL OR " + PHONE + " NOT NULL) AND " + DIRTY + " < ?";
String query = ID + " = ? AND (" + UUID + " NOT NULL OR " + PHONE + " NOT NULL) AND ";
String[] args = new String[] { recipientId.serialize(), String.valueOf(dirtyState.id) };
switch (dirtyState) {
case INSERT:
query += "(" + DIRTY + " < ? OR " + DIRTY + " = ?)";
args = SqlUtil.appendArg(args, String.valueOf(DirtyState.DELETE.getId()));
contentValues.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey()));
break;
case DELETE:
query += "(" + DIRTY + " < ? OR " + DIRTY + " = ?)";
args = SqlUtil.appendArg(args, String.valueOf(DirtyState.INSERT.getId()));
break;
default:
query += DIRTY + " < ?";
}
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, query, args);
}
@@ -1329,7 +1439,7 @@ public class RecipientDatabase extends Database {
}
}
private @NonNull RecipientId getOrInsertByColumn(@NonNull String column, String value) {
private @NonNull GetOrInsertResult getOrInsertByColumn(@NonNull String column, String value) {
if (TextUtils.isEmpty(value)) {
throw new AssertionError(column + " cannot be empty.");
}
@@ -1337,7 +1447,7 @@ public class RecipientDatabase extends Database {
Optional<RecipientId> existing = getByColumn(column, value);
if (existing.isPresent()) {
return existing.get();
return new GetOrInsertResult(existing.get(), false);
} else {
ContentValues values = new ContentValues();
values.put(column, value);
@@ -1348,12 +1458,12 @@ public class RecipientDatabase extends Database {
existing = getByColumn(column, value);
if (existing.isPresent()) {
return existing.get();
return new GetOrInsertResult(existing.get(), false);
} else {
throw new AssertionError("Failed to insert recipient!");
}
} else {
return RecipientId.from(id);
return new GetOrInsertResult(RecipientId.from(id), true);
}
}
}
@@ -1444,6 +1554,7 @@ public class RecipientDatabase extends Database {
private final String e164;
private final String email;
private final String groupId;
private final GroupType groupType;
private final boolean blocked;
private final long muteUntil;
private final VibrateState messageVibrateState;
@@ -1478,7 +1589,9 @@ public class RecipientDatabase extends Database {
@Nullable String e164,
@Nullable String email,
@Nullable String groupId,
boolean blocked, long muteUntil,
@NonNull GroupType groupType,
boolean blocked,
long muteUntil,
@NonNull VibrateState messageVibrateState,
@NonNull VibrateState callVibrateState,
@Nullable Uri messageRingtone,
@@ -1511,6 +1624,7 @@ public class RecipientDatabase extends Database {
this.e164 = e164;
this.email = email;
this.groupId = groupId;
this.groupType = groupType;
this.blocked = blocked;
this.muteUntil = muteUntil;
this.messageVibrateState = messageVibrateState;
@@ -1564,6 +1678,10 @@ public class RecipientDatabase extends Database {
return groupId;
}
public @NonNull GroupType getGroupType() {
return groupType;
}
public @Nullable MaterialColor getColor() {
return color;
}
@@ -1719,4 +1837,14 @@ public class RecipientDatabase extends Database {
super("Failed to find recipient with ID: " + id);
}
}
private static class GetOrInsertResult {
final RecipientId recipientId;
final boolean neededInsert;
private GetOrInsertResult(@NonNull RecipientId recipientId, boolean neededInsert) {
this.recipientId = recipientId;
this.neededInsert = neededInsert;
}
}
}

View File

@@ -20,10 +20,12 @@ package org.thoughtcrime.securesms.database;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import androidx.annotation.NonNull;
import android.text.TextUtils;
import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase;
@@ -45,7 +47,6 @@ import org.thoughtcrime.securesms.sms.IncomingTextMessage;
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
@@ -55,7 +56,6 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* Database for storage of SMS messages.
@@ -162,6 +162,24 @@ public class SmsDatabase extends MessagingDatabase {
notifyConversationListeners(threadId);
}
public @Nullable RecipientId getOldestGroupUpdateSender(long threadId, long minimumDateReceived) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] columns = new String[]{RECIPIENT_ID};
String query = THREAD_ID + " = ? AND " + TYPE + " & ? AND " + DATE_RECEIVED + " >= ?";
long type = Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT | Types.GROUP_UPDATE_BIT | Types.BASE_INBOX_TYPE;
String[] args = new String[]{String.valueOf(threadId), String.valueOf(type), String.valueOf(minimumDateReceived)};
String limit = "1";
try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, limit)) {
if (cursor.moveToFirst()) {
return RecipientId.from(cursor.getLong(cursor.getColumnIndex(RECIPIENT_ID)));
}
}
return null;
}
public long getThreadIdForMessage(long id) {
String sql = "SELECT " + THREAD_ID + " FROM " + TABLE_NAME + " WHERE " + ID + " = ?";
String[] sqlArgs = new String[] {id+""};
@@ -198,17 +216,31 @@ public class SmsDatabase extends MessagingDatabase {
public int getMessageCountForThread(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = null;
try {
cursor = db.query(TABLE_NAME, new String[] {"COUNT(*)"}, THREAD_ID + " = ?",
new String[] {threadId+""}, null, null, null);
String[] cols = new String[] {"COUNT(*)"};
String query = THREAD_ID + " = ?";
String[] args = new String[]{String.valueOf(threadId)};
if (cursor != null && cursor.moveToFirst())
try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
} finally {
if (cursor != null)
cursor.close();
}
}
return 0;
}
public int getMessageCountForThread(long threadId, long beforeTime) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] cols = new String[] {"COUNT(*)"};
String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < ?";
String[] args = new String[]{String.valueOf(threadId), String.valueOf(beforeTime)};
try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
}
}
return 0;
@@ -446,14 +478,20 @@ public class SmsDatabase extends MessagingDatabase {
database.beginTransaction();
try {
cursor = database.query(TABLE_NAME, new String[] {ID, RECIPIENT_ID, DATE_SENT, TYPE, EXPIRES_IN, EXPIRE_STARTED}, where, arguments, null, null, null);
cursor = database.query(TABLE_NAME, new String[] {ID, RECIPIENT_ID, DATE_SENT, TYPE, EXPIRES_IN, EXPIRE_STARTED, THREAD_ID}, where, arguments, null, null, null);
while (cursor != null && cursor.moveToNext()) {
if (Types.isSecureType(cursor.getLong(3))) {
SyncMessageId syncMessageId = new SyncMessageId(RecipientId.from(cursor.getLong(1)), cursor.getLong(2));
ExpirationInfo expirationInfo = new ExpirationInfo(cursor.getLong(0), cursor.getLong(4), cursor.getLong(5), false);
if (Types.isSecureType(cursor.getLong(cursor.getColumnIndex(TYPE)))) {
long threadId = cursor.getLong(cursor.getColumnIndex(THREAD_ID));
RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndex(RECIPIENT_ID)));
long dateSent = cursor.getLong(cursor.getColumnIndex(DATE_SENT));
long messageId = cursor.getLong(cursor.getColumnIndex(ID));
long expiresIn = cursor.getLong(cursor.getColumnIndex(EXPIRES_IN));
long expireStarted = cursor.getLong(cursor.getColumnIndex(EXPIRE_STARTED));
SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent);
ExpirationInfo expirationInfo = new ExpirationInfo(messageId, expiresIn, expireStarted, false);
results.add(new MarkedMessageInfo(syncMessageId, expirationInfo));
results.add(new MarkedMessageInfo(threadId, syncMessageId, expirationInfo));
}
}

View File

@@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
@@ -704,13 +705,30 @@ public class ThreadDatabase extends Database {
}
private @Nullable Extra getExtrasFor(MessageRecord record) {
boolean messageRequestAccepted = RecipientUtil.isMessageRequestAccepted(context, record.getThreadId());
RecipientId threadRecipientId = getRecipientIdForThreadId(record.getThreadId());
if (!messageRequestAccepted && threadRecipientId != null) {
boolean isPushGroup = Recipient.resolved(threadRecipientId).isPushGroup();
if (isPushGroup) {
RecipientId recipientId = DatabaseFactory.getMmsSmsDatabase(context).getGroupAddedBy(record.getThreadId());
if (recipientId != null) {
return Extra.forGroupMessageRequest(recipientId);
}
}
return Extra.forMessageRequest();
}
if (record.isMms() && ((MmsMessageRecord) record).isViewOnce()) {
return Extra.forRevealableMessage();
return Extra.forRevealable();
} else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getStickerSlide() != null) {
return Extra.forSticker();
} else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getSlides().size() > 1) {
return Extra.forAlbum();
}
return null;
}
@@ -829,28 +847,41 @@ public class ThreadDatabase extends Database {
@JsonProperty private final boolean isRevealable;
@JsonProperty private final boolean isSticker;
@JsonProperty private final boolean isAlbum;
@JsonProperty private final boolean isMessageRequestAccepted;
@JsonProperty private final String groupAddedBy;
public Extra(@JsonProperty("isRevealable") boolean isRevealable,
@JsonProperty("isSticker") boolean isSticker,
@JsonProperty("isAlbum") boolean isAlbum)
@JsonProperty("isAlbum") boolean isAlbum,
@JsonProperty("isMessageRequestAccepted") boolean isMessageRequestAccepted,
@JsonProperty("groupAddedBy") String groupAddedBy)
{
this.isRevealable = isRevealable;
this.isSticker = isSticker;
this.isAlbum = isAlbum;
this.isRevealable = isRevealable;
this.isSticker = isSticker;
this.isAlbum = isAlbum;
this.isMessageRequestAccepted = isMessageRequestAccepted;
this.groupAddedBy = groupAddedBy;
}
public static @NonNull Extra forRevealableMessage() {
return new Extra(true, false, false);
public static @NonNull Extra forRevealable() {
return new Extra(true, false, false, true, null);
}
public static @NonNull Extra forSticker() {
return new Extra(false, true, false);
return new Extra(false, true, false, true, null);
}
public static @NonNull Extra forAlbum() {
return new Extra(false, false, true);
return new Extra(false, false, true, true, null);
}
public static @NonNull Extra forMessageRequest() {
return new Extra(false, false, false, false, null);
}
public static @NonNull Extra forGroupMessageRequest(RecipientId recipientId) {
return new Extra(false, false, false, false, recipientId.serialize());
}
public boolean isRevealable() {
return isRevealable;
@@ -863,5 +894,13 @@ public class ThreadDatabase extends Database {
public boolean isAlbum() {
return isAlbum;
}
public boolean isMessageRequestAccepted() {
return isMessageRequestAccepted;
}
public @Nullable String getGroupAddedBy() {
return groupAddedBy;
}
}
}

View File

@@ -21,6 +21,7 @@ import net.sqlcipher.database.SQLiteDatabase;
import net.sqlcipher.database.SQLiteDatabaseHook;
import net.sqlcipher.database.SQLiteOpenHelper;
import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper;
import org.thoughtcrime.securesms.crypto.DatabaseSecret;
import org.thoughtcrime.securesms.crypto.MasterSecret;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
@@ -111,8 +112,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int PROFILE_KEY_TO_DB = 47;
private static final int PROFILE_KEY_CREDENTIALS = 48;
private static final int ATTACHMENT_FILE_INDEX = 49;
private static final int STORAGE_SERVICE_ACTIVE = 50;
private static final int DATABASE_VERSION = 49;
private static final int DATABASE_VERSION = 50;
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@@ -673,20 +675,6 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL("CREATE UNIQUE INDEX recipient_storage_service_key ON recipient (storage_service_key)");
db.execSQL("CREATE INDEX recipient_dirty_index ON recipient (dirty)");
// TODO [greyson] Do this in a future DB migration
// db.execSQL("UPDATE recipient SET dirty = 2 WHERE registered = 1");
//
// try (Cursor cursor = db.rawQuery("SELECT _id FROM recipient WHERE registered = 1", null)) {
// while (cursor != null && cursor.moveToNext()) {
// String id = cursor.getString(cursor.getColumnIndexOrThrow("_id"));
// ContentValues values = new ContentValues(1);
//
// values.put("storage_service_key", Base64.encodeBytes(StorageSyncHelper.generateKey()));
//
// db.update("recipient", values, "_id = ?", new String[] { id });
// }
// }
}
if (oldVersion < REACTIONS_UNREAD_INDEX) {
@@ -753,6 +741,26 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL("CREATE INDEX IF NOT EXISTS part_data_index ON part (_data)");
}
if (oldVersion < STORAGE_SERVICE_ACTIVE) {
db.execSQL("ALTER TABLE recipient ADD COLUMN group_type INTEGER DEFAULT 0");
db.execSQL("CREATE INDEX IF NOT EXISTS recipient_group_type_index ON recipient (group_type)");
db.execSQL("UPDATE recipient set group_type = 1 WHERE group_id NOT NULL AND group_id LIKE '__signal_mms_group__%'");
db.execSQL("UPDATE recipient set group_type = 2 WHERE group_id NOT NULL AND group_id LIKE '__textsecure_group__%'");
try (Cursor cursor = db.rawQuery("SELECT _id FROM recipient WHERE registered = 1 or group_type = 2", null)) {
while (cursor != null && cursor.moveToNext()) {
String id = cursor.getString(cursor.getColumnIndexOrThrow("_id"));
ContentValues values = new ContentValues(1);
values.put("dirty", 2);
values.put("storage_service_key", Base64.encodeBytes(StorageSyncHelper.generateKey()));
db.update("recipient", values, "_id = ?", new String[] { id });
}
}
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();

View File

@@ -4,6 +4,7 @@ import android.content.Context;
import android.database.Cursor;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.AbstractCursorLoader;
import org.whispersystems.libsignal.util.Pair;
@@ -13,6 +14,8 @@ public class ConversationLoader extends AbstractCursorLoader {
private int limit;
private long lastSeen;
private boolean hasSent;
private boolean isMessageRequestAccepted;
private boolean hasPreMessageRequestMessages;
public ConversationLoader(Context context, long threadId, int offset, int limit, long lastSeen) {
super(context);
@@ -43,6 +46,14 @@ public class ConversationLoader extends AbstractCursorLoader {
return hasSent;
}
public boolean isMessageRequestAccepted() {
return isMessageRequestAccepted;
}
public boolean hasPreMessageRequestMessages() {
return hasPreMessageRequestMessages;
}
@Override
public Cursor getCursor() {
Pair<Long, Boolean> lastSeenAndHasSent = DatabaseFactory.getThreadDatabase(context).getLastSeenAndHasSent(threadId);
@@ -53,6 +64,9 @@ public class ConversationLoader extends AbstractCursorLoader {
this.lastSeen = lastSeenAndHasSent.first();
}
this.isMessageRequestAccepted = RecipientUtil.isMessageRequestAccepted(context, threadId);
this.hasPreMessageRequestMessages = RecipientUtil.isPreMessageRequestThread(context, threadId);
return DatabaseFactory.getMmsSmsDatabase(context).getConversation(threadId, offset, limit);
}
}

View File

@@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase.Extra;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
@@ -77,7 +78,11 @@ public class ThreadRecord extends DisplayRecord {
@Override
public SpannableString getDisplayBody(@NonNull Context context) {
if (isGroupUpdate()) {
if (getGroupAddedBy() != null) {
return emphasisAdded(context.getString(R.string.ThreadRecord_s_added_you_to_the_group, Recipient.live(getGroupAddedBy()).get().getDisplayName(context)));
} else if (!isMessageRequestAccepted()) {
return emphasisAdded(context.getString(R.string.ThreadRecord_message_request));
} else if (isGroupUpdate()) {
return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated));
} else if (isGroupQuit()) {
return emphasisAdded(context.getString(R.string.ThreadRecord_left_the_group));
@@ -181,4 +186,14 @@ public class ThreadRecord extends DisplayRecord {
public long getLastSeen() {
return lastSeen;
}
public @Nullable RecipientId getGroupAddedBy() {
if (extra != null && extra.getGroupAddedBy() != null) return RecipientId.from(extra.getGroupAddedBy());
else return null;
}
public boolean isMessageRequestAccepted() {
if (extra != null) return extra.isMessageRequestAccepted();
else return true;
}
}

View File

@@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.sms.IncomingGroupMessage;
import org.thoughtcrime.securesms.sms.IncomingTextMessage;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
@@ -101,6 +102,13 @@ public class GroupMessageProcessor {
database.create(id, group.getName().orNull(), members,
avatar != null && avatar.isPointer() ? avatar.asPointer() : null, null);
Recipient sender = Recipient.externalPush(context, content.getSender());
if (FeatureFlags.messageRequests() && (sender.isSystemContact() || sender.isProfileSharing())) {
Log.i(TAG, "Auto-enabling profile sharing because 'adder' is trusted. contact: " + sender.isSystemContact() + ", profileSharing: " + sender.isProfileSharing());
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(Recipient.external(context, id).getId(), true);
}
return storeMessage(context, content, group, builder.build(), outgoing);
}
@@ -283,11 +291,11 @@ public class GroupMessageProcessor {
GroupContext.Member.Builder member = GroupContext.Member.newBuilder();
if (address.getUuid().isPresent()) {
member = member.setUuid(address.getUuid().get().toString());
member.setUuid(address.getUuid().get().toString());
}
if (address.getNumber().isPresent()) {
member = member.setE164(address.getNumber().get());
member.setE164(address.getNumber().get());
}
return member.build();

View File

@@ -208,6 +208,10 @@ public class Data {
}
}
public Builder buildUpon() {
return new Builder(this);
}
public static class Builder {
@@ -224,6 +228,23 @@ public class Data {
private final Map<String, Boolean> booleans = new HashMap<>();
private final Map<String, boolean[]> booleanArrays = new HashMap<>();
public Builder() { }
private Builder(@NonNull Data oldData) {
strings.putAll(oldData.strings);
stringArrays.putAll(oldData.stringArrays);
integers.putAll(oldData.integers);
integerArrays.putAll(oldData.integerArrays);
longs.putAll(oldData.longs);
longArrays.putAll(oldData.longArrays);
floats.putAll(oldData.floats);
floatArrays.putAll(oldData.floatArrays);
doubles.putAll(oldData.doubles);
doubleArrays.putAll(oldData.doubleArrays);
booleans.putAll(oldData.booleans);
booleanArrays.putAll(oldData.booleanArrays);
}
public Builder putString(@NonNull String key, @Nullable String value) {
strings.put(key, value);
return this;

View File

@@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.ArrayList;
import java.util.Collection;
@@ -23,9 +24,12 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* Allows the scheduling of durable jobs that will be run as early as possible.
@@ -34,7 +38,7 @@ public class JobManager implements ConstraintObserver.Notifier {
private static final String TAG = JobManager.class.getSimpleName();
public static final int CURRENT_VERSION = 4;
public static final int CURRENT_VERSION = 5;
private final Application application;
private final Configuration configuration;
@@ -159,6 +163,43 @@ public class JobManager implements ConstraintObserver.Notifier {
executor.execute(() -> jobController.cancelJob(id));
}
/**
* Runs the specified job synchronously. Beware: All normal dependencies are respected, meaning
* you must take great care where you call this. It could take a very long time to complete!
*
* @return If the job completed, this will contain its completion state. If it timed out or
* otherwise didn't complete, this will be absent.
*/
@WorkerThread
public Optional<JobTracker.JobState> runSynchronously(@NonNull Job job, long timeout) {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<JobTracker.JobState> resultState = new AtomicReference<>();
addListener(job.getId(), new JobTracker.JobListener() {
@Override
public void onStateChanged(@NonNull JobTracker.JobState jobState) {
if (jobState.isComplete()) {
removeListener(this);
resultState.set(jobState);
latch.countDown();
}
}
});
add(job);
try {
if (!latch.await(timeout, TimeUnit.MILLISECONDS)) {
return Optional.absent();
}
} catch (InterruptedException e) {
Log.w(TAG, "Interrupted during runSynchronously()", e);
return Optional.absent();
}
return Optional.fromNullable(resultState.get());
}
/**
* Retrieves a string representing the state of the job queue. Intended for debugging.
*/

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.jobmanager.migrations;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.JobMigration;
import java.util.SortedSet;
import java.util.TreeSet;
public class SendReadReceiptsJobMigration extends JobMigration {
private final MmsSmsDatabase mmsSmsDatabase;
public SendReadReceiptsJobMigration(@NonNull MmsSmsDatabase mmsSmsDatabase) {
super(5);
this.mmsSmsDatabase = mmsSmsDatabase;
}
@Override
protected @NonNull JobData migrate(@NonNull JobData jobData) {
if ("SendReadReceiptJob".equals(jobData.getFactoryKey())) {
return migrateSendReadReceiptJob(mmsSmsDatabase, jobData);
}
return jobData;
}
private static @NonNull JobData migrateSendReadReceiptJob(@NonNull MmsSmsDatabase mmsSmsDatabase, @NonNull JobData jobData) {
Data data = jobData.getData();
if (!data.hasLong("thread")) {
long[] messageIds = jobData.getData().getLongArray("message_ids");
SortedSet<Long> threadIds = new TreeSet<>();
for (long id : messageIds) {
long threadForMessageId = mmsSmsDatabase.getThreadForMessageId(id);
if (id != -1) {
threadIds.add(threadForMessageId);
}
}
if (threadIds.size() != 1) {
return new JobData("FailingJob", null, new Data.Builder().build());
} else {
return jobData.withData(data.buildUpon().putLong("thread", threadIds.first()).build());
}
} else {
return jobData;
}
}
}

View File

@@ -4,6 +4,7 @@ import android.app.Application;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.jobmanager.Constraint;
import org.thoughtcrime.securesms.jobmanager.ConstraintObserver;
import org.thoughtcrime.securesms.jobmanager.Job;
@@ -18,6 +19,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintOb
import org.thoughtcrime.securesms.jobmanager.migrations.RecipientIdFollowUpJobMigration;
import org.thoughtcrime.securesms.jobmanager.migrations.RecipientIdFollowUpJobMigration2;
import org.thoughtcrime.securesms.jobmanager.migrations.RecipientIdJobMigration;
import org.thoughtcrime.securesms.jobmanager.migrations.SendReadReceiptsJobMigration;
import org.thoughtcrime.securesms.migrations.Argon2TestMigrationJob;
import org.thoughtcrime.securesms.migrations.AvatarMigrationJob;
import org.thoughtcrime.securesms.migrations.CachedAttachmentsMigrationJob;
@@ -28,6 +30,8 @@ import org.thoughtcrime.securesms.migrations.RecipientSearchMigrationJob;
import org.thoughtcrime.securesms.migrations.RegistrationPinV2MigrationJob;
import org.thoughtcrime.securesms.migrations.StickerAdditionMigrationJob;
import org.thoughtcrime.securesms.migrations.StickerLaunchMigrationJob;
import org.thoughtcrime.securesms.migrations.StorageKeyRotationMigrationJob;
import org.thoughtcrime.securesms.migrations.StorageServiceMigrationJob;
import org.thoughtcrime.securesms.migrations.UuidMigrationJob;
import java.util.Arrays;
@@ -49,6 +53,7 @@ public final class JobManagerFactories {
put(CreateSignedPreKeyJob.KEY, new CreateSignedPreKeyJob.Factory());
put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory());
put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory());
put(LeaveGroupJob.KEY, new LeaveGroupJob.Factory());
put(LocalBackupJob.KEY, new LocalBackupJob.Factory());
put(MmsDownloadJob.KEY, new MmsDownloadJob.Factory());
put(MmsReceiveJob.KEY, new MmsReceiveJob.Factory());
@@ -58,6 +63,7 @@ public final class JobManagerFactories {
put(MultiDeviceContactUpdateJob.KEY, new MultiDeviceContactUpdateJob.Factory());
put(MultiDeviceGroupUpdateJob.KEY, new MultiDeviceGroupUpdateJob.Factory());
put(MultiDeviceKeysUpdateJob.KEY, new MultiDeviceKeysUpdateJob.Factory());
put(MultiDeviceMessageRequestResponseJob.KEY, new MultiDeviceMessageRequestResponseJob.Factory());
put(MultiDeviceProfileContentUpdateJob.KEY, new MultiDeviceProfileContentUpdateJob.Factory());
put(MultiDeviceProfileKeyUpdateJob.KEY, new MultiDeviceProfileKeyUpdateJob.Factory());
put(MultiDeviceReadUpdateJob.KEY, new MultiDeviceReadUpdateJob.Factory());
@@ -112,6 +118,8 @@ public final class JobManagerFactories {
put(RegistrationPinV2MigrationJob.KEY, new RegistrationPinV2MigrationJob.Factory());
put(StickerLaunchMigrationJob.KEY, new StickerLaunchMigrationJob.Factory());
put(StickerAdditionMigrationJob.KEY, new StickerAdditionMigrationJob.Factory());
put(StorageKeyRotationMigrationJob.KEY, new StorageKeyRotationMigrationJob.Factory());
put(StorageServiceMigrationJob.KEY, new StorageServiceMigrationJob.Factory());
put(UuidMigrationJob.KEY, new UuidMigrationJob.Factory());
// Dead jobs
@@ -140,6 +148,7 @@ public final class JobManagerFactories {
public static List<JobMigration> getJobMigrations(@NonNull Application application) {
return Arrays.asList(new RecipientIdJobMigration(application),
new RecipientIdFollowUpJobMigration(),
new RecipientIdFollowUpJobMigration2());
new RecipientIdFollowUpJobMigration2(),
new SendReadReceiptsJobMigration(DatabaseFactory.getMmsSmsDatabase(application)));
}
}

View File

@@ -0,0 +1,179 @@
package org.thoughtcrime.securesms.jobs;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* Normally, we can do group leaves via {@link PushGroupSendJob}. However, that job relies on a
* message being present in the database, which is not true if the user selects a message request
* option that deletes and leaves at the same time.
*
* This job tracks all send state within the job and does not require a message in the database to
* work.
*/
public class LeaveGroupJob extends BaseJob {
public static final String KEY = "LeaveGroupJob";
private static final String TAG = Log.tag(LeaveGroupJob.class);
private static final String KEY_GROUP_ID = "group_id";
private static final String KEY_GROUP_NAME = "name";
private static final String KEY_MEMBERS = "members";
private static final String KEY_RECIPIENTS = "recipients";
private final byte[] groupId;
private final String name;
private final List<RecipientId> members;
private final List<RecipientId> recipients;
public static @NonNull LeaveGroupJob create(@NonNull Recipient group) {
List<RecipientId> members = Stream.of(group.resolve().getParticipants()).map(Recipient::getId).toList();
members.remove(Recipient.self().getId());
return new LeaveGroupJob(GroupUtil.getDecodedIdOrThrow(group.getGroupId().get()),
group.resolve().getDisplayName(ApplicationDependencies.getApplication()),
members,
members,
new Parameters.Builder()
.setQueue(group.getId().toQueueKey())
.addConstraint(NetworkConstraint.KEY)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build());
}
private LeaveGroupJob(@NonNull byte[] groupId,
@NonNull String name,
@NonNull List<RecipientId> members,
@NonNull List<RecipientId> recipients,
@NonNull Parameters parameters)
{
super(parameters);
this.groupId = groupId;
this.name = name;
this.members = Collections.unmodifiableList(members);
this.recipients = recipients;
}
@Override
public @NonNull Data serialize() {
return new Data.Builder().putString(KEY_GROUP_ID, Base64.encodeBytes(groupId))
.putString(KEY_GROUP_NAME, name)
.putString(KEY_MEMBERS, RecipientId.toSerializedList(members))
.putString(KEY_RECIPIENTS, RecipientId.toSerializedList(recipients))
.build();
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
protected void onRun() throws Exception {
List<Recipient> completions = deliver(context, groupId, name, members, recipients);
for (Recipient completion : completions) {
recipients.remove(completion.getId());
}
Log.i(TAG, "Completed now: " + completions.size() + ", Remaining: " + recipients.size());
if (!recipients.isEmpty()) {
Log.w(TAG, "Still need to send to " + recipients.size() + " recipients. Retrying.");
throw new RetryLaterException();
}
}
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return e instanceof IOException || e instanceof RetryLaterException;
}
@Override
public void onFailure() {
}
private static @NonNull List<Recipient> deliver(@NonNull Context context,
@NonNull byte[] groupId,
@NonNull String name,
@NonNull List<RecipientId> members,
@NonNull List<RecipientId> destinations)
throws IOException, UntrustedIdentityException
{
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
List<SignalServiceAddress> addresses = Stream.of(destinations).map(Recipient::resolved).map(t -> RecipientUtil.toSignalServiceAddress(context, t)).toList();
List<SignalServiceAddress> memberAddresses = Stream.of(members).map(Recipient::resolved).map(t -> RecipientUtil.toSignalServiceAddress(context, t)).toList();
List<Optional<UnidentifiedAccessPair>> unidentifiedAccess = Stream.of(destinations).map(Recipient::resolved).map(recipient -> UnidentifiedAccessUtil.getAccessFor(context, recipient)).toList();
SignalServiceGroup serviceGroup = new SignalServiceGroup(SignalServiceGroup.Type.QUIT, groupId, name, memberAddresses, null);
SignalServiceDataMessage.Builder dataMessage = SignalServiceDataMessage.newBuilder()
.withTimestamp(System.currentTimeMillis())
.asGroupMessage(serviceGroup);
List<SendMessageResult> results = messageSender.sendMessage(addresses, unidentifiedAccess, false, dataMessage.build());
Stream.of(results)
.filter(r -> r.getIdentityFailure() != null)
.map(SendMessageResult::getAddress)
.map(a -> Recipient.externalPush(context, a))
.forEach(r -> Log.w(TAG, "Identity failure for " + r.getId()));
Stream.of(results)
.filter(SendMessageResult::isUnregisteredFailure)
.map(SendMessageResult::getAddress)
.map(a -> Recipient.externalPush(context, a))
.forEach(r -> Log.w(TAG, "Unregistered failure for " + r.getId()));
return Stream.of(results)
.filter(r -> r.getSuccess() != null || r.getIdentityFailure() != null || r.isUnregisteredFailure())
.map(SendMessageResult::getAddress)
.map(a -> Recipient.externalPush(context, a))
.toList();
}
public static class Factory implements Job.Factory<LeaveGroupJob> {
@Override
public @NonNull LeaveGroupJob create(@NonNull Parameters parameters, @NonNull Data data) {
return new LeaveGroupJob(Base64.decodeOrThrow(data.getString(KEY_GROUP_ID)),
data.getString(KEY_GROUP_NAME),
RecipientId.fromSerializedList(data.getString(KEY_MEMBERS)),
RecipientId.fromSerializedList(data.getString(KEY_RECIPIENTS)),
parameters);
}
}
}

View File

@@ -15,6 +15,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.api.storage.StorageKey;
import org.whispersystems.signalservice.api.messages.multidevice.KeysMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
@@ -58,15 +59,8 @@ public class MultiDeviceKeysUpdateJob extends BaseJob {
return;
}
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
MasterKey masterKey = SignalStore.kbsValues().getPinBackedMasterKey();
byte[] storageServiceKey = masterKey != null ? masterKey.deriveStorageServiceKey()
: null;
if (storageServiceKey == null) {
Log.w(TAG, "Syncing a null storage service key.");
}
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
StorageKey storageServiceKey = SignalStore.storageServiceValues().getOrCreateStorageMasterKey().deriveStorageServiceKey();
messageSender.sendMessage(SignalServiceSyncMessage.forKeys(new KeysMessage(Optional.fromNullable(storageServiceKey))),
UnidentifiedAccessUtil.getAccessForSync(context));

View File

@@ -0,0 +1,164 @@
package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.api.messages.multidevice.KeysMessage;
import org.whispersystems.signalservice.api.messages.multidevice.MessageRequestResponseMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
public class MultiDeviceMessageRequestResponseJob extends BaseJob {
public static final String KEY = "MultiDeviceMessageRequestResponseJob";
private static final String TAG = MultiDeviceMessageRequestResponseJob.class.getSimpleName();
private static final String KEY_THREAD_RECIPIENT = "thread_recipient";
private static final String KEY_TYPE = "type";
private final RecipientId threadRecipient;
private final Type type;
public static @NonNull MultiDeviceMessageRequestResponseJob forAccept(@NonNull RecipientId threadRecipient) {
return new MultiDeviceMessageRequestResponseJob(threadRecipient, Type.ACCEPT);
}
public static @NonNull MultiDeviceMessageRequestResponseJob forDelete(@NonNull RecipientId threadRecipient) {
return new MultiDeviceMessageRequestResponseJob(threadRecipient, Type.DELETE);
}
public static @NonNull MultiDeviceMessageRequestResponseJob forBlock(@NonNull RecipientId threadRecipient) {
return new MultiDeviceMessageRequestResponseJob(threadRecipient, Type.BLOCK);
}
public static @NonNull MultiDeviceMessageRequestResponseJob forBlockAndDelete(@NonNull RecipientId threadRecipient) {
return new MultiDeviceMessageRequestResponseJob(threadRecipient, Type.BLOCK_AND_DELETE);
}
private MultiDeviceMessageRequestResponseJob(@NonNull RecipientId threadRecipient, @NonNull Type type) {
this(new Parameters.Builder()
.setQueue("MultiDeviceMessageRequestResponseJob")
.addConstraint(NetworkConstraint.KEY)
.setMaxAttempts(Parameters.UNLIMITED)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.build(), threadRecipient, type);
}
private MultiDeviceMessageRequestResponseJob(@NonNull Parameters parameters,
@NonNull RecipientId threadRecipient,
@NonNull Type type)
{
super(parameters);
this.threadRecipient = threadRecipient;
this.type = type;
}
@Override
public @NonNull Data serialize() {
return new Data.Builder().putString(KEY_THREAD_RECIPIENT, threadRecipient.serialize())
.putInt(KEY_TYPE, type.serialize())
.build();
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public void onRun() throws IOException, UntrustedIdentityException {
if (!TextSecurePreferences.isMultiDevice(context)) {
Log.i(TAG, "Not multi device, aborting...");
return;
}
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
Recipient recipient = Recipient.resolved(threadRecipient);
MessageRequestResponseMessage response;
if (recipient.isGroup()) {
response = MessageRequestResponseMessage.forGroup(GroupUtil.getDecodedId(recipient.getGroupId().get()), localToRemoteType(type));
} else {
response = MessageRequestResponseMessage.forIndividual(RecipientUtil.toSignalServiceAddress(context, recipient), localToRemoteType(type));
}
messageSender.sendMessage(SignalServiceSyncMessage.forMessageRequestResponse(response),
UnidentifiedAccessUtil.getAccessForSync(context));
}
private static MessageRequestResponseMessage.Type localToRemoteType(@NonNull Type type) {
switch (type) {
case ACCEPT: return MessageRequestResponseMessage.Type.ACCEPT;
case DELETE: return MessageRequestResponseMessage.Type.DELETE;
case BLOCK: return MessageRequestResponseMessage.Type.BLOCK;
case BLOCK_AND_DELETE: return MessageRequestResponseMessage.Type.BLOCK_AND_DELETE;
default: return MessageRequestResponseMessage.Type.UNKNOWN;
}
}
@Override
public boolean onShouldRetry(@NonNull Exception e) {
return e instanceof PushNetworkException;
}
@Override
public void onFailure() {
}
private enum Type {
UNKNOWN(0), ACCEPT(1), DELETE(2), BLOCK(3), BLOCK_AND_DELETE(4);
private final int value;
Type(int value) {
this.value = value;
}
int serialize() {
return value;
}
static @NonNull Type deserialize(int value) {
for (Type type : Type.values()) {
if (type.value == value) {
return type;
}
}
throw new AssertionError("Unknown type: " + value);
}
}
public static final class Factory implements Job.Factory<MultiDeviceMessageRequestResponseJob> {
@Override
public @NonNull
MultiDeviceMessageRequestResponseJob create(@NonNull Parameters parameters, @NonNull Data data) {
RecipientId threadRecipient = RecipientId.from(data.getString(KEY_THREAD_RECIPIENT));
Type type = Type.deserialize(data.getInt(KEY_TYPE));
return new MultiDeviceMessageRequestResponseJob(parameters, threadRecipient, type);
}
}
}

View File

@@ -28,8 +28,10 @@ import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
@@ -151,13 +153,19 @@ public class PushGroupSendJob extends PushSendJob {
try {
log(TAG, "Sending message: " + messageId);
if (!message.getRecipient().resolve().isProfileSharing() && !database.isGroupQuitMessage(messageId)) {
RecipientUtil.shareProfileIfFirstSecureMessage(context, message.getRecipient());
}
List<RecipientId> target;
Recipient groupRecipient = message.getRecipient().fresh();
if (filterRecipient != null) target = Collections.singletonList(Recipient.resolved(filterRecipient).getId());
else if (!existingNetworkFailures.isEmpty()) target = Stream.of(existingNetworkFailures).map(nf -> nf.getRecipientId(context)).toList();
else target = getGroupMessageRecipients(message.getRecipient().requireGroupId(), messageId);
else target = getGroupMessageRecipients(groupRecipient.requireGroupId(), messageId);
List<SendMessageResult> results = deliver(message, target);
List<SendMessageResult> results = deliver(message, groupRecipient, target);
List<NetworkFailure> networkFailures = Stream.of(results).filter(SendMessageResult::isNetworkFailure).map(result -> new NetworkFailure(Recipient.externalPush(context, result.getAddress()).getId())).toList();
List<IdentityKeyMismatch> identityMismatches = Stream.of(results).filter(result -> result.getIdentityFailure() != null).map(result -> new IdentityKeyMismatch(Recipient.externalPush(context, result.getAddress()).getId(), result.getIdentityFailure().getIdentityKey())).toList();
Set<RecipientId> successIds = Stream.of(results).filter(result -> result.getSuccess() != null).map(SendMessageResult::getAddress).map(a -> Recipient.externalPush(context, a).getId()).collect(Collectors.toSet());
@@ -229,13 +237,13 @@ public class PushGroupSendJob extends PushSendJob {
DatabaseFactory.getMmsDatabase(context).markAsSentFailed(messageId);
}
private List<SendMessageResult> deliver(OutgoingMediaMessage message, @NonNull List<RecipientId> destinations)
private List<SendMessageResult> deliver(OutgoingMediaMessage message, @NonNull Recipient groupRecipient, @NonNull List<RecipientId> destinations)
throws IOException, UntrustedIdentityException, UndeliverableMessageException {
rotateSenderCertificateIfNecessary();
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
String groupId = message.getRecipient().requireGroupId();
Optional<byte[]> profileKey = getProfileKey(message.getRecipient());
String groupId = groupRecipient.requireGroupId();
Optional<byte[]> profileKey = getProfileKey(groupRecipient);
Optional<Quote> quote = getQuoteFor(message);
Optional<SignalServiceDataMessage.Sticker> sticker = getStickerFor(message);
List<SharedContact> sharedContacts = getSharedContactsFor(message);
@@ -261,7 +269,7 @@ public class PushGroupSendJob extends PushSendJob {
SignalServiceGroup group = new SignalServiceGroup(type, GroupUtil.getDecodedId(groupId), groupContext.getName(), members, avatar);
SignalServiceDataMessage groupDataMessage = SignalServiceDataMessage.newBuilder()
.withTimestamp(message.getSentTimeMillis())
.withExpiration(message.getRecipient().getExpireMessages())
.withExpiration(groupRecipient.getExpireMessages())
.asGroupMessage(group)
.build();

View File

@@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.transport.InsecureFallbackApprovalException;
import org.thoughtcrime.securesms.transport.RetryLaterException;
@@ -117,7 +118,9 @@ public class PushMediaSendJob extends PushSendJob {
try {
log(TAG, "Sending message: " + messageId);
Recipient recipient = message.getRecipient().resolve();
RecipientUtil.shareProfileIfFirstSecureMessage(context, message.getRecipient());
Recipient recipient = message.getRecipient().fresh();
byte[] profileKey = recipient.getProfileKey();
UnidentifiedAccessMode accessMode = recipient.getUnidentifiedAccessMode();
@@ -190,11 +193,12 @@ public class PushMediaSendJob extends PushSendJob {
try {
rotateSenderCertificateIfNecessary();
Recipient messageRecipient = message.getRecipient().fresh();
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
SignalServiceAddress address = getPushAddress(message.getRecipient());
SignalServiceAddress address = getPushAddress(messageRecipient);
List<Attachment> attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList();
List<SignalServiceAttachment> serviceAttachments = getAttachmentPointersFor(attachments);
Optional<byte[]> profileKey = getProfileKey(message.getRecipient());
Optional<byte[]> profileKey = getProfileKey(messageRecipient);
Optional<SignalServiceDataMessage.Quote> quote = getQuoteFor(message);
Optional<SignalServiceDataMessage.Sticker> sticker = getStickerFor(message);
List<SharedContact> sharedContacts = getSharedContactsFor(message);
@@ -220,7 +224,7 @@ public class PushMediaSendJob extends PushSendJob {
messageSender.sendMessage(syncMessage, syncAccess);
return syncAccess.isPresent();
} else {
return messageSender.sendMessage(address, UnidentifiedAccessUtil.getAccessFor(context, message.getRecipient()), mediaMessage).getSuccess().isUnidentified();
return messageSender.sendMessage(address, UnidentifiedAccessUtil.getAccessFor(context, messageRecipient), mediaMessage).getSuccess().isUnidentified();
}
} catch (UnregisteredUserException e) {
warn(TAG, e);

View File

@@ -95,6 +95,7 @@ import org.whispersystems.signalservice.api.messages.calls.OfferMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage;
import org.whispersystems.signalservice.api.messages.multidevice.ConfigurationMessage;
import org.whispersystems.signalservice.api.messages.multidevice.MessageRequestResponseMessage;
import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage;
import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage;
@@ -286,15 +287,16 @@ public final class PushProcessMessageJob extends BaseJob {
SignalServiceSyncMessage syncMessage = content.getSyncMessage().get();
if (syncMessage.getSent().isPresent()) handleSynchronizeSentMessage(content, syncMessage.getSent().get());
else if (syncMessage.getRequest().isPresent()) handleSynchronizeRequestMessage(syncMessage.getRequest().get());
else if (syncMessage.getRead().isPresent()) handleSynchronizeReadMessage(syncMessage.getRead().get(), content.getTimestamp());
else if (syncMessage.getViewOnceOpen().isPresent()) handleSynchronizeViewOnceOpenMessage(syncMessage.getViewOnceOpen().get(), content.getTimestamp());
else if (syncMessage.getVerified().isPresent()) handleSynchronizeVerifiedMessage(syncMessage.getVerified().get());
else if (syncMessage.getStickerPackOperations().isPresent()) handleSynchronizeStickerPackOperation(syncMessage.getStickerPackOperations().get());
else if (syncMessage.getConfiguration().isPresent()) handleSynchronizeConfigurationMessage(syncMessage.getConfiguration().get());
else if (syncMessage.getBlockedList().isPresent()) handleSynchronizeBlockedListMessage(syncMessage.getBlockedList().get());
else if (syncMessage.getFetchType().isPresent()) handleSynchronizeFetchMessage(syncMessage.getFetchType().get());
if (syncMessage.getSent().isPresent()) handleSynchronizeSentMessage(content, syncMessage.getSent().get());
else if (syncMessage.getRequest().isPresent()) handleSynchronizeRequestMessage(syncMessage.getRequest().get());
else if (syncMessage.getRead().isPresent()) handleSynchronizeReadMessage(syncMessage.getRead().get(), content.getTimestamp());
else if (syncMessage.getViewOnceOpen().isPresent()) handleSynchronizeViewOnceOpenMessage(syncMessage.getViewOnceOpen().get(), content.getTimestamp());
else if (syncMessage.getVerified().isPresent()) handleSynchronizeVerifiedMessage(syncMessage.getVerified().get());
else if (syncMessage.getStickerPackOperations().isPresent()) handleSynchronizeStickerPackOperation(syncMessage.getStickerPackOperations().get());
else if (syncMessage.getConfiguration().isPresent()) handleSynchronizeConfigurationMessage(syncMessage.getConfiguration().get());
else if (syncMessage.getBlockedList().isPresent()) handleSynchronizeBlockedListMessage(syncMessage.getBlockedList().get());
else if (syncMessage.getFetchType().isPresent()) handleSynchronizeFetchMessage(syncMessage.getFetchType().get());
else if (syncMessage.getMessageRequestResponse().isPresent()) handleSynchronizeMessageRequestResponse(syncMessage.getMessageRequestResponse().get());
else Log.w(TAG, "Contains no known sync types...");
} else if (content.getCallMessage().isPresent()) {
Log.i(TAG, "Got call message...");
@@ -652,10 +654,59 @@ public final class PushProcessMessageJob extends BaseJob {
}
private static void handleSynchronizeFetchMessage(@NonNull SignalServiceSyncMessage.FetchType fetchType) {
if (fetchType == SignalServiceSyncMessage.FetchType.LOCAL_PROFILE) {
ApplicationDependencies.getJobManager().add(new RefreshOwnProfileJob());
Log.i(TAG, "Received fetch request with type: " + fetchType);
switch (fetchType) {
case LOCAL_PROFILE:
ApplicationDependencies.getJobManager().add(new RefreshOwnProfileJob());
break;
case STORAGE_MANIFEST:
ApplicationDependencies.getJobManager().add(new StorageSyncJob());
break;
default:
Log.w(TAG, "Received a fetch message for an unknown type.");
}
}
private void handleSynchronizeMessageRequestResponse(@NonNull MessageRequestResponseMessage response) {
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
Recipient recipient;
if (response.getPerson().isPresent()) {
recipient = Recipient.externalPush(context, response.getPerson().get());
} else if (response.getGroupId().isPresent()) {
String groupId = GroupUtil.getEncodedId(response.getGroupId().get(), false);
recipient = Recipient.externalGroup(context, groupId);
} else {
Log.w(TAG, "Received a fetch message for an unknown type.");
Log.w(TAG, "Message request response was missing a thread recipient! Skipping.");
return;
}
long threadId = threadDatabase.getThreadIdFor(recipient);
switch (response.getType()) {
case ACCEPT:
recipientDatabase.setProfileSharing(recipient.getId(), true);
recipientDatabase.setBlocked(recipient.getId(), false);
break;
case DELETE:
recipientDatabase.setProfileSharing(recipient.getId(), false);
if (threadId > 0) threadDatabase.deleteConversation(threadId);
break;
case BLOCK:
recipientDatabase.setBlocked(recipient.getId(), true);
recipientDatabase.setProfileSharing(recipient.getId(), false);
break;
case BLOCK_AND_DELETE:
recipientDatabase.setBlocked(recipient.getId(), true);
recipientDatabase.setProfileSharing(recipient.getId(), false);
if (threadId > 0) threadDatabase.deleteConversation(threadId);
break;
default:
Log.w(TAG, "Got an unknown response type! Skipping");
break;
}
}
@@ -733,7 +784,7 @@ public final class PushProcessMessageJob extends BaseJob {
}
if (message.isKeysRequest()) {
// ApplicationDependencies.getJobManager().add(new );
ApplicationDependencies.getJobManager().add(new MultiDeviceKeysUpdateJob());
}
}

View File

@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.transport.InsecureFallbackApprovalException;
import org.thoughtcrime.securesms.transport.RetryLaterException;
@@ -80,7 +81,9 @@ public class PushTextSendJob extends PushSendJob {
try {
log(TAG, "Sending message: " + messageId);
Recipient recipient = record.getRecipient().resolve();
RecipientUtil.shareProfileIfFirstSecureMessage(context, record.getRecipient());
Recipient recipient = record.getRecipient().fresh();
byte[] profileKey = recipient.getProfileKey();
UnidentifiedAccessMode accessMode = recipient.getUnidentifiedAccessMode();
@@ -151,10 +154,11 @@ public class PushTextSendJob extends PushSendJob {
try {
rotateSenderCertificateIfNecessary();
Recipient messageRecipient = message.getIndividualRecipient().fresh();
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
SignalServiceAddress address = getPushAddress(message.getIndividualRecipient());
Optional<byte[]> profileKey = getProfileKey(message.getIndividualRecipient());
Optional<UnidentifiedAccessPair> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, message.getIndividualRecipient());
SignalServiceAddress address = getPushAddress(messageRecipient);
Optional<byte[]> profileKey = getProfileKey(messageRecipient);
Optional<UnidentifiedAccessPair> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, messageRecipient);
log(TAG, "Have access key to use: " + unidentifiedAccess.isPresent());

View File

@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.libsignal.IdentityKeyPair;
@@ -17,9 +18,11 @@ import org.whispersystems.libsignal.state.SignedPreKeyRecord;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.TimeUnit;
public class RefreshPreKeysJob extends BaseJob {
@@ -29,14 +32,27 @@ public class RefreshPreKeysJob extends BaseJob {
private static final int PREKEY_MINIMUM = 10;
private static final long REFRESH_INTERVAL = TimeUnit.DAYS.toMillis(3);
public RefreshPreKeysJob() {
this(new Job.Parameters.Builder()
.setQueue("RefreshPreKeysJob")
.addConstraint(NetworkConstraint.KEY)
.setMaxAttempts(5)
.setMaxInstances(1)
.setMaxAttempts(Parameters.UNLIMITED)
.setLifespan(TimeUnit.DAYS.toMillis(30))
.build());
}
public static void scheduleIfNecessary() {
long timeSinceLastRefresh = System.currentTimeMillis() - SignalStore.getLastPrekeyRefreshTime();
if (timeSinceLastRefresh > REFRESH_INTERVAL) {
Log.i(TAG, "Scheduling a prekey refresh. Time since last schedule: " + timeSinceLastRefresh + " ms");
ApplicationDependencies.getJobManager().add(new RefreshPreKeysJob());
}
}
private RefreshPreKeysJob(@NonNull Job.Parameters parameters) {
super(parameters);
}
@@ -53,14 +69,20 @@ public class RefreshPreKeysJob extends BaseJob {
@Override
public void onRun() throws IOException {
if (!TextSecurePreferences.isPushRegistered(context)) return;
if (!TextSecurePreferences.isPushRegistered(context)) {
Log.w(TAG, "Not registered. Skipping.");
return;
}
SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager();
int availableKeys = accountManager.getPreKeysCount();
Log.i(TAG, "Available keys: " + availableKeys);
if (availableKeys >= PREKEY_MINIMUM && TextSecurePreferences.isSignedPreKeyRegistered(context)) {
Log.i(TAG, "Available keys sufficient: " + availableKeys);
Log.i(TAG, "Available keys sufficient.");
SignalStore.setLastPrekeyRefreshTime(System.currentTimeMillis());
return;
}
@@ -76,6 +98,8 @@ public class RefreshPreKeysJob extends BaseJob {
TextSecurePreferences.setSignedPreKeyRegistered(context, true);
ApplicationDependencies.getJobManager().add(new CleanPreKeysJob());
SignalStore.setLastPrekeyRefreshTime(System.currentTimeMillis());
Log.i(TAG, "Successfully refreshed prekeys.");
}
@Override

View File

@@ -43,7 +43,6 @@ public class RemoteConfigRefreshJob extends BaseJob {
protected void onRun() throws Exception {
Map<String, Boolean> config = ApplicationDependencies.getSignalServiceAccountManager().getRemoteConfig();
FeatureFlags.update(config);
SignalStore.setRemoteConfigLastFetchTime(System.currentTimeMillis());
}
@Override

View File

@@ -17,6 +17,8 @@ import org.whispersystems.libsignal.state.SignedPreKeyRecord;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import java.util.concurrent.TimeUnit;
public class RotateSignedPreKeyJob extends BaseJob {
public static final String KEY = "RotateSignedPreKeyJob";
@@ -27,7 +29,9 @@ public class RotateSignedPreKeyJob extends BaseJob {
this(new Job.Parameters.Builder()
.setQueue("RotateSignedPreKeyJob")
.addConstraint(NetworkConstraint.KEY)
.setMaxAttempts(5)
.setMaxInstances(1)
.setMaxAttempts(Parameters.UNLIMITED)
.setLifespan(TimeUnit.DAYS.toMillis(2))
.build());
}

View File

@@ -32,33 +32,38 @@ public class SendReadReceiptJob extends BaseJob {
private static final String TAG = SendReadReceiptJob.class.getSimpleName();
private static final String KEY_THREAD = "thread";
private static final String KEY_ADDRESS = "address";
private static final String KEY_RECIPIENT = "recipient";
private static final String KEY_MESSAGE_IDS = "message_ids";
private static final String KEY_TIMESTAMP = "timestamp";
private long threadId;
private RecipientId recipientId;
private List<Long> messageIds;
private long timestamp;
public SendReadReceiptJob(@NonNull RecipientId recipientId, List<Long> messageIds) {
public SendReadReceiptJob(long threadId, @NonNull RecipientId recipientId, List<Long> messageIds) {
this(new Job.Parameters.Builder()
.addConstraint(NetworkConstraint.KEY)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
threadId,
recipientId,
messageIds,
System.currentTimeMillis());
}
private SendReadReceiptJob(@NonNull Job.Parameters parameters,
long threadId,
@NonNull RecipientId recipientId,
@NonNull List<Long> messageIds,
long timestamp)
{
super(parameters);
this.threadId = threadId;
this.recipientId = recipientId;
this.messageIds = messageIds;
this.timestamp = timestamp;
@@ -74,6 +79,7 @@ public class SendReadReceiptJob extends BaseJob {
return new Data.Builder().putString(KEY_RECIPIENT, recipientId.serialize())
.putLongArray(KEY_MESSAGE_IDS, ids)
.putLong(KEY_TIMESTAMP, timestamp)
.putLong(KEY_THREAD, threadId)
.build();
}
@@ -86,12 +92,12 @@ public class SendReadReceiptJob extends BaseJob {
public void onRun() throws IOException, UntrustedIdentityException {
if (!TextSecurePreferences.isReadReceiptsEnabled(context) || messageIds.isEmpty()) return;
Recipient recipient = Recipient.resolved(recipientId);
if (!RecipientUtil.isRecipientMessageRequestAccepted(context, recipient)) {
if (!RecipientUtil.isMessageRequestAccepted(context, threadId)) {
Log.w(TAG, "Refusing to send receipts to untrusted recipient");
return;
}
Recipient recipient = Recipient.resolved(recipientId);
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
SignalServiceAddress remoteAddress = RecipientUtil.toSignalServiceAddress(context, recipient);
SignalServiceReceiptMessage receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.READ, messageIds, timestamp);
@@ -127,12 +133,13 @@ public class SendReadReceiptJob extends BaseJob {
List<Long> messageIds = new ArrayList<>(ids.length);
RecipientId recipientId = data.hasString(KEY_RECIPIENT) ? RecipientId.from(data.getString(KEY_RECIPIENT))
: Recipient.external(application, data.getString(KEY_ADDRESS)).getId();
long threadId = data.getLong(KEY_THREAD);
for (long id : ids) {
messageIds.add(id);
}
return new SendReadReceiptJob(parameters, recipientId, messageIds, timestamp);
return new SendReadReceiptJob(parameters, threadId, recipientId, messageIds, timestamp);
}
}
}

View File

@@ -176,8 +176,7 @@ public class StickerPackDownloadJob extends BaseJob {
public static final class Factory implements Job.Factory<StickerPackDownloadJob> {
@Override
public @NonNull
StickerPackDownloadJob create(@NonNull Parameters parameters, @NonNull Data data) {
public @NonNull StickerPackDownloadJob create(@NonNull Parameters parameters, @NonNull Data data) {
return new StickerPackDownloadJob(parameters,
data.getString(KEY_PACK_ID),
data.getString(KEY_PACK_KEY),

View File

@@ -22,20 +22,24 @@ import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.api.storage.StorageKey;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.storage.SignalStorageManifest;
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* Forces remote storage to match our local state. This should only be done after a key change or
* when we detect that the remote data is badly-encrypted.
* Forces remote storage to match our local state. This should only be done when we detect that the
* remote data is badly-encrypted (which should only happen after re-registering without a PIN).
*/
public class StorageForcePushJob extends BaseJob {
@@ -45,10 +49,10 @@ public class StorageForcePushJob extends BaseJob {
public StorageForcePushJob() {
this(new Parameters.Builder().addConstraint(NetworkConstraint.KEY)
.setQueue(StorageSyncJob.QUEUE_KEY)
.setMaxInstances(1)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.build());
.setQueue(StorageSyncJob.QUEUE_KEY)
.setMaxInstances(1)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.build());
}
private StorageForcePushJob(@NonNull Parameters parameters) {
@@ -67,45 +71,46 @@ public class StorageForcePushJob extends BaseJob {
@Override
protected void onRun() throws IOException, RetryLaterException {
if (!FeatureFlags.storageService()) throw new AssertionError();
MasterKey kbsMasterKey = SignalStore.kbsValues().getPinBackedMasterKey();
if (kbsMasterKey == null) {
Log.w(TAG, "No KBS master key is set! Must abort.");
return;
}
byte[] storageServiceKey = kbsMasterKey.deriveStorageServiceKey();
StorageKey storageServiceKey = SignalStore.storageServiceValues().getOrCreateStorageMasterKey().deriveStorageServiceKey();
SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager();
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context);
long currentVersion = accountManager.getStorageManifestVersion();
Map<RecipientId, byte[]> oldContactKeys = recipientDatabase.getAllStorageSyncKeysMap();
List<byte[]> oldUnknownKeys = storageKeyDatabase.getAllKeys();
Map<RecipientId, byte[]> oldStorageKeys = recipientDatabase.getAllStorageSyncKeysMap();
long newVersion = currentVersion + 1;
Map<RecipientId, byte[]> newContactKeys = generateNewKeys(oldContactKeys);
List<byte[]> keysToDelete = Util.concatenatedList(new ArrayList<>(oldContactKeys.values()), oldUnknownKeys);
List<SignalStorageRecord> inserts = Stream.of(oldContactKeys.keySet())
Map<RecipientId, byte[]> newStorageKeys = generateNewKeys(oldStorageKeys);
List<SignalStorageRecord> inserts = Stream.of(oldStorageKeys.keySet())
.map(recipientDatabase::getRecipientSettings)
.withoutNulls()
.map(StorageSyncHelper::localToRemoteContact)
.map(r -> SignalStorageRecord.forContact(r.getKey(), r))
.map(s -> StorageSyncHelper.localToRemoteRecord(s, Objects.requireNonNull(newStorageKeys.get(s.getId()))))
.toList();
SignalStorageManifest manifest = new SignalStorageManifest(newVersion, new ArrayList<>(newContactKeys.values()));
SignalStorageManifest manifest = new SignalStorageManifest(newVersion, new ArrayList<>(newStorageKeys.values()));
try {
accountManager.writeStorageRecords(storageServiceKey, manifest, inserts, keysToDelete);
if (newVersion > 1) {
Log.i(TAG, String.format(Locale.ENGLISH, "Force-pushing data. Inserting %d keys.", inserts.size()));
if (accountManager.resetStorageRecords(storageServiceKey, manifest, inserts).isPresent()) {
Log.w(TAG, "Hit a conflict. Trying again.");
throw new RetryLaterException();
}
} else {
Log.i(TAG, String.format(Locale.ENGLISH, "First version, normal push. Inserting %d keys.", inserts.size()));
if (accountManager.writeStorageRecords(storageServiceKey, manifest, inserts, Collections.emptyList()).isPresent()) {
Log.w(TAG, "Hit a conflict. Trying again.");
throw new RetryLaterException();
}
}
} catch (InvalidKeyException e) {
Log.w(TAG, "Hit an invalid key exception, which likely indicates a conflict.");
throw new RetryLaterException();
throw new RetryLaterException(e);
}
Log.i(TAG, "Force push succeeded. Updating local manifest version to: " + newVersion);
TextSecurePreferences.setStorageManifestVersion(context, newVersion);
recipientDatabase.applyStorageSyncKeyUpdates(newContactKeys);
recipientDatabase.applyStorageSyncKeyUpdates(newStorageKeys);
storageKeyDatabase.deleteAll();
}
@@ -129,10 +134,8 @@ public class StorageForcePushJob extends BaseJob {
}
public static final class Factory implements Job.Factory<StorageForcePushJob> {
@Override
public @NonNull
StorageForcePushJob create(@NonNull Parameters parameters, @NonNull Data data) {
public @NonNull StorageForcePushJob create(@NonNull Parameters parameters, @NonNull Data data) {
return new StorageForcePushJob(parameters);
}
}

View File

@@ -29,9 +29,8 @@ import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.api.storage.StorageKey;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
import org.whispersystems.signalservice.api.storage.SignalStorageManifest;
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
@@ -39,6 +38,7 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
/**
@@ -55,6 +55,8 @@ public class StorageSyncJob extends BaseJob {
private static final String TAG = Log.tag(StorageSyncJob.class);
private static final long REFRESH_INTERVAL = TimeUnit.HOURS.toMillis(2);
public StorageSyncJob() {
this(new Job.Parameters.Builder().addConstraint(NetworkConstraint.KEY)
.setQueue(QUEUE_KEY)
@@ -63,6 +65,17 @@ public class StorageSyncJob extends BaseJob {
.build());
}
public static void scheduleIfNecessary() {
long timeSinceLastSync = System.currentTimeMillis() - SignalStore.storageServiceValues().getLastSyncTime();
if (timeSinceLastSync > REFRESH_INTERVAL) {
Log.d(TAG, "Scheduling a sync. Last sync was " + timeSinceLastSync + " ms ago.");
ApplicationDependencies.getJobManager().add(new StorageSyncJob());
} else {
Log.d(TAG, "No need for sync. Last sync was " + timeSinceLastSync + " ms ago.");
}
}
private StorageSyncJob(@NonNull Parameters parameters) {
super(parameters);
}
@@ -79,7 +92,10 @@ public class StorageSyncJob extends BaseJob {
@Override
protected void onRun() throws IOException, RetryLaterException {
if (!FeatureFlags.storageService()) throw new AssertionError();
if (!FeatureFlags.storageService()) {
Log.i(TAG, "Not enabled. Skipping.");
return;
}
try {
boolean needsMultiDeviceSync = performSync();
@@ -87,6 +103,8 @@ public class StorageSyncJob extends BaseJob {
if (TextSecurePreferences.isMultiDevice(context) && needsMultiDeviceSync) {
ApplicationDependencies.getJobManager().add(new MultiDeviceStorageSyncRequestJob());
}
SignalStore.storageServiceValues().onSyncCompleted();
} catch (InvalidKeyException e) {
Log.w(TAG, "Failed to decrypt remote storage! Force-pushing and syncing the storage key to linked devices.", e);
@@ -94,6 +112,11 @@ public class StorageSyncJob extends BaseJob {
.then(new StorageForcePushJob())
.then(new MultiDeviceStorageSyncRequestJob())
.enqueue();
} finally {
if (!SignalStore.storageServiceValues().hasFirstStorageSyncCompleted()) {
SignalStore.storageServiceValues().setFirstStorageSyncCompleted(true);
ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(false));
}
}
}
@@ -110,47 +133,62 @@ public class StorageSyncJob extends BaseJob {
SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager();
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context);
MasterKey kbsMasterKey = SignalStore.kbsValues().getPinBackedMasterKey();
StorageKey storageServiceKey = SignalStore.storageServiceValues().getOrCreateStorageMasterKey().deriveStorageServiceKey();
if (kbsMasterKey == null) {
Log.w(TAG, "No KBS master key is set! Must abort.");
return false;
}
boolean needsMultiDeviceSync = false;
long localManifestVersion = TextSecurePreferences.getStorageManifestVersion(context);
Optional<SignalStorageManifest> remoteManifest = accountManager.getStorageManifestIfDifferentVersion(storageServiceKey, localManifestVersion);
long remoteManifestVersion = remoteManifest.transform(SignalStorageManifest::getVersion).or(localManifestVersion);
byte[] storageServiceKey = kbsMasterKey.deriveStorageServiceKey();
boolean needsMultiDeviceSync = false;
long localManifestVersion = TextSecurePreferences.getStorageManifestVersion(context);
SignalStorageManifest remoteManifest = accountManager.getStorageManifest(storageServiceKey).or(new SignalStorageManifest(0, Collections.emptyList()));
Log.i(TAG, "Our version: " + localManifestVersion + ", their version: " + remoteManifestVersion);
if (remoteManifest.getVersion() > localManifestVersion) {
Log.i(TAG, "Newer manifest version found! Our version: " + localManifestVersion + ", their version: " + remoteManifest.getVersion());
if (remoteManifest.isPresent() && remoteManifestVersion > localManifestVersion) {
Log.i(TAG, "[Remote Newer] Newer manifest version found!");
List<byte[]> allLocalStorageKeys = getAllLocalStorageKeys(context);
KeyDifferenceResult keyDifference = StorageSyncHelper.findKeyDifference(remoteManifest.getStorageKeys(), allLocalStorageKeys);
KeyDifferenceResult keyDifference = StorageSyncHelper.findKeyDifference(remoteManifest.get().getStorageKeys(), allLocalStorageKeys);
if (!keyDifference.isEmpty()) {
Log.i(TAG, "[Remote Newer] There's a difference in keys. Local-only: " + keyDifference.getLocalOnlyKeys().size() + ", Remote-only: " + keyDifference.getRemoteOnlyKeys().size());
List<SignalStorageRecord> localOnly = buildLocalStorageRecords(context, keyDifference.getLocalOnlyKeys());
List<SignalStorageRecord> remoteOnly = accountManager.readStorageRecords(storageServiceKey, keyDifference.getRemoteOnlyKeys());
MergeResult mergeResult = StorageSyncHelper.resolveConflict(remoteOnly, localOnly);
WriteOperationResult writeOperationResult = StorageSyncHelper.createWriteOperation(remoteManifest.getVersion(), allLocalStorageKeys, mergeResult);
WriteOperationResult writeOperationResult = StorageSyncHelper.createWriteOperation(remoteManifest.get().getVersion(), allLocalStorageKeys, mergeResult);
Optional<SignalStorageManifest> conflict = accountManager.writeStorageRecords(storageServiceKey, writeOperationResult.getManifest(), writeOperationResult.getInserts(), writeOperationResult.getDeletes());
Log.i(TAG, "[Remote Newer] MergeResult :: " + mergeResult);
if (conflict.isPresent()) {
Log.w(TAG, "Hit a conflict when trying to resolve the conflict! Retrying.");
throw new RetryLaterException();
if (!writeOperationResult.isEmpty()) {
Log.i(TAG, "[Remote Newer] WriteOperationResult :: " + writeOperationResult);
Log.i(TAG, "[Remote Newer] We have something to write remotely.");
if (writeOperationResult.getManifest().getStorageKeys().size() != remoteManifest.get().getStorageKeys().size() + writeOperationResult.getInserts().size() - writeOperationResult.getDeletes().size()) {
Log.w(TAG, String.format(Locale.ENGLISH, "Bad storage key management! originalRemoteKeys: %d, newRemoteKeys: %d, insertedKeys: %d, deletedKeys: %d",
remoteManifest.get().getStorageKeys().size(), writeOperationResult.getManifest().getStorageKeys().size(), writeOperationResult.getInserts().size(), writeOperationResult.getDeletes().size()));
}
Optional<SignalStorageManifest> conflict = accountManager.writeStorageRecords(storageServiceKey, writeOperationResult.getManifest(), writeOperationResult.getInserts(), writeOperationResult.getDeletes());
if (conflict.isPresent()) {
Log.w(TAG, "[Remote Newer] Hit a conflict when trying to resolve the conflict! Retrying.");
throw new RetryLaterException();
}
remoteManifestVersion = writeOperationResult.getManifest().getVersion();
} else {
Log.i(TAG, "[Remote Newer] After resolving the conflict, all changes are local. No remote writes needed.");
}
recipientDatabase.applyStorageSyncUpdates(mergeResult.getLocalContactInserts(), mergeResult.getLocalContactUpdates());
recipientDatabase.applyStorageSyncUpdates(mergeResult.getLocalContactInserts(), mergeResult.getLocalContactUpdates(), mergeResult.getLocalGroupV1Inserts(), mergeResult.getLocalGroupV1Updates());
storageKeyDatabase.applyStorageSyncUpdates(mergeResult.getLocalUnknownInserts(), mergeResult.getLocalUnknownDeletes());
needsMultiDeviceSync = true;
Log.i(TAG, "[Post-Conflict] Updating local manifest version to: " + writeOperationResult.getManifest().getVersion());
TextSecurePreferences.setStorageManifestVersion(context, writeOperationResult.getManifest().getVersion());
Log.i(TAG, "[Remote Newer] Updating local manifest version to: " + remoteManifestVersion);
TextSecurePreferences.setStorageManifestVersion(context, remoteManifestVersion);
} else {
Log.i(TAG, "Remote version was newer, but our local data matched.");
Log.i(TAG, "[Post-Empty-Conflict] Updating local manifest version to: " + remoteManifest.getVersion());
TextSecurePreferences.setStorageManifestVersion(context, remoteManifest.getVersion());
Log.i(TAG, "[Remote Newer] Remote version was newer, but our local data matched.");
Log.i(TAG, "[Remote Newer] Updating local manifest version to: " + remoteManifest.get().getVersion());
TextSecurePreferences.setStorageManifestVersion(context, remoteManifest.get().getVersion());
}
}
@@ -167,11 +205,20 @@ public class StorageSyncJob extends BaseJob {
pendingDeletions);
if (localWriteResult.isPresent()) {
WriteOperationResult localWrite = localWriteResult.get().getWriteResult();
Log.i(TAG, String.format(Locale.ENGLISH, "[Local Changes] Local changes present. %d updates, %d inserts, %d deletes.", pendingUpdates.size(), pendingInsertions.size(), pendingDeletions.size()));
WriteOperationResult localWrite = localWriteResult.get().getWriteResult();
Log.i(TAG, "[Local Changes] WriteOperationResult :: " + localWrite);
if (localWrite.isEmpty()) {
throw new AssertionError("Decided there were local writes, but our write result was empty!");
}
Optional<SignalStorageManifest> conflict = accountManager.writeStorageRecords(storageServiceKey, localWrite.getManifest(), localWrite.getInserts(), localWrite.getDeletes());
if (conflict.isPresent()) {
Log.w(TAG, "Hit a conflict when trying to upload our local writes! Retrying.");
Log.w(TAG, "[Local Changes] Hit a conflict when trying to upload our local writes! Retrying.");
throw new RetryLaterException();
}
@@ -186,21 +233,21 @@ public class StorageSyncJob extends BaseJob {
needsMultiDeviceSync = true;
Log.i(TAG, "[Post Write] Updating local manifest version to: " + localWriteResult.get().getWriteResult().getManifest().getVersion());
Log.i(TAG, "[Local Changes] Updating local manifest version to: " + localWriteResult.get().getWriteResult().getManifest().getVersion());
TextSecurePreferences.setStorageManifestVersion(context, localWriteResult.get().getWriteResult().getManifest().getVersion());
} else {
Log.i(TAG, "Nothing locally to write.");
Log.i(TAG, "[Local Changes] No local changes.");
}
return needsMultiDeviceSync;
}
public static @NonNull List<byte[]> getAllLocalStorageKeys(@NonNull Context context) {
private static @NonNull List<byte[]> getAllLocalStorageKeys(@NonNull Context context) {
return Util.concatenatedList(DatabaseFactory.getRecipientDatabase(context).getAllStorageSyncKeys(),
DatabaseFactory.getStorageKeyDatabase(context).getAllKeys());
}
public static @NonNull List<SignalStorageRecord> buildLocalStorageRecords(@NonNull Context context, @NonNull List<byte[]> keys) {
private static @NonNull List<SignalStorageRecord> buildLocalStorageRecords(@NonNull Context context, @NonNull List<byte[]> keys) {
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context);
@@ -208,10 +255,7 @@ public class StorageSyncJob extends BaseJob {
for (byte[] key : keys) {
SignalStorageRecord record = Optional.fromNullable(recipientDatabase.getByStorageSyncKey(key))
.transform(recipient -> {
SignalContactRecord contact = StorageSyncHelper.localToRemoteContact(recipient);
return SignalStorageRecord.forContact(key, contact);
})
.transform(StorageSyncHelper::localToRemoteRecord)
.or(() -> storageKeyDatabase.getByKey(key));
records.add(record);
}
@@ -220,7 +264,6 @@ public class StorageSyncJob extends BaseJob {
}
public static final class Factory implements Job.Factory<StorageSyncJob> {
@Override
public @NonNull StorageSyncJob create(@NonNull Parameters parameters, @NonNull Data data) {
return new StorageSyncJob(parameters);

View File

@@ -129,6 +129,6 @@ public final class KbsValues {
}
public boolean hasMigratedToPinsForAll() {
return store.getString(KEYBOARD_TYPE, null) != null;
return store.getString(KEYBOARD_TYPE, null) != null && store.getBoolean(V2_LOCK_ENABLED, false);
}
}

View File

@@ -14,15 +14,16 @@ public final class RegistrationValues {
this.store = store;
}
public synchronized void onNewInstall() {
public synchronized void onFirstEverAppLaunch() {
store.beginWrite()
.putBoolean(REGISTRATION_COMPLETE, false)
.putBoolean(PIN_REQUIRED, true)
// TODO [greyson] [pins] Maybe re-enable in the future
// .putBoolean(PIN_REQUIRED, true)
.commit();
}
public synchronized void clearRegistrationComplete() {
onNewInstall();
onFirstEverAppLaunch();
}
public synchronized void setRegistrationComplete() {

View File

@@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.keyvalue;
import org.thoughtcrime.securesms.logging.Log;
public final class RemoteConfigValues {
private static final String TAG = Log.tag(RemoteConfigValues.class);
private static final String CURRENT_CONFIG = "remote_config";
private static final String PENDING_CONFIG = "pending_remote_config";
private static final String LAST_FETCH_TIME = "remote_config_last_fetch_time";
private final KeyValueStore store;
RemoteConfigValues(KeyValueStore store) {
this.store = store;
}
public String getCurrentConfig() {
return store.getString(CURRENT_CONFIG, null);
}
public void setCurrentConfig(String value) {
store.beginWrite().putString(CURRENT_CONFIG, value).apply();
}
public String getPendingConfig() {
return store.getString(PENDING_CONFIG, getCurrentConfig());
}
public void setPendingConfig(String value) {
store.beginWrite().putString(PENDING_CONFIG, value).apply();
}
public long getLastFetchTime() {
return store.getLong(LAST_FETCH_TIME, 0);
}
public void setLastFetchTime(long time) {
store.beginWrite().putLong(LAST_FETCH_TIME, time).apply();
}
}

View File

@@ -1,22 +1,26 @@
package org.thoughtcrime.securesms.keyvalue;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.logging.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.util.FeatureFlags;
/**
* Simple, encrypted key-value store.
*/
public final class SignalStore {
private static final String REMOTE_CONFIG = "remote_config";
private static final String REMOTE_CONFIG_LAST_FETCH_TIME = "remote_config_last_fetch_time";
private static final String LAST_PREKEY_REFRESH_TIME = "last_prekey_refresh_time";
private static final String MESSAGE_REQUEST_ENABLE_TIME = "message_request_enable_time";
private SignalStore() {}
public static void onFirstEverAppLaunch() {
registrationValues().onFirstEverAppLaunch();
storageServiceValues().setFirstStorageSyncCompleted(false);
}
public static @NonNull KbsValues kbsValues() {
return new KbsValues(getStore());
}
@@ -29,22 +33,29 @@ public final class SignalStore {
return new PinValues(getStore());
}
public static String getRemoteConfig() {
return getStore().getString(REMOTE_CONFIG, null);
public static @NonNull RemoteConfigValues remoteConfigValues() {
return new RemoteConfigValues(getStore());
}
public static void setRemoteConfig(String value) {
putString(REMOTE_CONFIG, value);
public static @NonNull StorageServiceValues storageServiceValues() {
return new StorageServiceValues(getStore());
}
public static long getRemoteConfigLastFetchTime() {
return getStore().getLong(REMOTE_CONFIG_LAST_FETCH_TIME, 0);
public static long getLastPrekeyRefreshTime() {
return getStore().getLong(LAST_PREKEY_REFRESH_TIME, 0);
}
public static void setRemoteConfigLastFetchTime(long time) {
putLong(REMOTE_CONFIG_LAST_FETCH_TIME, time);
public static void setLastPrekeyRefreshTime(long time) {
putLong(LAST_PREKEY_REFRESH_TIME, time);
}
public static long getMessageRequestEnableTime() {
return getStore().getLong(MESSAGE_REQUEST_ENABLE_TIME, 0);
}
public static void setMessageRequestEnableTime(long time) {
putLong(MESSAGE_REQUEST_ENABLE_TIME, time);
}
/**
* Ensures any pending writes are finished. Only intended to be called by

View File

@@ -0,0 +1,56 @@
package org.thoughtcrime.securesms.keyvalue;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import java.security.SecureRandom;
public class StorageServiceValues {
private static final String STORAGE_MASTER_KEY = "storage.storage_master_key";
private static final String FIRST_STORAGE_SYNC_COMPLETED = "storage.first_storage_sync_completed";
private static final String LAST_SYNC_TIME = "storage.last_sync_time";
private final KeyValueStore store;
StorageServiceValues(@NonNull KeyValueStore store) {
this.store = store;
}
public synchronized MasterKey getOrCreateStorageMasterKey() {
byte[] blob = store.getBlob(STORAGE_MASTER_KEY, null);
if (blob == null) {
store.beginWrite()
.putBlob(STORAGE_MASTER_KEY, MasterKey.createNew(new SecureRandom()).serialize())
.commit();
blob = store.getBlob(STORAGE_MASTER_KEY, null);
}
return new MasterKey(blob);
}
public synchronized void rotateStorageMasterKey() {
store.beginWrite()
.putBlob(STORAGE_MASTER_KEY, MasterKey.createNew(new SecureRandom()).serialize())
.commit();
}
public boolean hasFirstStorageSyncCompleted() {
return !FeatureFlags.storageServiceRestore() || store.getBoolean(FIRST_STORAGE_SYNC_COMPLETED, true);
}
public void setFirstStorageSyncCompleted(boolean completed) {
store.beginWrite().putBoolean(FIRST_STORAGE_SYNC_COMPLETED, completed).apply();
}
public long getLastSyncTime() {
return store.getLong(LAST_SYNC_TIME, 0);
}
public void onSyncCompleted() {
store.beginWrite().putLong(LAST_SYNC_TIME, System.currentTimeMillis()).apply();
}
}

View File

@@ -2,8 +2,10 @@ package org.thoughtcrime.securesms.lock.v2;
import android.animation.Animator;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import androidx.annotation.NonNull;
import androidx.annotation.RawRes;
@@ -75,6 +77,7 @@ public class ConfirmKbsPinFragment extends BaseKbsPinFragment<ConfirmKbsPinViewM
break;
case CREATING_PIN:
getLabel().setText(R.string.ConfirmKbsPinFragment__creating_pin);
getInput().setEnabled(false);
break;
case RE_ENTER_PIN:
getLabel().setText(R.string.ConfirmKbsPinFragment__re_enter_pin);

View File

@@ -100,8 +100,8 @@ public class PersistentLogger extends Log.Logger {
}
@WorkerThread
public ListenableFuture<String> getLogs() {
final SettableFuture<String> future = new SettableFuture<>();
public ListenableFuture<CharSequence> getLogs() {
final SettableFuture<CharSequence> future = new SettableFuture<>();
executor.execute(() -> {
StringBuilder builder = new StringBuilder();
@@ -118,7 +118,7 @@ public class PersistentLogger extends Log.Logger {
}
}
future.set(builder.toString());
future.set(builder);
} catch (NoExternalStorageException e) {
future.setException(e);
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.logsubmit;
import androidx.annotation.NonNull;
/**
* A {@link LogLine} with proper IDs.
*/
public class CompleteLogLine implements LogLine {
private final long id;
private final LogLine line;
public CompleteLogLine(long id, @NonNull LogLine line) {
this.id = id;
this.line = line;
}
@Override
public long getId() {
return id;
}
@Override
public @NonNull String getText() {
return line.getText();
}
@Override
public @NonNull Style getStyle() {
return line.getStyle();
}
}

View File

@@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.logsubmit;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import java.util.List;
import java.util.regex.Pattern;
interface LogLine {
long getId();
@NonNull String getText();
@NonNull Style getStyle();
static List<LogLine> fromText(@NonNull CharSequence text) {
return Stream.of(Pattern.compile("\\n").split(text))
.map(s -> new SimpleLogLine(s, Style.NONE))
.map(line -> (LogLine) line)
.toList();
}
enum Style {
NONE, VERBOSE, DEBUG, INFO, WARNING, ERROR
}
}

View File

@@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.logsubmit;
import android.content.Context;
import androidx.annotation.NonNull;
import java.util.List;
interface LogSection {
/**
* The title to show at the top of the log section.
*/
@NonNull String getTitle();
/**
* The full content of your log section. We use a {@link CharSequence} instead of a
* {@link List<LogLine> } for performance reasons. Scrubbing large swaths of text is faster than
* one line at a time.
*/
@NonNull CharSequence getContent(@NonNull Context context);
}

View File

@@ -0,0 +1,54 @@
package org.thoughtcrime.securesms.logsubmit;
import android.content.Context;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.Util;
import java.util.Map;
public class LogSectionFeatureFlags implements LogSection {
@Override
public @NonNull String getTitle() {
return "FEATURE FLAGS";
}
@Override
public @NonNull CharSequence getContent(@NonNull Context context) {
StringBuilder out = new StringBuilder();
Map<String, Boolean> memory = FeatureFlags.getMemoryValues();
Map<String, Boolean> disk = FeatureFlags.getDiskValues();
Map<String, Boolean> forced = FeatureFlags.getForcedValues();
int remoteLength = Stream.of(memory.keySet()).map(String::length).max(Integer::compareTo).orElse(0);
int diskLength = Stream.of(disk.keySet()).map(String::length).max(Integer::compareTo).orElse(0);
int forcedLength = Stream.of(forced.keySet()).map(String::length).max(Integer::compareTo).orElse(0);
out.append("-- Memory\n");
for (Map.Entry<String, Boolean> entry : memory.entrySet()) {
out.append(Util.rightPad(entry.getKey(), remoteLength)).append(": ").append(entry.getValue()).append("\n");
}
out.append("\n");
out.append("-- Disk\n");
for (Map.Entry<String, Boolean> entry : disk.entrySet()) {
out.append(Util.rightPad(entry.getKey(), diskLength)).append(": ").append(entry.getValue()).append("\n");
}
out.append("\n");
out.append("-- Forced\n");
if (forced.isEmpty()) {
out.append("None\n");
} else {
for (Map.Entry<String, Boolean> entry : forced.entrySet()) {
out.append(Util.rightPad(entry.getKey(), forcedLength)).append(": ").append(entry.getValue()).append("\n");
}
}
return out;
}
}

View File

@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.logsubmit;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import java.util.List;
public class LogSectionJobs implements LogSection {
@Override
public @NonNull String getTitle() {
return "JOBS";
}
@Override
public @NonNull CharSequence getContent(@NonNull Context context) {
return ApplicationDependencies.getJobManager().getDebugInfo();
}
}

View File

@@ -0,0 +1,38 @@
package org.thoughtcrime.securesms.logsubmit;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.logging.Log;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class LogSectionLogcat implements LogSection {
@Override
public @NonNull String getTitle() {
return "LOGCAT";
}
@Override
public @NonNull CharSequence getContent(@NonNull Context context) {
try {
final Process process = Runtime.getRuntime().exec("logcat -d");
final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
final StringBuilder log = new StringBuilder();
final String separator = System.getProperty("line.separator");
String line;
while ((line = bufferedReader.readLine()) != null) {
log.append(line);
log.append(separator);
}
return log.toString();
} catch (IOException ioe) {
return "Failed to retrieve.";
}
}
}

View File

@@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.logsubmit;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.ApplicationContext;
import java.util.concurrent.ExecutionException;
public class LogSectionLogger implements LogSection {
@Override
public @NonNull String getTitle() {
return "LOGGER";
}
@Override
public @NonNull CharSequence getContent(@NonNull Context context) {
try {
return ApplicationContext.getInstance(context).getPersistentLogger().getLogs().get();
} catch (ExecutionException | InterruptedException e) {
return "Failed to retrieve.";
}
}
}

View File

@@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.logsubmit;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import androidx.annotation.NonNull;
import org.whispersystems.libsignal.util.Pair;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class LogSectionPermissions implements LogSection {
@Override
public @NonNull String getTitle() {
return "PERMISSIONS";
}
@Override
public @NonNull CharSequence getContent(@NonNull Context context) {
StringBuilder out = new StringBuilder();
List<Pair<String, Boolean>> status = new ArrayList<>();
try {
PackageInfo info = context.getPackageManager().getPackageInfo("org.thoughtcrime.securesms", PackageManager.GET_PERMISSIONS);
for (int i = 0; i < info.requestedPermissions.length; i++) {
status.add(new Pair<>(info.requestedPermissions[i],
(info.requestedPermissionsFlags[i] & PackageInfo.REQUESTED_PERMISSION_GRANTED) != 0));
}
} catch (PackageManager.NameNotFoundException e) {
return "Unable to retrieve.";
}
Collections.sort(status, (o1, o2) -> o1.first().compareTo(o2.first()));
for (Pair<String, Boolean> pair : status) {
out.append(pair.first()).append(": ");
out.append(pair.second() ? "YES" : "NO");
out.append("\n");
}
return out;
}
}

View File

@@ -0,0 +1,37 @@
package org.thoughtcrime.securesms.logsubmit;
import android.app.usage.UsageStatsManager;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import org.thoughtcrime.securesms.util.BucketInfo;
import java.util.List;
import java.util.concurrent.TimeUnit;
@RequiresApi(28)
public class LogSectionPower implements LogSection {
@Override
public @NonNull String getTitle() {
return "POWER";
}
@Override
public @NonNull CharSequence getContent(@NonNull Context context) {
final UsageStatsManager usageStatsManager = (UsageStatsManager) context.getSystemService(Context.USAGE_STATS_SERVICE);
if (usageStatsManager == null) {
return "UsageStatsManager not available";
}
BucketInfo info = BucketInfo.getInfo(usageStatsManager, TimeUnit.DAYS.toMillis(3));
return new StringBuilder().append("Current bucket: ").append(BucketInfo.bucketToString(info.getCurrentBucket())).append('\n')
.append("Highest bucket: ").append(BucketInfo.bucketToString(info.getBestBucket())).append('\n')
.append("Lowest bucket : ").append(BucketInfo.bucketToString(info.getWorstBucket())).append("\n\n")
.append(info.getHistory());
}
}

View File

@@ -0,0 +1,94 @@
package org.thoughtcrime.securesms.logsubmit;
import android.app.ActivityManager;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.util.ByteUnit;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
public class LogSectionSystemInfo implements LogSection {
@Override
public @NonNull String getTitle() {
return "SYSINFO";
}
@Override
public @NonNull CharSequence getContent(@NonNull Context context) {
final PackageManager pm = context.getPackageManager();
final StringBuilder builder = new StringBuilder();
builder.append("Time : ").append(System.currentTimeMillis()).append('\n');
builder.append("Device : ").append(Build.MANUFACTURER).append(" ")
.append(Build.MODEL).append(" (")
.append(Build.PRODUCT).append(")\n");
builder.append("Android : ").append(Build.VERSION.RELEASE).append(" (")
.append(Build.VERSION.INCREMENTAL).append(", ")
.append(Build.DISPLAY).append(")\n");
builder.append("ABIs : ").append(TextUtils.join(", ", getSupportedAbis())).append("\n");
builder.append("Memory : ").append(getMemoryUsage()).append("\n");
builder.append("Memclass : ").append(getMemoryClass(context)).append("\n");
builder.append("OS Host : ").append(Build.HOST).append("\n");
builder.append("First Version: ").append(TextSecurePreferences.getFirstInstallVersion(context)).append("\n");
builder.append("App : ");
try {
builder.append(pm.getApplicationLabel(pm.getApplicationInfo(context.getPackageName(), 0)))
.append(" ")
.append(pm.getPackageInfo(context.getPackageName(), 0).versionName)
.append(" (")
.append(Util.getManifestApkVersion(context))
.append(")\n");
} catch (PackageManager.NameNotFoundException nnfe) {
builder.append("Unknown\n");
}
return builder;
}
private static @NonNull String getMemoryUsage() {
Runtime info = Runtime.getRuntime();
long totalMemory = info.totalMemory();
return String.format(Locale.ENGLISH,
"%dM (%.2f%% free, %dM max)",
ByteUnit.BYTES.toMegabytes(totalMemory),
(float) info.freeMemory() / totalMemory * 100f,
ByteUnit.BYTES.toMegabytes(info.maxMemory()));
}
private static @NonNull String getMemoryClass(Context context) {
ActivityManager activityManager = ServiceUtil.getActivityManager(context);
String lowMem = "";
if (activityManager.isLowRamDevice()) {
lowMem = ", low-mem device";
}
return activityManager.getMemoryClass() + lowMem;
}
private static @NonNull Iterable<String> getSupportedAbis() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
return Arrays.asList(Build.SUPPORTED_ABIS);
} else {
LinkedList<String> abis = new LinkedList<>();
abis.add(Build.CPU_ABI);
if (Build.CPU_ABI2 != null && !"unknown".equals(Build.CPU_ABI2)) {
abis.add(Build.CPU_ABI2);
}
return abis;
}
}
}

View File

@@ -0,0 +1,38 @@
package org.thoughtcrime.securesms.logsubmit;
import android.content.Context;
import androidx.annotation.NonNull;
import java.util.List;
import java.util.Map;
public class LogSectionThreads implements LogSection {
@Override
public @NonNull String getTitle() {
return "BLOCKED THREADS";
}
@Override
public @NonNull CharSequence getContent(@NonNull Context context) {
Map<Thread, StackTraceElement[]> traces = Thread.getAllStackTraces();
StringBuilder out = new StringBuilder();
for (Map.Entry<Thread, StackTraceElement[]> entry : traces.entrySet()) {
if (entry.getKey().getState() == Thread.State.BLOCKED) {
Thread thread = entry.getKey();
out.append("-- [").append(thread.getId()).append("] ")
.append(thread.getName()).append(" (").append(thread.getState().toString()).append(")\n");
for (StackTraceElement element : entry.getValue()) {
out.append(element.toString()).append("\n");
}
out.append("\n");
}
}
return out.length() == 0 ? "None" : out;
}
}

View File

@@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.logsubmit;
import androidx.annotation.NonNull;
import java.util.HashMap;
import java.util.Map;
public class LogStyleParser {
private static final Map<String, LogLine.Style> STYLE_MARKERS = new HashMap<String, LogLine.Style>() {{
put(" V ", LogLine.Style.VERBOSE);
put(" D ", LogLine.Style.DEBUG);
put(" I ", LogLine.Style.INFO);
put(" W ", LogLine.Style.WARNING);
put(" E ", LogLine.Style.ERROR);
}};
public static LogLine.Style parseStyle(@NonNull String text) {
for (Map.Entry<String, LogLine.Style> entry : STYLE_MARKERS.entrySet()) {
if (text.contains(entry.getKey())) {
return entry.getValue();
}
}
return LogLine.Style.NONE;
}
}

View File

@@ -1,67 +0,0 @@
/*
* *
* Copyright (C) 2014 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
* /
*/
package org.thoughtcrime.securesms.logsubmit;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.ResolveInfo;
import androidx.annotation.NonNull;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.TextView;
import org.thoughtcrime.securesms.R;
import java.util.List;
/**
* rhodey
*/
public class ShareIntentListAdapter extends ArrayAdapter<ResolveInfo> {
public static ShareIntentListAdapter getAdapterForIntent(Context context, Intent shareIntent) {
List<ResolveInfo> activities = context.getPackageManager().queryIntentActivities(shareIntent, 0);
return new ShareIntentListAdapter(context, activities.toArray(new ResolveInfo[activities.size()]));
}
public ShareIntentListAdapter(Context context, ResolveInfo[] items) {
super(context, R.layout.share_intent_list, items);
}
@Override
public @NonNull View getView(int position, View convertView, @NonNull ViewGroup parent) {
LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View rowView = inflater.inflate(R.layout.share_intent_row, parent, false);
ImageView intentImage = (ImageView) rowView.findViewById(R.id.share_intent_image);
TextView intentLabel = (TextView) rowView.findViewById(R.id.share_intent_label);
ApplicationInfo intentInfo = getItem(position).activityInfo.applicationInfo;
intentImage.setImageDrawable(intentInfo.loadIcon(getContext().getPackageManager()));
intentLabel.setText(intentInfo.loadLabel(getContext().getPackageManager()));
return rowView;
}
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.logsubmit;
import androidx.annotation.NonNull;
/**
* A {@link LogLine} that doesn't worry about IDs.
*/
class SimpleLogLine implements LogLine {
static final SimpleLogLine EMPTY = new SimpleLogLine("", Style.NONE);
private final String text;
private final Style style;
SimpleLogLine(@NonNull String text, @NonNull Style style) {
this.text = text;
this.style = style;
}
@Override
public long getId() {
return -1;
}
public @NonNull String getText() {
return text;
}
public @NonNull Style getStyle() {
return style;
}
}

View File

@@ -0,0 +1,273 @@
package org.thoughtcrime.securesms.logsubmit;
import android.os.Bundle;
import android.text.method.LinkMovementMethod;
import android.text.util.Linkify;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.SearchView;
import androidx.core.app.ShareCompat;
import androidx.core.text.util.LinkifyCompat;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.dd.CircularProgressButton;
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.util.List;
public class SubmitDebugLogActivity extends PassphraseRequiredActionBarActivity implements SubmitDebugLogAdapter.Listener {
private RecyclerView lineList;
private SubmitDebugLogAdapter adapter;
private SubmitDebugLogViewModel viewModel;
private View warningBanner;
private View editBanner;
private CircularProgressButton submitButton;
private AlertDialog loadingDialog;
private View scrollToBottomButton;
private View scrollToTopButton;
private MenuItem editMenuItem;
private MenuItem doneMenuItem;
private MenuItem searchMenuItem;
private final DynamicTheme dynamicTheme = new DynamicTheme();
@Override
protected void onPreCreate() {
dynamicTheme.onCreate(this);
}
@Override
protected void onCreate(Bundle savedInstanceState, boolean ready) {
setContentView(R.layout.submit_debug_log_activity);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
initView();
initViewModel();
}
@Override
protected void onResume() {
super.onResume();
dynamicTheme.onResume(this);
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.submit_debug_log_normal, menu);
this.editMenuItem = menu.findItem(R.id.menu_edit_log);
this.doneMenuItem = menu.findItem(R.id.menu_done_editing_log);
this.searchMenuItem = menu.findItem(R.id.menu_search);
SearchView searchView = (SearchView) searchMenuItem.getActionView();
SearchView.OnQueryTextListener queryListener = new SearchView.OnQueryTextListener() {
@Override
public boolean onQueryTextSubmit(String query) {
viewModel.onQueryUpdated(query);
return true;
}
@Override
public boolean onQueryTextChange(String query) {
viewModel.onQueryUpdated(query);
return true;
}
};
searchMenuItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() {
@Override
public boolean onMenuItemActionExpand(MenuItem item) {
searchView.setOnQueryTextListener(queryListener);
return true;
}
@Override
public boolean onMenuItemActionCollapse(MenuItem item) {
searchView.setOnQueryTextListener(null);
viewModel.onSearchClosed();
return true;
}
});
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
super.onOptionsItemSelected(item);
switch (item.getItemId()) {
case android.R.id.home:
finish();
return true;
case R.id.menu_edit_log:
viewModel.onEditButtonPressed();
break;
case R.id.menu_done_editing_log:
viewModel.onDoneEditingButtonPressed();
break;
}
return false;
}
@Override
public void onBackPressed() {
if (!viewModel.onBackPressed()) {
super.onBackPressed();
}
}
@Override
public void onLogDeleted(@NonNull LogLine logLine) {
viewModel.onLogDeleted(logLine);
}
private void initView() {
this.lineList = findViewById(R.id.debug_log_lines);
this.warningBanner = findViewById(R.id.debug_log_warning_banner);
this.editBanner = findViewById(R.id.debug_log_edit_banner);
this.submitButton = findViewById(R.id.debug_log_submit_button);
this.scrollToBottomButton = findViewById(R.id.debug_log_scroll_to_bottom);
this.scrollToTopButton = findViewById(R.id.debug_log_scroll_to_top);
this.adapter = new SubmitDebugLogAdapter(this);
this.lineList.setLayoutManager(new LinearLayoutManager(this));
this.lineList.setAdapter(adapter);
submitButton.setOnClickListener(v -> onSubmitClicked());
scrollToBottomButton.setOnClickListener(v -> lineList.scrollToPosition(adapter.getItemCount() - 1));
scrollToTopButton.setOnClickListener(v -> lineList.scrollToPosition(0));
lineList.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (((LinearLayoutManager) recyclerView.getLayoutManager()).findLastVisibleItemPosition() < adapter.getItemCount() - 10) {
scrollToBottomButton.setVisibility(View.VISIBLE);
} else {
scrollToBottomButton.setVisibility(View.GONE);
}
if (((LinearLayoutManager) recyclerView.getLayoutManager()).findFirstVisibleItemPosition() > 10) {
scrollToTopButton.setVisibility(View.VISIBLE);
} else {
scrollToTopButton.setVisibility(View.GONE);
}
}
});
this.loadingDialog = SimpleProgressDialog.show(this);
}
private void initViewModel() {
this.viewModel = ViewModelProviders.of(this, new SubmitDebugLogViewModel.Factory()).get(SubmitDebugLogViewModel.class);
viewModel.getLines().observe(this, this::presentLines);
viewModel.getMode().observe(this, this::presentMode);
}
private void presentLines(@NonNull List<LogLine> lines) {
if (loadingDialog != null) {
loadingDialog.dismiss();
loadingDialog = null;
warningBanner.setVisibility(View.VISIBLE);
submitButton.setVisibility(View.VISIBLE);
}
adapter.setLines(lines);
}
private void presentMode(@NonNull SubmitDebugLogViewModel.Mode mode) {
switch (mode) {
case NORMAL:
editBanner.setVisibility(View.GONE);
adapter.setEditing(false);
editMenuItem.setVisible(true);
doneMenuItem.setVisible(false);
searchMenuItem.setVisible(true);
break;
case SUBMITTING:
editBanner.setVisibility(View.GONE);
adapter.setEditing(false);
editMenuItem.setVisible(false);
doneMenuItem.setVisible(false);
searchMenuItem.setVisible(false);
break;
case EDIT:
editBanner.setVisibility(View.VISIBLE);
adapter.setEditing(true);
editMenuItem.setVisible(false);
doneMenuItem.setVisible(true);
searchMenuItem.setVisible(true);
break;
}
}
private void presentResultDialog(@NonNull String url) {
AlertDialog.Builder builder = new AlertDialog.Builder(this)
.setTitle(R.string.SubmitDebugLogActivity_success)
.setCancelable(false)
.setNeutralButton(R.string.SubmitDebugLogActivity_ok, (d, w) -> finish())
.setPositiveButton(R.string.SubmitDebugLogActivity_share, (d, w) -> {
ShareCompat.IntentBuilder.from(this)
.setText(url)
.setType("text/plain")
.setEmailTo(new String[] { "support@signal.org" })
.startChooser();
});
TextView textView = new TextView(builder.getContext());
textView.setText(getResources().getString(R.string.SubmitDebugLogActivity_copy_this_url_and_add_it_to_your_issue, url));
textView.setMovementMethod(LinkMovementMethod.getInstance());
textView.setOnLongClickListener(v -> {
Util.copyToClipboard(this, url);
Toast.makeText(this, R.string.SubmitDebugLogActivity_copied_to_clipboard, Toast.LENGTH_SHORT).show();
return true;
});
LinkifyCompat.addLinks(textView, Linkify.WEB_URLS);
ViewUtil.setPadding(textView, (int) ThemeUtil.getThemedDimen(this, R.attr.dialogPreferredPadding));
builder.setView(textView);
builder.show();
}
private void onSubmitClicked() {
submitButton.setClickable(false);
submitButton.setIndeterminateProgressMode(true);
submitButton.setProgress(50);
viewModel.onSubmitClicked().observe(this, result -> {
if (result.isPresent()) {
presentResultDialog(result.get());
} else {
Toast.makeText(this, R.string.SubmitDebugLogActivity_failed_to_submit_logs, Toast.LENGTH_LONG).show();
}
submitButton.setClickable(true);
submitButton.setIndeterminateProgressMode(false);
submitButton.setProgress(0);
});
}
}

View File

@@ -0,0 +1,168 @@
package org.thoughtcrime.securesms.logsubmit;
import android.content.Context;
import android.graphics.Color;
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 com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ListenableHorizontalScrollView;
import org.thoughtcrime.securesms.util.ThemeUtil;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
public class SubmitDebugLogAdapter extends RecyclerView.Adapter<SubmitDebugLogAdapter.LineViewHolder> {
private final List<LogLine> lines;
private final ScrollManager scrollManager;
private final Listener listener;
private boolean editing;
private int longestLine;
public SubmitDebugLogAdapter(@NonNull Listener listener) {
this.listener = listener;
this.lines = new ArrayList<>();
this.scrollManager = new ScrollManager();
setHasStableIds(true);
}
@Override
public long getItemId(int position) {
return lines.get(position).getId();
}
@Override
public @NonNull LineViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new LineViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.submit_debug_log_line_item, parent, false));
}
@Override
public void onBindViewHolder(@NonNull LineViewHolder holder, int position) {
holder.bind(lines.get(position), longestLine, editing, scrollManager, listener);
}
@Override
public void onViewRecycled(@NonNull LineViewHolder holder) {
holder.unbind(scrollManager);
}
@Override
public int getItemCount() {
return lines.size();
}
public void setLines(@NonNull List<LogLine> lines) {
this.lines.clear();
this.lines.addAll(lines);
this.longestLine = Stream.of(lines).reduce(0, (currentMax, line) -> Math.max(currentMax, line.getText().length()));
notifyDataSetChanged();
}
public void setEditing(boolean editing) {
this.editing = editing;
notifyDataSetChanged();
}
private static class ScrollManager {
private final List<ScrollObserver> listeners = new CopyOnWriteArrayList<>();
private int currentPosition;
void subscribe(@NonNull ScrollObserver observer) {
listeners.add(observer);
observer.onScrollChanged(currentPosition);
}
void unsubscribe(@NonNull ScrollObserver observer) {
listeners.remove(observer);
}
void notify(int position) {
currentPosition = position;
for (ScrollObserver listener : listeners) {
listener.onScrollChanged(position);
}
}
}
private interface ScrollObserver {
void onScrollChanged(int position);
}
interface Listener {
void onLogDeleted(@NonNull LogLine logLine);
}
static class LineViewHolder extends RecyclerView.ViewHolder implements ScrollObserver {
private final TextView text;
private final ListenableHorizontalScrollView scrollView;
LineViewHolder(@NonNull View itemView) {
super(itemView);
this.text = itemView.findViewById(R.id.log_item_text);
this.scrollView = itemView.findViewById(R.id.log_item_scroll);
}
void bind(@NonNull LogLine line, int longestLine, boolean editing, @NonNull ScrollManager scrollManager, @NonNull Listener listener) {
Context context = itemView.getContext();
if (line.getText().length() < longestLine) {
text.setText(padRight(line.getText(), longestLine));
} else {
text.setText(line.getText());
}
switch (line.getStyle()) {
case NONE: text.setTextColor(ThemeUtil.getThemedColor(context, R.attr.debuglog_color_none)); break;
case VERBOSE: text.setTextColor(ThemeUtil.getThemedColor(context, R.attr.debuglog_color_verbose)); break;
case DEBUG: text.setTextColor(ThemeUtil.getThemedColor(context, R.attr.debuglog_color_debug)); break;
case INFO: text.setTextColor(ThemeUtil.getThemedColor(context, R.attr.debuglog_color_info)); break;
case WARNING: text.setTextColor(ThemeUtil.getThemedColor(context, R.attr.debuglog_color_warn)); break;
case ERROR: text.setTextColor(ThemeUtil.getThemedColor(context, R.attr.debuglog_color_error)); break;
}
scrollView.setOnScrollListener((newLeft, oldLeft) -> {
if (oldLeft - newLeft != 0) {
scrollManager.notify(newLeft);
}
});
scrollManager.subscribe(this);
if (editing) {
text.setOnClickListener(v -> listener.onLogDeleted(line));
} else {
text.setOnClickListener(null);
}
}
void unbind(@NonNull ScrollManager scrollManager) {
text.setOnClickListener(null);
scrollManager.unsubscribe(this);
}
@Override
public void onScrollChanged(int position) {
scrollView.scrollTo(position, 0);
}
private static String padRight(String s, int n) {
return String.format("%-" + n + "s", s);
}
}
}

View File

@@ -0,0 +1,215 @@
package org.thoughtcrime.securesms.logsubmit;
import android.content.Context;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.json.JSONException;
import org.json.JSONObject;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.logsubmit.util.Scrubber;
import org.thoughtcrime.securesms.net.UserAgentInterceptor;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.regex.Pattern;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
/**
* Handles retrieving, scrubbing, and uploading of all debug logs.
*
* Adding a new log section:
* - Create a new {@link LogSection}.
* - Add it to {@link #SECTIONS}. The order of the list is the order the sections are displayed.
*/
class SubmitDebugLogRepository {
private static final String TAG = Log.tag(SubmitDebugLogRepository.class);
private static final char TITLE_DECORATION = '=';
private static final int MIN_DECORATIONS = 5;
private static final int SECTION_SPACING = 3;
private static final String API_ENDPOINT = "https://debuglogs.org";
/** Ordered list of log sections. */
private static final List<LogSection> SECTIONS = new ArrayList<LogSection>() {{
add(new LogSectionSystemInfo());
add(new LogSectionJobs());
if (Build.VERSION.SDK_INT >= 28) {
add(new LogSectionPower());
}
add(new LogSectionThreads());
add(new LogSectionFeatureFlags());
add(new LogSectionPermissions());
add(new LogSectionLogcat());
add(new LogSectionLogger());
}};
private final Context context;
private final ExecutorService executor;
SubmitDebugLogRepository() {
this.context = ApplicationDependencies.getApplication();
this.executor = SignalExecutors.SERIAL;
}
void getLogLines(@NonNull Callback<List<LogLine>> callback) {
executor.execute(() -> callback.onResult(getLogLinesInternal()));
}
void submitLog(@NonNull List<LogLine> lines, Callback<Optional<String>> callback) {
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogInternal(lines)));
}
@WorkerThread
private @NonNull Optional<String> submitLogInternal(@NonNull List<LogLine> lines) {
StringBuilder bodyBuilder = new StringBuilder();
for (LogLine line : lines) {
bodyBuilder.append(line.getText()).append('\n');
}
try {
OkHttpClient client = new OkHttpClient.Builder().addInterceptor(new UserAgentInterceptor()).build();
Response response = client.newCall(new Request.Builder().url(API_ENDPOINT).get().build()).execute();
ResponseBody body = response.body();
if (!response.isSuccessful() || body == null) {
throw new IOException("Unsuccessful response: " + response);
}
JSONObject json = new JSONObject(body.string());
String url = json.getString("url");
JSONObject fields = json.getJSONObject("fields");
String item = fields.getString("key");
MultipartBody.Builder post = new MultipartBody.Builder();
Iterator<String> keys = fields.keys();
post.addFormDataPart("Content-Type", "text/plain");
while (keys.hasNext()) {
String key = keys.next();
post.addFormDataPart(key, fields.getString(key));
}
post.addFormDataPart("file", "file", RequestBody.create(MediaType.parse("text/plain"), bodyBuilder.toString()));
Response postResponse = client.newCall(new Request.Builder().url(url).post(post.build()).build()).execute();
if (!postResponse.isSuccessful()) {
throw new IOException("Bad response: " + postResponse);
}
return Optional.of(API_ENDPOINT + "/" + item);
} catch (IOException | JSONException e) {
Log.w(TAG, "Error during upload.", e);
return Optional.absent();
}
}
@WorkerThread
private @NonNull List<LogLine> getLogLinesInternal() {
long startTime = System.currentTimeMillis();
int maxTitleLength = Stream.of(SECTIONS).reduce(0, (max, section) -> Math.max(max, section.getTitle().length()));
List<Future<List<LogLine>>> futures = new ArrayList<>();
for (LogSection section : SECTIONS) {
futures.add(SignalExecutors.BOUNDED.submit(() -> {
List<LogLine> lines = getLinesForSection(context, section, maxTitleLength);
if (SECTIONS.indexOf(section) != SECTIONS.size() - 1) {
for (int i = 0; i < SECTION_SPACING; i++) {
lines.add(SimpleLogLine.EMPTY);
}
}
return lines;
}));
}
List<LogLine> allLines = new ArrayList<>();
for (Future<List<LogLine>> future : futures) {
try {
allLines.addAll(future.get());
} catch (ExecutionException | InterruptedException e) {
throw new AssertionError(e);
}
}
List<LogLine> withIds = new ArrayList<>(allLines.size());
for (int i = 0; i < allLines.size(); i++) {
withIds.add(new CompleteLogLine(i, allLines.get(i)));
}
Log.d(TAG, "Total time: " + (System.currentTimeMillis() - startTime) + " ms");
return withIds;
}
@WorkerThread
private static @NonNull List<LogLine> getLinesForSection(@NonNull Context context, @NonNull LogSection section, int maxTitleLength) {
long startTime = System.currentTimeMillis();
List<LogLine> out = new ArrayList<>();
out.add(new SimpleLogLine(formatTitle(section.getTitle(), maxTitleLength), LogLine.Style.NONE));
CharSequence content = Scrubber.scrub(section.getContent(context));
List<LogLine> lines = Stream.of(Pattern.compile("\\n").split(content))
.map(s -> new SimpleLogLine(s, LogStyleParser.parseStyle(s)))
.map(line -> (LogLine) line)
.toList();
out.addAll(lines);
Log.d(TAG, "[" + section.getTitle() + "] Took " + (System.currentTimeMillis() - startTime) + " ms");
return out;
}
private static @NonNull String formatTitle(@NonNull String title, int maxTitleLength) {
int neededPadding = maxTitleLength - title.length();
int leftPadding = neededPadding / 2;
int rightPadding = neededPadding - leftPadding;
StringBuilder out = new StringBuilder();
for (int i = 0; i < leftPadding + MIN_DECORATIONS; i++) {
out.append(TITLE_DECORATION);
}
out.append(' ').append(title).append(' ');
for (int i = 0; i < rightPadding + MIN_DECORATIONS; i++) {
out.append(TITLE_DECORATION);
}
return out.toString();
}
interface Callback<E> {
void onResult(E result);
}
}

View File

@@ -0,0 +1,116 @@
package org.thoughtcrime.securesms.logsubmit;
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 com.annimon.stream.Stream;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Collections;
import java.util.List;
public class SubmitDebugLogViewModel extends ViewModel {
private final SubmitDebugLogRepository repo;
private final DefaultValueLiveData<List<LogLine>> lines;
private final DefaultValueLiveData<Mode> mode;
private List<LogLine> sourceLines;
private SubmitDebugLogViewModel() {
this.repo = new SubmitDebugLogRepository();
this.lines = new DefaultValueLiveData<>(Collections.emptyList());
this.mode = new DefaultValueLiveData<>(Mode.NORMAL);
repo.getLogLines(result -> {
sourceLines = result;
mode.postValue(Mode.NORMAL);
lines.postValue(sourceLines);
});
}
@NonNull LiveData<List<LogLine>> getLines() {
return lines;
}
boolean hasLines() {
return lines.getValue().size() > 0;
}
@NonNull LiveData<Mode> getMode() {
return mode;
}
@NonNull LiveData<Optional<String>> onSubmitClicked() {
mode.postValue(Mode.SUBMITTING);
MutableLiveData<Optional<String>> result = new MutableLiveData<>();
repo.submitLog(lines.getValue(), value -> {
mode.postValue(Mode.NORMAL);
result.postValue(value);
});
return result;
}
void onQueryUpdated(@NonNull String query) {
if (TextUtils.isEmpty(query)) {
lines.postValue(sourceLines);
} else {
List<LogLine> filtered = Stream.of(sourceLines)
.filter(l -> l.getText().toLowerCase().contains(query.toLowerCase()))
.toList();
lines.postValue(filtered);
}
}
void onSearchClosed() {
lines.postValue(sourceLines);
}
void onEditButtonPressed() {
mode.setValue(Mode.EDIT);
}
void onDoneEditingButtonPressed() {
mode.setValue(Mode.NORMAL);
}
void onLogDeleted(@NonNull LogLine line) {
sourceLines.remove(line);
List<LogLine> logs = lines.getValue();
logs.remove(line);
lines.postValue(logs);
}
boolean onBackPressed() {
if (mode.getValue().equals(Mode.EDIT)) {
mode.setValue(Mode.NORMAL);
return true;
} else {
return false;
}
}
enum Mode {
NORMAL, EDIT, SUBMITTING
}
public static class Factory extends ViewModelProvider.NewInstanceFactory {
@Override
public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new SubmitDebugLogViewModel());
}
}
}

View File

@@ -1,759 +0,0 @@
/*
* Copyright (C) 2014 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.logsubmit;
import android.annotation.TargetApi;
import android.app.Activity;
import android.app.ActivityManager;
import android.app.AlertDialog;
import android.app.usage.UsageStatsManager;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Bundle;
import android.text.ClipboardManager;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.text.util.Linkify;
import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Stream;
import org.json.JSONException;
import org.json.JSONObject;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.logsubmit.util.Scrubber;
import org.thoughtcrime.securesms.util.BucketInfo;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.FrameRateTracker;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.whispersystems.libsignal.util.Pair;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
/**
* A helper {@link Fragment} to preview and submit logcat information to a public pastebin.
* Activities that contain this fragment must implement the
* {@link SubmitLogFragment.OnLogSubmittedListener} interface
* to handle interaction events.
* Use the {@link SubmitLogFragment#newInstance} factory method to
* create an instance of this fragment.
*
*/
public class SubmitLogFragment extends Fragment {
private static final String TAG = SubmitLogFragment.class.getSimpleName();
private static final String API_ENDPOINT = "https://debuglogs.org";
private static final String HEADER_SYSINFO = "========= SYSINFO =========";
private static final String HEADER_JOBS = "=========== JOBS ==========";
private static final String HEADER_POWER = "========== POWER ==========";
private static final String HEADER_THREADS = "===== BLOCKED THREADS =====";
private static final String HEADER_PERMISSIONS = "======= PERMISSIONS =======";
private static final String HEADER_FLAGS = "====== FEATURE FLAGS ======";
private static final String HEADER_LOGCAT = "========== LOGCAT =========";
private static final String HEADER_LOGGER = "========== LOGGER =========";
private Button okButton;
private Button cancelButton;
private View scrollButton;
private String supportEmailAddress;
private String supportEmailSubject;
private String hackSavedLogUrl;
private boolean emailActivityWasStarted = false;
private RecyclerView logPreview;
private LogPreviewAdapter logPreviewAdapter;
private OnLogSubmittedListener mListener;
/**
* Use this factory method to create a new instance of
* this fragment using the provided parameters.
*
* @return A new instance of fragment SubmitLogFragment.
*/
public static SubmitLogFragment newInstance(String supportEmailAddress,
String supportEmailSubject)
{
SubmitLogFragment fragment = new SubmitLogFragment();
fragment.supportEmailAddress = supportEmailAddress;
fragment.supportEmailSubject = supportEmailSubject;
return fragment;
}
public static SubmitLogFragment newInstance()
{
return newInstance(null, null);
}
public SubmitLogFragment() { }
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_submit_log, container, false);
}
@Override
public void onViewCreated(@NonNull View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
initializeResources();
}
@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
try {
mListener = (OnLogSubmittedListener) activity;
} catch (ClassCastException e) {
throw new ClassCastException(activity.toString() + " must implement OnFragmentInteractionListener");
}
}
@Override
public void onResume() {
super.onResume();
if (emailActivityWasStarted && mListener != null)
mListener.onSuccess();
}
@Override
public void onDetach() {
super.onDetach();
mListener = null;
}
private void initializeResources() {
okButton = getView().findViewById(R.id.ok);
cancelButton = getView().findViewById(R.id.cancel);
logPreview = getView().findViewById(R.id.log_preview);
scrollButton = getView().findViewById(R.id.scroll_to_bottom_button);
okButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
new SubmitToPastebinAsyncTask(logPreviewAdapter.getText()).execute();
}
});
cancelButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (mListener != null) mListener.onCancel();
}
});
scrollButton.setOnClickListener(v -> logPreview.scrollToPosition(logPreviewAdapter.getItemCount() - 1));
logPreview.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (((LinearLayoutManager) recyclerView.getLayoutManager()).findLastVisibleItemPosition() < logPreviewAdapter.getItemCount() - 10) {
scrollButton.setVisibility(View.VISIBLE);
} else {
scrollButton.setVisibility(View.GONE);
}
}
});
logPreviewAdapter = new LogPreviewAdapter();
logPreview.setLayoutManager(new LinearLayoutManager(getContext()));
logPreview.setAdapter(logPreviewAdapter);
new PopulateLogcatAsyncTask(getActivity()).execute();
}
private static String grabLogcat() {
try {
final Process process = Runtime.getRuntime().exec("logcat -d");
final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
final StringBuilder log = new StringBuilder();
final String separator = System.getProperty("line.separator");
String line;
while ((line = bufferedReader.readLine()) != null) {
log.append(line);
log.append(separator);
}
return log.toString();
} catch (IOException ioe) {
Log.w(TAG, "IOException when trying to read logcat.", ioe);
return null;
}
}
private Intent getIntentForSupportEmail(String logUrl) {
Intent emailSendIntent = new Intent(Intent.ACTION_SEND);
emailSendIntent.putExtra(Intent.EXTRA_EMAIL, new String[] { supportEmailAddress });
emailSendIntent.putExtra(Intent.EXTRA_SUBJECT, supportEmailSubject);
emailSendIntent.putExtra(
Intent.EXTRA_TEXT,
getString(R.string.log_submit_activity__please_review_this_log_from_my_app, logUrl)
);
emailSendIntent.setType("message/rfc822");
return emailSendIntent;
}
private void handleShowChooserForIntent(final Intent intent, String chooserTitle) {
final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
final ShareIntentListAdapter adapter = ShareIntentListAdapter.getAdapterForIntent(getActivity(), intent);
builder.setTitle(chooserTitle)
.setAdapter(adapter, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
ActivityInfo info = adapter.getItem(which).activityInfo;
intent.setClassName(info.packageName, info.name);
startActivity(intent);
emailActivityWasStarted = true;
}
})
.setOnCancelListener(new DialogInterface.OnCancelListener() {
@Override
public void onCancel(DialogInterface dialogInterface) {
if (hackSavedLogUrl != null)
handleShowSuccessDialog(hackSavedLogUrl);
}
})
.create().show();
}
private TextView handleBuildSuccessTextView(final String logUrl) {
TextView showText = new TextView(getActivity());
showText.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16);
showText.setPadding(15, 30, 15, 30);
showText.setText(getString(R.string.log_submit_activity__copy_this_url_and_add_it_to_your_issue, logUrl));
showText.setAutoLinkMask(Activity.RESULT_OK);
showText.setMovementMethod(LinkMovementMethod.getInstance());
showText.setOnLongClickListener(new View.OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
@SuppressWarnings("deprecation")
ClipboardManager manager =
(ClipboardManager) getActivity().getSystemService(Activity.CLIPBOARD_SERVICE);
manager.setText(logUrl);
Toast.makeText(getActivity(),
R.string.log_submit_activity__copied_to_clipboard,
Toast.LENGTH_SHORT).show();
return true;
}
});
Linkify.addLinks(showText, Linkify.WEB_URLS);
return showText;
}
private void handleShowSuccessDialog(final String logUrl) {
TextView showText = handleBuildSuccessTextView(logUrl);
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
builder.setTitle(R.string.log_submit_activity__success)
.setView(showText)
.setCancelable(false)
.setNeutralButton(R.string.log_submit_activity__button_got_it, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
dialogInterface.dismiss();
if (mListener != null) mListener.onSuccess();
}
});
if (supportEmailAddress != null) {
builder.setPositiveButton(R.string.log_submit_activity__button_compose_email, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
handleShowChooserForIntent(
getIntentForSupportEmail(logUrl),
getString(R.string.log_submit_activity__choose_email_app)
);
}
});
}
builder.create().show();
hackSavedLogUrl = logUrl;
}
private class PopulateLogcatAsyncTask extends AsyncTask<Void,Void,String> {
private WeakReference<Context> weakContext;
public PopulateLogcatAsyncTask(Context context) {
this.weakContext = new WeakReference<>(context);
}
@Override
protected String doInBackground(Void... voids) {
Context context = weakContext.get();
if (context == null) return null;
CharSequence newLogs;
try {
long t1 = System.currentTimeMillis();
String logs = ApplicationContext.getInstance(context).getPersistentLogger().getLogs().get();
Log.i(TAG, "Fetch our logs : " + (System.currentTimeMillis() - t1) + " ms");
long t2 = System.currentTimeMillis();
newLogs = Scrubber.scrub(logs);
Log.i(TAG, "Scrub our logs: " + (System.currentTimeMillis() - t2) + " ms");
} catch (InterruptedException | ExecutionException e) {
Log.w(TAG, "Failed to retrieve new logs.", e);
newLogs = "Failed to retrieve logs.";
}
long t3 = System.currentTimeMillis();
String logcat = grabLogcat();
Log.i(TAG, "Fetch logcat: " + (System.currentTimeMillis() - t3) + " ms");
long t4 = System.currentTimeMillis();
CharSequence scrubbedLogcat = Scrubber.scrub(logcat);
Log.i(TAG, "Scrub logcat: " + (System.currentTimeMillis() - t4) + " ms");
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append(HEADER_SYSINFO)
.append("\n\n")
.append(buildDescription(context))
.append("\n\n\n")
.append(HEADER_JOBS)
.append("\n\n")
.append(Scrubber.scrub(ApplicationDependencies.getJobManager().getDebugInfo()))
.append("\n\n\n");
if (VERSION.SDK_INT >= 28) {
stringBuilder.append(HEADER_POWER)
.append("\n\n")
.append(buildPower(context))
.append("\n\n\n");
}
stringBuilder.append(HEADER_THREADS)
.append("\n\n")
.append(buildBlockedThreads())
.append("\n\n\n");
stringBuilder.append(HEADER_FLAGS)
.append("\n\n")
.append(buildFlags())
.append("\n\n\n");
stringBuilder.append(HEADER_PERMISSIONS)
.append("\n\n")
.append(buildPermissions(context))
.append("\n\n\n");
stringBuilder.append(HEADER_LOGCAT)
.append("\n\n")
.append(scrubbedLogcat)
.append("\n\n\n")
.append(HEADER_LOGGER)
.append("\n\n")
.append(newLogs);
return stringBuilder.toString();
}
@Override
protected void onPreExecute() {
super.onPreExecute();
logPreviewAdapter.setText(getString(R.string.log_submit_activity__loading_logs));
okButton.setEnabled(false);
}
@Override
protected void onPostExecute(String logcat) {
super.onPostExecute(logcat);
if (TextUtils.isEmpty(logcat)) {
if (mListener != null) mListener.onFailure();
return;
}
logPreviewAdapter.setText(logcat);
okButton.setEnabled(true);
}
}
private class SubmitToPastebinAsyncTask extends ProgressDialogAsyncTask<Void,Void,String> {
private final String paste;
public SubmitToPastebinAsyncTask(String paste) {
super(getActivity(), R.string.log_submit_activity__submitting, R.string.log_submit_activity__uploading_logs);
this.paste = paste;
}
@Override
protected String doInBackground(Void... voids) {
try {
OkHttpClient client = new OkHttpClient.Builder().build();
Response response = client.newCall(new Request.Builder().url(API_ENDPOINT).get().build()).execute();
ResponseBody body = response.body();
if (!response.isSuccessful() || body == null) {
throw new IOException("Unsuccessful response: " + response);
}
JSONObject json = new JSONObject(body.string());
String url = json.getString("url");
JSONObject fields = json.getJSONObject("fields");
String item = fields.getString("key");
MultipartBody.Builder post = new MultipartBody.Builder();
Iterator<String> keys = fields.keys();
post.addFormDataPart("Content-Type", "text/plain");
while (keys.hasNext()) {
String key = keys.next();
post.addFormDataPart(key, fields.getString(key));
}
post.addFormDataPart("file", "file", RequestBody.create(MediaType.parse("text/plain"), paste));
Response postResponse = client.newCall(new Request.Builder().url(url).post(post.build()).build()).execute();
if (!postResponse.isSuccessful()) {
throw new IOException("Bad response: " + postResponse);
}
return API_ENDPOINT + "/" + item;
} catch (IOException | JSONException e) {
Log.w("ImageActivity", e);
}
return null;
}
@Override
protected void onPostExecute(final String response) {
super.onPostExecute(response);
if (response != null)
handleShowSuccessDialog(response);
else {
Log.w(TAG, "Response was null from Gist API.");
Toast.makeText(getActivity(), R.string.log_submit_activity__network_failure, Toast.LENGTH_LONG).show();
}
}
}
private static long asMegs(long bytes) {
return bytes / 1048576L;
}
public static String getMemoryUsage(Context context) {
Runtime info = Runtime.getRuntime();
long totalMemory = info.totalMemory();
return String.format(Locale.ENGLISH, "%dM (%.2f%% free, %dM max)",
asMegs(totalMemory),
(float)info.freeMemory() / totalMemory * 100f,
asMegs(info.maxMemory()));
}
@TargetApi(VERSION_CODES.KITKAT)
public static String getMemoryClass(Context context) {
ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
String lowMem = "";
if (VERSION.SDK_INT >= VERSION_CODES.KITKAT && activityManager.isLowRamDevice()) {
lowMem = ", low-mem device";
}
return activityManager.getMemoryClass() + lowMem;
}
private static CharSequence buildDescription(Context context) {
final PackageManager pm = context.getPackageManager();
final StringBuilder builder = new StringBuilder();
builder.append("Time : ").append(System.currentTimeMillis()).append('\n');
builder.append("Device : ").append(Build.MANUFACTURER).append(" ")
.append(Build.MODEL).append(" (")
.append(Build.PRODUCT).append(")\n");
builder.append("Android : ").append(VERSION.RELEASE).append(" (")
.append(VERSION.INCREMENTAL).append(", ")
.append(Build.DISPLAY).append(")\n");
builder.append("ABIs : ").append(TextUtils.join(", ", getSupportedAbis())).append("\n");
builder.append("Memory : ").append(getMemoryUsage(context)).append("\n");
builder.append("Memclass : ").append(getMemoryClass(context)).append("\n");
builder.append("OS Host : ").append(Build.HOST).append("\n");
builder.append("Refresh Rate : ").append(String.format(Locale.ENGLISH, "%.2f", FrameRateTracker.getDisplayRefreshRate(context))).append(" hz").append("\n");
builder.append("Average FPS : ").append(String.format(Locale.ENGLISH, "%.2f", ApplicationDependencies.getFrameRateTracker().getRunningAverageFps())).append("\n");
builder.append("First Version: ").append(TextSecurePreferences.getFirstInstallVersion(context)).append("\n");
builder.append("App : ").append(BuildConfig.VERSION_NAME);
return builder;
}
@RequiresApi(28)
private static CharSequence buildPower(@NonNull Context context) {
final UsageStatsManager usageStatsManager = (UsageStatsManager) context.getSystemService(Context.USAGE_STATS_SERVICE);
if (usageStatsManager == null) {
return "UsageStatsManager not available";
}
BucketInfo info = BucketInfo.getInfo(usageStatsManager, TimeUnit.DAYS.toMillis(3));
return new StringBuilder().append("Current bucket: ").append(BucketInfo.bucketToString(info.getCurrentBucket())).append('\n')
.append("Highest bucket: ").append(BucketInfo.bucketToString(info.getBestBucket())).append('\n')
.append("Lowest bucket : ").append(BucketInfo.bucketToString(info.getWorstBucket())).append("\n\n")
.append(info.getHistory());
}
private static CharSequence buildBlockedThreads() {
Map<Thread, StackTraceElement[]> traces = Thread.getAllStackTraces();
StringBuilder out = new StringBuilder();
for (Map.Entry<Thread, StackTraceElement[]> entry : traces.entrySet()) {
if (entry.getKey().getState() == Thread.State.BLOCKED) {
Thread thread = entry.getKey();
out.append("-- [").append(thread.getId()).append("] ")
.append(thread.getName()).append(" (").append(thread.getState().toString()).append(")\n");
for (StackTraceElement element : entry.getValue()) {
out.append(element.toString()).append("\n");
}
out.append("\n");
}
}
return out.length() == 0 ? "None" : out;
}
private static CharSequence buildPermissions(@NonNull Context context) {
StringBuilder out = new StringBuilder();
List<Pair<String, Boolean>> status = new ArrayList<>();
try {
PackageInfo info = context.getPackageManager().getPackageInfo("org.thoughtcrime.securesms", PackageManager.GET_PERMISSIONS);
for (int i = 0; i < info.requestedPermissions.length; i++) {
status.add(new Pair<>(info.requestedPermissions[i],
(info.requestedPermissionsFlags[i] & PackageInfo.REQUESTED_PERMISSION_GRANTED) != 0));
}
} catch (PackageManager.NameNotFoundException e) {
return "Unable to retrieve.";
}
Collections.sort(status, (o1, o2) -> o1.first().compareTo(o2.first()));
for (Pair<String, Boolean> pair : status) {
out.append(pair.first()).append(": ");
out.append(pair.second() ? "YES" : "NO");
out.append("\n");
}
return out;
}
private static CharSequence buildFlags() {
StringBuilder out = new StringBuilder();
Map<String, Boolean> memory = FeatureFlags.getMemoryValues();
Map<String, Boolean> disk = FeatureFlags.getDiskValues();
Map<String, Boolean> forced = FeatureFlags.getForcedValues();
int remoteLength = Stream.of(memory.keySet()).map(String::length).max(Integer::compareTo).orElse(0);
int diskLength = Stream.of(disk.keySet()).map(String::length).max(Integer::compareTo).orElse(0);
int forcedLength = Stream.of(forced.keySet()).map(String::length).max(Integer::compareTo).orElse(0);
out.append("-- Memory\n");
for (Map.Entry<String, Boolean> entry : memory.entrySet()) {
out.append(Util.rightPad(entry.getKey(), remoteLength)).append(": ").append(entry.getValue()).append("\n");
}
out.append("\n");
out.append("-- Disk\n");
for (Map.Entry<String, Boolean> entry : disk.entrySet()) {
out.append(Util.rightPad(entry.getKey(), diskLength)).append(": ").append(entry.getValue()).append("\n");
}
out.append("\n");
out.append("-- Forced\n");
if (forced.isEmpty()) {
out.append("None\n");
} else {
for (Map.Entry<String, Boolean> entry : forced.entrySet()) {
out.append(Util.rightPad(entry.getKey(), forcedLength)).append(": ").append(entry.getValue()).append("\n");
}
}
return out;
}
private static Iterable<String> getSupportedAbis() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
return Arrays.asList(Build.SUPPORTED_ABIS);
} else {
LinkedList<String> abis = new LinkedList<>();
abis.add(Build.CPU_ABI);
if (Build.CPU_ABI2 != null && !"unknown".equals(Build.CPU_ABI2)) {
abis.add(Build.CPU_ABI2);
}
return abis;
}
}
/**
* This interface must be implemented by activities that contain this
* fragment to allow an interaction in this fragment to be communicated
* to the activity and potentially other fragments contained in that
* activity.
* <p>
* See the Android Training lesson <a href=
* "http://developer.android.com/training/basics/fragments/communicating.html"
* >Communicating with Other Fragments</a> for more information.
*/
public interface OnLogSubmittedListener {
public void onSuccess();
public void onFailure();
public void onCancel();
}
private static final class LogPreviewAdapter extends RecyclerView.Adapter<LogPreviewViewHolder> {
private String[] lines = new String[0];
@Override
public LogPreviewViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
return new LogPreviewViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item_log_preview, parent, false));
}
@Override
public void onBindViewHolder(LogPreviewViewHolder holder, int position) {
holder.bind(lines, position);
}
@Override
public void onViewRecycled(LogPreviewViewHolder holder) {
holder.unbind();
}
@Override
public int getItemCount() {
return lines.length;
}
void setText(@NonNull String text) {
lines = text.split("\n");
notifyDataSetChanged();
}
String getText() {
return Util.join(lines, "\n");
}
}
private static final class LogPreviewViewHolder extends RecyclerView.ViewHolder {
private EditText text;
private String[] lines;
private int index;
LogPreviewViewHolder(View itemView) {
super(itemView);
text = (EditText) itemView;
}
void bind(String[] lines, int index) {
this.lines = lines;
this.index = index;
text.setText(lines[index]);
text.addTextChangedListener(textWatcher);
}
void unbind() {
text.removeTextChangedListener(textWatcher);
}
private final SimpleTextWatcher textWatcher = new SimpleTextWatcher() {
@Override
public void onTextChanged(String text) {
if (lines != null) {
lines[index] = text;
}
}
};
}
}

View File

@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.InviteActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ThemeUtil;
@@ -75,8 +76,8 @@ public class CameraContactSelectionFragment extends Fragment implements CameraCo
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
int theme = TextSecurePreferences.getTheme(inflater.getContext()).equals("light") ? R.style.TextSecure_LightTheme
: R.style.TextSecure_DarkTheme;
int theme = DynamicTheme.isDarkTheme(inflater.getContext()) ? R.style.TextSecure_DarkTheme
: R.style.TextSecure_LightTheme;
return ThemeUtil.getThemedInflater(inflater.getContext(), inflater, theme)
.inflate(R.layout.camera_contact_selection_fragment, container, false);
}

View File

@@ -156,7 +156,7 @@ public class Megaphone {
}
enum Style {
REACTIONS, BASIC, FULLSCREEN
REACTIONS, BASIC, FULLSCREEN, POPUP
}
public interface EventListener {

View File

@@ -50,6 +50,7 @@ public class MegaphoneRepository {
public void onFirstEverAppLaunch() {
executor.execute(() -> {
database.markFinished(Event.REACTIONS);
database.markFinished(Event.MESSAGE_REQUESTS);
resetDatabaseCache();
});
}

View File

@@ -21,6 +21,8 @@ public class MegaphoneViewBuilder {
return null;
case REACTIONS:
return buildReactionsMegaphone(context, megaphone, listener);
case POPUP:
return buildPopupMegaphone(context, megaphone, listener);
default:
throw new IllegalArgumentException("No view implemented for style!");
}
@@ -43,4 +45,13 @@ public class MegaphoneViewBuilder {
view.present(megaphone, listener);
return view;
}
private static @NonNull View buildPopupMegaphone(@NonNull Context context,
@NonNull Megaphone megaphone,
@NonNull MegaphoneActionController listener)
{
PopupMegaphoneView view = new PopupMegaphoneView(context);
view.present(megaphone, listener);
return view;
}
}

View File

@@ -9,6 +9,7 @@ import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment;
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
@@ -19,6 +20,7 @@ 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.logging.Log;
import org.thoughtcrime.securesms.messagerequests.MessageRequestMegaphoneActivity;
import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
import org.thoughtcrime.securesms.util.FeatureFlags;
@@ -49,7 +51,8 @@ public final class Megaphones {
private static final MegaphoneSchedule ALWAYS = new ForeverSchedule(true);
private static final MegaphoneSchedule NEVER = new ForeverSchedule(false);
private static final MegaphoneSchedule EVERY_TWO_DAYS = new RecurringSchedule(TimeUnit.DAYS.toMillis(2));
static final MegaphoneSchedule EVERY_TWO_DAYS = new RecurringSchedule(TimeUnit.DAYS.toMillis(2));
private Megaphones() {}
@@ -90,8 +93,9 @@ public final class Megaphones {
return new LinkedHashMap<Event, MegaphoneSchedule>() {{
put(Event.REACTIONS, ALWAYS);
put(Event.PINS_FOR_ALL, new PinsForAllSchedule());
put(Event.PROFILE_NAMES_FOR_ALL, FeatureFlags.profileNamesMegaphoneEnabled() ? EVERY_TWO_DAYS : NEVER);
put(Event.PROFILE_NAMES_FOR_ALL, FeatureFlags.profileNamesMegaphone() ? EVERY_TWO_DAYS : NEVER);
put(Event.PIN_REMINDER, new SignalPinReminderSchedule());
put(Event.MESSAGE_REQUESTS, shouldShowMessageRequestsMegaphone() ? ALWAYS : NEVER);
}};
}
@@ -105,6 +109,8 @@ public final class Megaphones {
return buildPinReminderMegaphone(context);
case PROFILE_NAMES_FOR_ALL:
return buildProfileNamesMegaphone(context);
case MESSAGE_REQUESTS:
return buildMessageRequestsMegaphone(context);
default:
throw new IllegalArgumentException("Event not handled!");
}
@@ -195,6 +201,10 @@ public final class Megaphones {
}
private static @NonNull Megaphone buildProfileNamesMegaphone(@NonNull Context context) {
short requestCode = TextSecurePreferences.getProfileName(context) != ProfileName.EMPTY
? ConversationListFragment.PROFILE_NAMES_REQUEST_CODE_CONFIRM_NAME
: ConversationListFragment.PROFILE_NAMES_REQUEST_CODE_CREATE_NAME;
Megaphone.Builder builder = new Megaphone.Builder(Event.PROFILE_NAMES_FOR_ALL, Megaphone.Style.BASIC)
.enableSnooze(null)
.setImage(R.drawable.profile_megaphone);
@@ -204,7 +214,7 @@ public final class Megaphones {
.setBody(R.string.ProfileNamesMegaphone__this_will_be_displayed_when_you_start)
.setActionButton(R.string.ProfileNamesMegaphone__add_profile_name, (megaphone, listener) -> {
listener.onMegaphoneSnooze(Event.PROFILE_NAMES_FOR_ALL);
listener.onMegaphoneNavigationRequested(new Intent(context, EditProfileActivity.class));
listener.onMegaphoneNavigationRequested(new Intent(context, EditProfileActivity.class), requestCode);
})
.build();
} else {
@@ -212,17 +222,34 @@ public final class Megaphones {
.setBody(R.string.ProfileNamesMegaphone__your_profile_can_now_include)
.setActionButton(R.string.ProfileNamesMegaphone__confirm_name, (megaphone, listener) -> {
listener.onMegaphoneCompleted(Event.PROFILE_NAMES_FOR_ALL);
listener.onMegaphoneNavigationRequested(new Intent(context, EditProfileActivity.class));
listener.onMegaphoneNavigationRequested(new Intent(context, EditProfileActivity.class), requestCode);
})
.build();
}
}
private static @NonNull Megaphone buildMessageRequestsMegaphone(@NonNull Context context) {
return new Megaphone.Builder(Event.MESSAGE_REQUESTS, Megaphone.Style.FULLSCREEN)
.disableSnooze()
.setMandatory(true)
.setOnVisibleListener(((megaphone, listener) -> {
listener.onMegaphoneNavigationRequested(new Intent(context, MessageRequestMegaphoneActivity.class),
ConversationListFragment.MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME);
}))
.build();
}
private static boolean shouldShowMessageRequestsMegaphone() {
boolean userHasAProfileName = TextSecurePreferences.getProfileName(ApplicationDependencies.getApplication()) != ProfileName.EMPTY;
return FeatureFlags.messageRequests() && !userHasAProfileName;
}
public enum Event {
REACTIONS("reactions"),
PINS_FOR_ALL("pins_for_all"),
PIN_REMINDER("pin_reminder"),
PROFILE_NAMES_FOR_ALL("profile_names");
PROFILE_NAMES_FOR_ALL("profile_names"),
MESSAGE_REQUESTS("message_requests");
private final String key;

View File

@@ -26,7 +26,7 @@ class PinsForAllSchedule implements MegaphoneSchedule {
static boolean shouldDisplayFullScreen(long firstVisible, long currentTime) {
return false;
// TODO [greyson]
// TODO [greyson] [pins] Maybe re-enable if we ever do a blocking flow again
// if (pinCreationFailedDuringRegistration()) {
// return true;
// }
@@ -74,6 +74,10 @@ class PinsForAllSchedule implements MegaphoneSchedule {
return false;
}
if (SignalStore.kbsValues().hasMigratedToPinsForAll()) {
return false;
}
return FeatureFlags.pinsForAll();
}

View File

@@ -0,0 +1,86 @@
package org.thoughtcrime.securesms.megaphone;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
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 PopupMegaphoneView extends FrameLayout {
private ImageView image;
private TextView titleText;
private TextView bodyText;
private View xButton;
private Megaphone megaphone;
private MegaphoneActionController megaphoneListener;
public PopupMegaphoneView(@NonNull Context context) {
super(context);
init(context);
}
public PopupMegaphoneView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(context);
}
private void init(@NonNull Context context) {
inflate(context, R.layout.popup_megaphone_view, this);
this.image = findViewById(R.id.popup_megaphone_image);
this.titleText = findViewById(R.id.popup_megaphone_title);
this.bodyText = findViewById(R.id.popup_megaphone_body);
this.xButton = findViewById(R.id.popup_x);
}
@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 MegaphoneActionController 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.hasButton()) {
xButton.setOnClickListener(v -> megaphone.getButtonClickListener().onEvent(megaphone, megaphoneListener));
} else {
xButton.setOnClickListener(v -> megaphoneListener.onMegaphoneCompleted(megaphone.getEvent()));
}
}
}

View File

@@ -1,172 +0,0 @@
package org.thoughtcrime.securesms.messagerequests;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.conversation.ConversationItem;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
public class MessageRequestFragment extends Fragment {
private AvatarImageView contactAvatar;
private TextView contactTitle;
private TextView contactSubtitle;
private TextView contactDescription;
private FrameLayout messageView;
private TextView question;
private Button accept;
private Button block;
private Button delete;
private ConversationItem conversationItem;
private MessageRequestFragmentViewModel viewModel;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState)
{
return inflater.inflate(R.layout.message_request_fragment, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
contactAvatar = view.findViewById(R.id.message_request_avatar);
contactTitle = view.findViewById(R.id.message_request_title);
contactSubtitle = view.findViewById(R.id.message_request_subtitle);
contactDescription = view.findViewById(R.id.message_request_description);
messageView = view.findViewById(R.id.message_request_message);
question = view.findViewById(R.id.message_request_question);
accept = view.findViewById(R.id.message_request_accept);
block = view.findViewById(R.id.message_request_block);
delete = view.findViewById(R.id.message_request_delete);
initializeViewModel();
initializeBottomViewListeners();
}
private void initializeViewModel() {
viewModel = ViewModelProviders.of(requireActivity()).get(MessageRequestFragmentViewModel.class);
viewModel.getState().observe(getViewLifecycleOwner(), state -> {
if (state.messageRecord == null || state.recipient == null) return;
presentConversationItemTo(state.messageRecord, state.recipient);
presentMessageRequestBottomViewTo(state.recipient);
presentMessageRequestProfileViewTo(state.recipient, state.groups, state.memberCount);
});
}
private void presentConversationItemTo(@NonNull MessageRecord messageRecord, @NonNull Recipient recipient) {
if (messageRecord.isGroupAction()) {
if (conversationItem != null) {
messageView.removeAllViews();
}
return;
}
if (conversationItem == null) {
conversationItem = (ConversationItem) LayoutInflater.from(requireActivity()).inflate(R.layout.conversation_item_received, messageView, false);
}
conversationItem.bind(messageRecord,
Optional.absent(),
Optional.absent(),
GlideApp.with(this),
Locale.getDefault(),
Collections.emptySet(),
recipient,
null,
false);
if (messageView.getChildCount() == 0 || messageView.getChildAt(0) != conversationItem) {
messageView.removeAllViews();
messageView.addView(conversationItem);
}
}
private void presentMessageRequestProfileViewTo(@Nullable Recipient recipient, @Nullable List<String> groups, int memberCount) {
if (recipient != null) {
contactAvatar.setAvatar(GlideApp.with(this), recipient, false);
String title = recipient.getDisplayName(requireContext());
contactTitle.setText(title);
if (recipient.isGroup()) {
contactSubtitle.setText(getString(R.string.MessageRequestProfileView_members, memberCount));
} else {
String subtitle = recipient.getUsername().or(recipient.getE164()).orNull();
if (subtitle == null || subtitle.equals(title)) {
contactSubtitle.setVisibility(View.GONE);
} else {
contactSubtitle.setText(subtitle);
}
}
}
if (groups == null || groups.isEmpty()) {
contactDescription.setVisibility(View.GONE);
} else {
final String description;
switch (groups.size()) {
case 1:
description = getString(R.string.MessageRequestProfileView_member_of_one_group, bold(groups.get(0)));
break;
case 2:
description = getString(R.string.MessageRequestProfileView_member_of_two_groups, bold(groups.get(0)), bold(groups.get(1)));
break;
case 3:
description = getString(R.string.MessageRequestProfileView_member_of_many_groups, bold(groups.get(0)), bold(groups.get(1)), bold(groups.get(2)));
break;
default:
int others = groups.size() - 2;
description = getString(R.string.MessageRequestProfileView_member_of_many_groups,
bold(groups.get(0)),
bold(groups.get(1)),
getResources().getQuantityString(R.plurals.MessageRequestProfileView_member_of_others, others, others));
}
contactDescription.setText(HtmlCompat.fromHtml(description, 0));
contactDescription.setVisibility(View.VISIBLE);
}
}
private @NonNull String bold(@NonNull String target) {
return "<b>" + target + "</b>";
}
private void presentMessageRequestBottomViewTo(@Nullable Recipient recipient) {
if (recipient == null) return;
question.setText(HtmlCompat.fromHtml(getString(R.string.MessageRequestBottomView_do_you_want_to_let, bold(recipient.getDisplayName(requireContext()))), 0));
}
private void initializeBottomViewListeners() {
accept.setOnClickListener(v -> viewModel.accept());
delete.setOnClickListener(v -> viewModel.delete());
block.setOnClickListener(v -> viewModel.block());
}
}

View File

@@ -1,106 +0,0 @@
package org.thoughtcrime.securesms.messagerequests;
import android.content.Context;
import android.database.Cursor;
import androidx.annotation.NonNull;
import androidx.core.util.Consumer;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.MessagingDatabase;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.List;
public class MessageRequestFragmentRepository {
private final Context context;
private final RecipientId recipientId;
private final long threadId;
private final LiveRecipient liveRecipient;
public MessageRequestFragmentRepository(@NonNull Context context, @NonNull RecipientId recipientId, long threadId) {
this.context = context.getApplicationContext();
this.recipientId = recipientId;
this.threadId = threadId;
this.liveRecipient = Recipient.live(recipientId);
}
public LiveRecipient getLiveRecipient() {
return liveRecipient;
}
public void refreshRecipient() {
SignalExecutors.BOUNDED.execute(liveRecipient::refresh);
}
public void getMessageRecord(@NonNull Consumer<MessageRecord> onMessageRecordLoaded) {
SimpleTask.run(() -> {
MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context);
try (Cursor cursor = mmsSmsDatabase.getConversation(threadId, 0, 1)) {
if (!cursor.moveToFirst()) return null;
return mmsSmsDatabase.readerFor(cursor).getCurrent();
}
}, onMessageRecordLoaded::accept);
}
public void getGroups(@NonNull Consumer<List<String>> onGroupsLoaded) {
SimpleTask.run(() -> {
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
return groupDatabase.getGroupNamesContainingMember(recipientId);
}, onGroupsLoaded::accept);
}
public void getMemberCount(@NonNull Consumer<Integer> onMemberCountLoaded) {
SimpleTask.run(() -> {
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
Optional<GroupDatabase.GroupRecord> groupRecord = groupDatabase.getGroup(recipientId);
return groupRecord.transform(record -> record.getMembers().size()).or(0);
}, onMemberCountLoaded::accept);
}
public void acceptMessageRequest(@NonNull Runnable onMessageRequestAccepted) {
SimpleTask.run(() -> {
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
recipientDatabase.setProfileSharing(recipientId, true);
liveRecipient.refresh();
List<MessagingDatabase.MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(context)
.setEntireThreadRead(threadId);
MessageNotifier.updateNotification(context);
MarkReadReceiver.process(context, messageIds);
return null;
}, v -> onMessageRequestAccepted.run());
}
public void deleteMessageRequest(@NonNull Runnable onMessageRequestDeleted) {
SimpleTask.run(() -> {
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
threadDatabase.deleteConversation(threadId);
return null;
}, v -> onMessageRequestDeleted.run());
}
public void blockMessageRequest(@NonNull Runnable onMessageRequestBlocked) {
SimpleTask.run(() -> {
Recipient recipient = liveRecipient.resolve();
RecipientUtil.block(context, recipient);
liveRecipient.refresh();
return null;
}, v -> onMessageRequestBlocked.run());
}
}

View File

@@ -1,90 +0,0 @@
package org.thoughtcrime.securesms.messagerequests;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.List;
public class MessageRequestFragmentState {
public enum MessageRequestState {
LOADING,
PENDING,
BLOCKED,
DELETED,
ACCEPTED
}
public final @NonNull MessageRequestState messageRequestState;
public final @Nullable MessageRecord messageRecord;
public final @Nullable Recipient recipient;
public final @Nullable List<String> groups;
public final int memberCount;
public MessageRequestFragmentState(@NonNull MessageRequestState messageRequestState,
@Nullable MessageRecord messageRecord,
@Nullable Recipient recipient,
@Nullable List<String> groups,
int memberCount)
{
this.messageRequestState = messageRequestState;
this.messageRecord = messageRecord;
this.recipient = recipient;
this.groups = groups;
this.memberCount = memberCount;
}
public @NonNull MessageRequestFragmentState updateMessageRequestState(@NonNull MessageRequestState messageRequestState) {
return new MessageRequestFragmentState(messageRequestState,
this.messageRecord,
this.recipient,
this.groups,
this.memberCount);
}
public @NonNull MessageRequestFragmentState updateMessageRecord(@NonNull MessageRecord messageRecord) {
return new MessageRequestFragmentState(this.messageRequestState,
messageRecord,
this.recipient,
this.groups,
this.memberCount);
}
public @NonNull MessageRequestFragmentState updateRecipient(@NonNull Recipient recipient) {
return new MessageRequestFragmentState(this.messageRequestState,
this.messageRecord,
recipient,
this.groups,
this.memberCount);
}
public @NonNull MessageRequestFragmentState updateGroups(@NonNull List<String> groups) {
return new MessageRequestFragmentState(this.messageRequestState,
this.messageRecord,
this.recipient,
groups,
this.memberCount);
}
public @NonNull MessageRequestFragmentState updateMemberCount(int memberCount) {
return new MessageRequestFragmentState(this.messageRequestState,
this.messageRecord,
this.recipient,
this.groups,
memberCount);
}
@Override
public @NonNull String toString() {
return "MessageRequestFragmentState: [" +
messageRequestState.name() + "] [" +
messageRecord + "] [" +
recipient + "] [" +
groups + "] [" +
memberCount + "]";
}
}

View File

@@ -1,139 +0,0 @@
package org.thoughtcrime.securesms.messagerequests;
import android.content.Context;
import android.util.Log;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.arch.core.util.Function;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.recipients.RecipientId;
public class MessageRequestFragmentViewModel extends ViewModel {
private static final String TAG = MessageRequestFragmentViewModel.class.getSimpleName();
private final MutableLiveData<MessageRequestFragmentState> internalState = new MutableLiveData<>();
private final MessageRequestFragmentRepository repository;
@SuppressWarnings("CodeBlock2Expr")
private final RecipientForeverObserver recipientObserver = recipient -> {
updateState(getNewState(s -> s.updateRecipient(recipient)));
};
private MessageRequestFragmentViewModel(@NonNull MessageRequestFragmentRepository repository) {
internalState.setValue(new MessageRequestFragmentState(MessageRequestFragmentState.MessageRequestState.LOADING, null, null, null, 0));
this.repository = repository;
loadRecipient();
loadMessageRecord();
loadGroups();
loadMemberCount();
}
@Override
protected void onCleared() {
repository.getLiveRecipient().removeForeverObserver(recipientObserver);
}
public @NonNull LiveData<MessageRequestFragmentState> getState() {
return internalState;
}
@MainThread
public void accept() {
repository.acceptMessageRequest(() -> {
MessageRequestFragmentState state = internalState.getValue();
updateState(state.updateMessageRequestState(MessageRequestFragmentState.MessageRequestState.ACCEPTED));
});
}
@MainThread
public void delete() {
repository.deleteMessageRequest(() -> {
MessageRequestFragmentState state = internalState.getValue();
updateState(state.updateMessageRequestState(MessageRequestFragmentState.MessageRequestState.DELETED));
});
}
@MainThread
public void block() {
repository.blockMessageRequest(() -> {
MessageRequestFragmentState state = internalState.getValue();
updateState(state.updateMessageRequestState(MessageRequestFragmentState.MessageRequestState.BLOCKED));
});
}
private void updateState(@NonNull MessageRequestFragmentState newState) {
Log.i(TAG, "updateState: " + newState);
internalState.setValue(newState);
}
private void loadRecipient() {
repository.getLiveRecipient().observeForever(recipientObserver);
repository.refreshRecipient();
}
private void loadMessageRecord() {
repository.getMessageRecord(messageRecord -> {
MessageRequestFragmentState newState = getNewState(s -> s.updateMessageRecord(messageRecord));
updateState(newState);
});
}
private void loadGroups() {
repository.getGroups(groups -> {
MessageRequestFragmentState newState = getNewState(s -> s.updateGroups(groups));
updateState(newState);
});
}
private void loadMemberCount() {
repository.getMemberCount(memberCount -> {
MessageRequestFragmentState newState = getNewState(s -> s.updateMemberCount(memberCount == null ? 0 : memberCount));
updateState(newState);
});
}
private @NonNull MessageRequestFragmentState getNewState(@NonNull Function<MessageRequestFragmentState, MessageRequestFragmentState> stateTransformer) {
MessageRequestFragmentState oldState = internalState.getValue();
MessageRequestFragmentState newState = stateTransformer.apply(oldState);
return newState.updateMessageRequestState(getUpdatedRequestState(newState));
}
private static @NonNull MessageRequestFragmentState.MessageRequestState getUpdatedRequestState(@NonNull MessageRequestFragmentState state) {
if (state.messageRequestState != MessageRequestFragmentState.MessageRequestState.LOADING) {
return state.messageRequestState;
}
if (state.messageRecord != null && state.recipient != null && state.groups != null) {
return MessageRequestFragmentState.MessageRequestState.PENDING;
}
return MessageRequestFragmentState.MessageRequestState.LOADING;
}
public static class Factory implements ViewModelProvider.Factory {
private final Context context;
private final long threadId;
private final RecipientId recipientId;
public Factory(@NonNull Context context, long threadId, @NonNull RecipientId recipientId) {
this.context = context;
this.threadId = threadId;
this.recipientId = recipientId;
}
@SuppressWarnings("unchecked")
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return (T) new MessageRequestFragmentViewModel(new MessageRequestFragmentRepository(context, recipientId, threadId));
}
}
}

View File

@@ -0,0 +1,73 @@
package org.thoughtcrime.securesms.messagerequests;
import android.content.Intent;
import android.os.Bundle;
import android.widget.TextView;
import androidx.annotation.Nullable;
import com.airbnb.lottie.LottieAnimationView;
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.megaphone.Megaphones;
import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
public class MessageRequestMegaphoneActivity extends PassphraseRequiredActionBarActivity {
public static final short EDIT_PROFILE_REQUEST_CODE = 24563;
private DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
@Override
public void onCreate(@Nullable Bundle savedInstanceState, boolean isReady) {
dynamicTheme.onCreate(this);
setContentView(R.layout.message_requests_megaphone_activity);
LottieAnimationView lottie = findViewById(R.id.message_requests_lottie);
TextView profileNameButton = findViewById(R.id.message_requests_confirm_profile_name);
lottie.setAnimation(R.raw.lottie_message_requests_splash);
lottie.playAnimation();
profileNameButton.setOnClickListener(v -> {
final Intent profile = new Intent(this, EditProfileActivity.class);
profile.putExtra(EditProfileActivity.SHOW_TOOLBAR, false);
profile.putExtra(EditProfileActivity.NEXT_BUTTON_TEXT, R.string.save);
startActivityForResult(profile, EDIT_PROFILE_REQUEST_CODE);
});
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == EDIT_PROFILE_REQUEST_CODE &&
resultCode == RESULT_OK &&
TextSecurePreferences.getProfileName(this) != ProfileName.EMPTY) {
ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.MESSAGE_REQUESTS);
setResult(RESULT_OK);
finish();
}
}
@Override
public void onBackPressed() {
}
@Override
protected void onResume() {
super.onResume();
dynamicTheme.onResume(this);
}
}

View File

@@ -0,0 +1,156 @@
package org.thoughtcrime.securesms.messagerequests;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.core.util.Consumer;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.MessagingDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.MultiDeviceMessageRequestResponseJob;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.List;
import java.util.concurrent.Executor;
public class MessageRequestRepository {
private final Context context;
private final Executor executor;
MessageRequestRepository(@NonNull Context context) {
this.context = context.getApplicationContext();
this.executor = SignalExecutors.BOUNDED;
}
void getGroups(@NonNull RecipientId recipientId, @NonNull Consumer<List<String>> onGroupsLoaded) {
executor.execute(() -> {
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
onGroupsLoaded.accept(groupDatabase.getGroupNamesContainingMember(recipientId));
});
}
void getMemberCount(@NonNull RecipientId recipientId, @NonNull Consumer<Integer> onMemberCountLoaded) {
executor.execute(() -> {
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
Optional<GroupDatabase.GroupRecord> groupRecord = groupDatabase.getGroup(recipientId);
onMemberCountLoaded.accept(groupRecord.transform(record -> record.getMembers().size()).or(0));
});
}
void getMessageRequestState(@NonNull Recipient recipient, long threadId, @NonNull Consumer<MessageRequestState> state) {
executor.execute(() -> {
if (!RecipientUtil.isMessageRequestAccepted(context, threadId)) {
state.accept(MessageRequestState.UNACCEPTED);
} else if (RecipientUtil.isPreMessageRequestThread(context, threadId) && !RecipientUtil.isLegacyProfileSharingAccepted(recipient)) {
state.accept(MessageRequestState.LEGACY);
} else {
state.accept(MessageRequestState.ACCEPTED);
}
});
}
void acceptMessageRequest(@NonNull LiveRecipient liveRecipient, long threadId, @NonNull Runnable onMessageRequestAccepted) {
executor.execute(()-> {
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
recipientDatabase.setProfileSharing(liveRecipient.getId(), true);
liveRecipient.refresh();
List<MessagingDatabase.MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(context)
.setEntireThreadRead(threadId);
MessageNotifier.updateNotification(context);
MarkReadReceiver.process(context, messageIds);
if (TextSecurePreferences.isMultiDevice(context)) {
ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forAccept(liveRecipient.getId()));
}
onMessageRequestAccepted.run();
});
}
void deleteMessageRequest(@NonNull LiveRecipient recipient, long threadId, @NonNull Runnable onMessageRequestDeleted) {
executor.execute(() -> {
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
threadDatabase.deleteConversation(threadId);
if (recipient.resolve().isGroup()) {
RecipientUtil.leaveGroup(context, recipient.get());
}
if (TextSecurePreferences.isMultiDevice(context)) {
ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forDelete(recipient.getId()));
}
onMessageRequestDeleted.run();
});
}
void blockMessageRequest(@NonNull LiveRecipient liveRecipient, @NonNull Runnable onMessageRequestBlocked) {
executor.execute(() -> {
Recipient recipient = liveRecipient.resolve();
RecipientUtil.block(context, recipient);
liveRecipient.refresh();
if (TextSecurePreferences.isMultiDevice(context)) {
ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forBlock(liveRecipient.getId()));
}
onMessageRequestBlocked.run();
});
}
void blockAndDeleteMessageRequest(@NonNull LiveRecipient liveRecipient, long threadId, @NonNull Runnable onMessageRequestBlocked) {
executor.execute(() -> {
Recipient recipient = liveRecipient.resolve();
RecipientUtil.block(context, recipient);
liveRecipient.refresh();
DatabaseFactory.getThreadDatabase(context).deleteConversation(threadId);
if (TextSecurePreferences.isMultiDevice(context)) {
ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forBlockAndDelete(liveRecipient.getId()));
}
onMessageRequestBlocked.run();
});
}
void unblockAndAccept(@NonNull LiveRecipient liveRecipient, long threadId, @NonNull Runnable onMessageRequestUnblocked) {
executor.execute(() -> {
Recipient recipient = liveRecipient.resolve();
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
RecipientUtil.unblock(context, recipient);
recipientDatabase.setProfileSharing(liveRecipient.getId(), true);
liveRecipient.refresh();
List<MessagingDatabase.MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(context)
.setEntireThreadRead(threadId);
MessageNotifier.updateNotification(context);
MarkReadReceiver.process(context, messageIds);
if (TextSecurePreferences.isMultiDevice(context)) {
ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forAccept(liveRecipient.getId()));
}
onMessageRequestUnblocked.run();
});
}
enum MessageRequestState {
ACCEPTED, UNACCEPTED, LEGACY
}
}

View File

@@ -0,0 +1,214 @@
package org.thoughtcrime.securesms.messagerequests;
import android.content.Context;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.livedata.LiveDataTriple;
import java.util.Collections;
import java.util.List;
public class MessageRequestViewModel extends ViewModel {
private final SingleLiveEvent<Status> status = new SingleLiveEvent<>();
private final MutableLiveData<Recipient> recipient = new MutableLiveData<>();
private final MutableLiveData<List<String>> groups = new MutableLiveData<>(Collections.emptyList());
private final MutableLiveData<Integer> memberCount = new MutableLiveData<>(0);
private final MutableLiveData<DisplayState> displayState = new MutableLiveData<>();
private final LiveData<RecipientInfo> recipientInfo = Transformations.map(new LiveDataTriple<>(recipient, memberCount, groups),
triple -> new RecipientInfo(triple.first(), triple.second(), triple.third()));
private final MessageRequestRepository repository;
private LiveRecipient liveRecipient;
private long threadId;
@SuppressWarnings("CodeBlock2Expr")
private final RecipientForeverObserver recipientObserver = recipient -> {
loadMessageRequestAccepted(recipient);
this.recipient.setValue(recipient);
};
private MessageRequestViewModel(MessageRequestRepository repository) {
this.repository = repository;
}
public void setConversationInfo(@NonNull RecipientId recipientId, long threadId) {
if (liveRecipient != null) {
liveRecipient.removeForeverObserver(recipientObserver);
}
liveRecipient = Recipient.live(recipientId);
this.threadId = threadId;
loadRecipient();
loadGroups();
loadMemberCount();
}
@Override
protected void onCleared() {
if (liveRecipient != null) {
liveRecipient.removeForeverObserver(recipientObserver);
}
}
public LiveData<DisplayState> getMessageRequestDisplayState() {
return displayState;
}
public LiveData<Recipient> getRecipient() {
return recipient;
}
public LiveData<RecipientInfo> getRecipientInfo() {
return recipientInfo;
}
public LiveData<Status> getMessageRequestStatus() {
return status;
}
public boolean shouldShowMessageRequest() {
return displayState.getValue() == DisplayState.DISPLAY_MESSAGE_REQUEST;
}
@MainThread
public void onAccept() {
repository.acceptMessageRequest(liveRecipient, threadId, () -> {
status.postValue(Status.ACCEPTED);
});
}
@MainThread
public void onDelete() {
repository.deleteMessageRequest(liveRecipient, threadId, () -> {
status.postValue(Status.DELETED);
});
}
@MainThread
public void onBlock() {
repository.blockMessageRequest(liveRecipient, () -> {
status.postValue(Status.BLOCKED);
});
}
@MainThread
public void onUnblock() {
repository.unblockAndAccept(liveRecipient, threadId, () -> {
status.postValue(Status.ACCEPTED);
});
}
@MainThread
public void onBlockAndDelete() {
repository.blockAndDeleteMessageRequest(liveRecipient, threadId, () -> {
status.postValue(Status.BLOCKED);
});
}
private void loadRecipient() {
liveRecipient.observeForever(recipientObserver);
SignalExecutors.BOUNDED.execute(liveRecipient::refresh);
}
private void loadGroups() {
repository.getGroups(liveRecipient.getId(), this.groups::postValue);
}
private void loadMemberCount() {
repository.getMemberCount(liveRecipient.getId(), memberCount -> {
this.memberCount.postValue(memberCount == null ? 0 : memberCount);
});
}
@SuppressWarnings("ConstantConditions")
private void loadMessageRequestAccepted(@NonNull Recipient recipient) {
if (FeatureFlags.messageRequests() && recipient.isBlocked()) {
displayState.postValue(DisplayState.DISPLAY_MESSAGE_REQUEST);
return;
}
repository.getMessageRequestState(recipient, threadId, accepted -> {
switch (accepted) {
case ACCEPTED:
displayState.postValue(DisplayState.DISPLAY_NONE);
break;
case UNACCEPTED:
displayState.postValue(DisplayState.DISPLAY_MESSAGE_REQUEST);
break;
case LEGACY:
displayState.postValue(DisplayState.DISPLAY_LEGACY);
break;
}
});
}
public static class RecipientInfo {
private final @Nullable Recipient recipient;
private final int groupMemberCount;
private final @NonNull List<String> sharedGroups;
private RecipientInfo(@Nullable Recipient recipient, @Nullable Integer groupMemberCount, @Nullable List<String> sharedGroups) {
this.recipient = recipient;
this.groupMemberCount = groupMemberCount == null ? 0 : groupMemberCount;
this.sharedGroups = sharedGroups == null ? Collections.emptyList() : sharedGroups;
}
@Nullable
public Recipient getRecipient() {
return recipient;
}
public int getGroupMemberCount() {
return groupMemberCount;
}
@NonNull
public List<String> getSharedGroups() {
return sharedGroups;
}
}
public enum Status {
BLOCKED,
DELETED,
ACCEPTED
}
public enum DisplayState {
DISPLAY_MESSAGE_REQUEST, DISPLAY_LEGACY, DISPLAY_NONE
}
public static class Factory implements ViewModelProvider.Factory {
private final Context context;
public Factory(Context context) {
this.context = context;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection unchecked
return (T) new MessageRequestViewModel(new MessageRequestRepository(context.getApplicationContext()));
}
}
}

View File

@@ -0,0 +1,93 @@
package org.thoughtcrime.securesms.messagerequests;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.Group;
import androidx.core.text.HtmlCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.HtmlUtil;
public class MessageRequestsBottomView extends ConstraintLayout {
private TextView question;
private View accept;
private View block;
private View delete;
private View bigDelete;
private View bigUnblock;
private Group normalButtons;
private Group blockedButtons;
public MessageRequestsBottomView(Context context) {
super(context);
}
public MessageRequestsBottomView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public MessageRequestsBottomView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
inflate(getContext(), R.layout.message_request_bottom_bar, this);
question = findViewById(R.id.message_request_question);
accept = findViewById(R.id.message_request_accept);
block = findViewById(R.id.message_request_block);
delete = findViewById(R.id.message_request_delete);
bigDelete = findViewById(R.id.message_request_big_delete);
bigUnblock = findViewById(R.id.message_request_big_unblock);
normalButtons = findViewById(R.id.message_request_normal_buttons);
blockedButtons = findViewById(R.id.message_request_blocked_buttons);
}
public void setRecipient(@NonNull Recipient recipient) {
if (recipient.isBlocked()) {
if (recipient.isGroup()) {
question.setText(R.string.MessageRequestBottomView_unblock_to_allow_group_members_to_add_you_to_this_group_again);
} else {
question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_unblock_s_to_message_and_call_each_other, HtmlUtil.bold(recipient.getDisplayName(getContext()))), 0));
}
normalButtons.setVisibility(GONE);
blockedButtons.setVisibility(VISIBLE);
} else {
if (recipient.isGroup()) {
question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_join_the_group_s_they_wont_know_youve_seen_their_messages_until_you_accept, HtmlUtil.bold(recipient.getDisplayName(getContext()))), 0));
} else {
question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_they_wont_know_youve_seen_their_messages_until_you_accept, HtmlUtil.bold(recipient.getDisplayName(getContext()))), 0));
}
normalButtons.setVisibility(VISIBLE);
blockedButtons.setVisibility(GONE);
}
}
public void setAcceptOnClickListener(OnClickListener acceptOnClickListener) {
accept.setOnClickListener(acceptOnClickListener);
}
public void setDeleteOnClickListener(OnClickListener deleteOnClickListener) {
delete.setOnClickListener(deleteOnClickListener);
bigDelete.setOnClickListener(deleteOnClickListener);
}
public void setBlockOnClickListener(OnClickListener blockOnClickListener) {
block.setOnClickListener(blockOnClickListener);
}
public void setUnblockOnClickListener(OnClickListener unblockOnClickListener) {
bigUnblock.setOnClickListener(unblockOnClickListener);
}
}

View File

@@ -40,7 +40,7 @@ public class ApplicationMigrations {
private static final int LEGACY_CANONICAL_VERSION = 455;
public static final int CURRENT_VERSION = 10;
public static final int CURRENT_VERSION = 12;
private static final class Version {
static final int LEGACY = 1;
@@ -53,6 +53,8 @@ public class ApplicationMigrations {
static final int STICKERS_LAUNCH = 8;
static final int TEST_ARGON2 = 9;
static final int SWOON_STICKERS = 10;
static final int STORAGE_SERVICE = 11;
static final int STORAGE_KEY_ROTATE = 12;
}
/**
@@ -205,6 +207,14 @@ public class ApplicationMigrations {
jobs.put(Version.SWOON_STICKERS, new StickerAdditionMigrationJob(BlessedPacks.SWOON_HANDS, BlessedPacks.SWOON_FACES));
}
if (lastSeenVersion < Version.STORAGE_SERVICE) {
jobs.put(Version.STORAGE_SERVICE, new StorageServiceMigrationJob());
}
if (lastSeenVersion < Version.STORAGE_KEY_ROTATE) {
jobs.put(Version.STORAGE_KEY_ROTATE, new StorageKeyRotationMigrationJob());
}
return jobs;
}

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