mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-16 15:03:17 +01:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82305ce2b3 | ||
|
|
eaf73edcad | ||
|
|
543a4ee177 | ||
|
|
fd2a464bae | ||
|
|
b06152ba58 | ||
|
|
c24d285cd3 | ||
|
|
be39cd653e | ||
|
|
6813f47bc1 | ||
|
|
8e795c4177 | ||
|
|
9c96afee09 | ||
|
|
d507be0ab0 | ||
|
|
75a52f801a | ||
|
|
d3b123f3a9 | ||
|
|
da3cdd984b | ||
|
|
6184e5f828 | ||
|
|
133bd44b85 | ||
|
|
0c254c9621 | ||
|
|
1faf196f82 | ||
|
|
81c7887d47 | ||
|
|
e62e630987 | ||
|
|
739e38a047 | ||
|
|
8c23b17517 | ||
|
|
fda8f3e1ce | ||
|
|
9e5f64c431 | ||
|
|
dc689d325b | ||
|
|
0a883dc234 | ||
|
|
3824e90997 | ||
|
|
5158a15379 | ||
|
|
1bae79af5b | ||
|
|
58b7612987 | ||
|
|
9506da6dd3 |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)), ",");
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ public class Megaphone {
|
||||
}
|
||||
|
||||
enum Style {
|
||||
REACTIONS, BASIC, FULLSCREEN
|
||||
REACTIONS, BASIC, FULLSCREEN, POPUP
|
||||
}
|
||||
|
||||
public interface EventListener {
|
||||
|
||||
@@ -50,6 +50,7 @@ public class MegaphoneRepository {
|
||||
public void onFirstEverAppLaunch() {
|
||||
executor.execute(() -> {
|
||||
database.markFinished(Event.REACTIONS);
|
||||
database.markFinished(Event.MESSAGE_REQUESTS);
|
||||
resetDatabaseCache();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 + "]";
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user