diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b47ac43c6..0b2b98b85 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,6 +1,11 @@ name: Android CI -on: [push, pull_request] +on: + pull_request: + push: + branches: + - 'master' + - '4.**' jobs: build: diff --git a/app/build.gradle b/app/build.gradle index 76dd51f74..4ef2a8dba 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -80,8 +80,8 @@ protobuf { } } -def canonicalVersionCode = 613 -def canonicalVersionName = "4.57.2" +def canonicalVersionCode = 614 +def canonicalVersionName = "4.58.0" def postFixSize = 10 def abiPostFix = ['universal' : 0, @@ -160,6 +160,7 @@ android { exclude 'META-INF/LICENSE' exclude 'META-INF/NOTICE' exclude 'META-INF/proguard/androidx-annotations.pro' + exclude 'lib/*/libzkgroup.so' // TODO: GV2 Remove line to include .so when used } buildTypes { @@ -268,8 +269,10 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:1.0.0-alpha05' implementation 'androidx.lifecycle:lifecycle-common-java8:2.1.0' - implementation "androidx.camera:camera-core:1.0.0-alpha06" - implementation "androidx.camera:camera-camera2:1.0.0-alpha06" + implementation "androidx.camera:camera-core:1.0.0-beta01" + implementation "androidx.camera:camera-camera2:1.0.0-beta01" + implementation "androidx.camera:camera-lifecycle:1.0.0-beta01" + implementation "androidx.concurrent:concurrent-futures:1.0.0" implementation('com.google.firebase:firebase-messaging:17.3.4') { exclude group: 'com.google.firebase', module: 'firebase-core' @@ -287,10 +290,11 @@ dependencies { implementation 'org.signal:aesgcmprovider:0.0.3' implementation project(':libsignal-service') + implementation 'org.signal:zkgroup-android:0.4.1' implementation 'org.signal:argon2:13.1@aar' - implementation 'org.signal:ringrtc-android:1.0.2' + implementation 'org.signal:ringrtc-android:1.1.0' implementation "me.leolin:ShortcutBadger:1.1.16" implementation 'se.emilsjolander:stickylistheaders:2.7.0' @@ -350,7 +354,9 @@ dependencies { testImplementation 'org.powermock:powermock-classloading-xstream:1.6.1' testImplementation 'androidx.test:core:1.2.0' - testImplementation 'org.robolectric:robolectric:4.2' + testImplementation ('org.robolectric:robolectric:4.2') { + exclude group: 'com.google.protobuf', module: 'protobuf-java' + } testImplementation 'org.robolectric:shadows-multidex:4.2' androidTestImplementation 'androidx.test.ext:junit:1.1.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1b6f8620b..519568c40 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ xmlns:tools="http://schemas.android.com/tools" package="org.thoughtcrime.securesms"> - + - - - - - - - - - - - - - - diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 29056202d..6ded0a044 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -19,7 +19,7 @@ package org.thoughtcrime.securesms; import android.annotation.SuppressLint; import androidx.appcompat.app.AppCompatDelegate; -import androidx.camera.camera2.Camera2AppConfig; +import androidx.camera.camera2.Camera2Config; import androidx.camera.core.CameraX; import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; @@ -71,6 +71,7 @@ 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.storage.StorageSyncHelper; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; @@ -136,7 +137,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi FeatureFlags.init(); NotificationChannels.create(this); RefreshPreKeysJob.scheduleIfNecessary(); - StorageSyncJob.scheduleIfNecessary(); + StorageSyncHelper.scheduleRoutineSync(); ProcessLifecycleOwner.get().getLifecycle().addObserver(this); if (Build.VERSION.SDK_INT < 21) { @@ -385,7 +386,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi if (CameraXUtil.isSupported()) { new Thread(() -> { try { - CameraX.init(this, Camera2AppConfig.create(this)); + CameraX.initialize(this, Camera2Config.defaultConfig()); } catch (Throwable t) { Log.w(TAG, "Failed to initialize CameraX."); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/BasicIntroFragment.java b/app/src/main/java/org/thoughtcrime/securesms/BasicIntroFragment.java deleted file mode 100644 index d73a67802..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/BasicIntroFragment.java +++ /dev/null @@ -1,55 +0,0 @@ -package org.thoughtcrime.securesms; - -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -public class BasicIntroFragment extends Fragment { - - private static final String ARG_DRAWABLE = "drawable"; - private static final String ARG_TEXT = "text"; - private static final String ARG_SUBTEXT = "subtext"; - - private int drawable; - private int text; - private int subtext; - - public static BasicIntroFragment newInstance(int drawable, int text, int subtext) { - BasicIntroFragment fragment = new BasicIntroFragment(); - Bundle args = new Bundle(); - args.putInt(ARG_DRAWABLE, drawable); - args.putInt(ARG_TEXT, text); - args.putInt(ARG_SUBTEXT, subtext); - fragment.setArguments(args); - return fragment; - } - - public BasicIntroFragment() {} - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (getArguments() != null) { - drawable = getArguments().getInt(ARG_DRAWABLE); - text = getArguments().getInt(ARG_TEXT); - subtext = getArguments().getInt(ARG_SUBTEXT); - } - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - View v = inflater.inflate(R.layout.color_fragment, container, false); - - ((ImageView)v.findViewById(R.id.watermark)).setImageResource(drawable); - ((TextView)v.findViewById(R.id.blurb)).setText(text); - ((TextView)v.findViewById(R.id.subblurb)).setText(subtext); - - return v; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ExperienceUpgradeActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ExperienceUpgradeActivity.java deleted file mode 100644 index 71f412d8d..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/ExperienceUpgradeActivity.java +++ /dev/null @@ -1,322 +0,0 @@ -package org.thoughtcrime.securesms; - -import android.app.Notification; -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.graphics.drawable.ColorDrawable; -import android.os.Bundle; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.core.app.NotificationCompat; -import androidx.viewpager.widget.ViewPager; - -import com.melnykov.fab.FloatingActionButton; - -import org.thoughtcrime.securesms.IntroPagerAdapter.IntroPage; -import org.thoughtcrime.securesms.experienceupgrades.StickersIntroFragment; -import org.thoughtcrime.securesms.logging.Log; -import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.notifications.NotificationIds; -import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; -import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; -import org.thoughtcrime.securesms.util.DynamicTheme; -import org.thoughtcrime.securesms.util.ServiceUtil; -import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.util.Util; -import org.thoughtcrime.securesms.util.ViewUtil; -import org.whispersystems.libsignal.util.guava.Optional; - -import java.util.Collections; -import java.util.List; - -public class ExperienceUpgradeActivity extends BaseActionBarActivity - implements TypingIndicatorIntroFragment.Controller, - LinkPreviewsIntroFragment.Controller, - StickersIntroFragment.Controller -{ - private static final String TAG = ExperienceUpgradeActivity.class.getSimpleName(); - private static final String DISMISS_ACTION = "org.thoughtcrime.securesms.ExperienceUpgradeActivity.DISMISS_ACTION"; - - private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); - - private enum ExperienceUpgrade { - SIGNAL_REBRANDING(157, - new IntroPage(0xFF2090EA, - BasicIntroFragment.newInstance(R.drawable.splash_logo, - R.string.ExperienceUpgradeActivity_welcome_to_signal_dgaf, - R.string.ExperienceUpgradeActivity_textsecure_is_now_called_signal)), - R.string.ExperienceUpgradeActivity_welcome_to_signal_excited, - R.string.ExperienceUpgradeActivity_textsecure_is_now_signal, - R.string.ExperienceUpgradeActivity_textsecure_is_now_signal_long, - null, - false), - VIDEO_CALLS(245, - new IntroPage(0xFF2090EA, - BasicIntroFragment.newInstance(R.drawable.video_splash, - R.string.ExperienceUpgradeActivity_say_hello_to_video_calls, - R.string.ExperienceUpgradeActivity_signal_now_supports_secure_video_calls)), - R.string.ExperienceUpgradeActivity_say_hello_to_video_calls, - R.string.ExperienceUpgradeActivity_signal_now_supports_secure_video_calling, - R.string.ExperienceUpgradeActivity_signal_now_supports_secure_video_calling_long, - null, - false), - PROFILES(286, - new IntroPage(0xFF2090EA, - BasicIntroFragment.newInstance(R.drawable.profile_splash, - R.string.ExperienceUpgradeActivity_ready_for_your_closeup, - R.string.ExperienceUpgradeActivity_now_you_can_share_a_profile_photo_and_name_with_friends_on_signal)), - R.string.ExperienceUpgradeActivity_signal_profiles_are_here, - R.string.ExperienceUpgradeActivity_now_you_can_share_a_profile_photo_and_name_with_friends_on_signal, - R.string.ExperienceUpgradeActivity_now_you_can_share_a_profile_photo_and_name_with_friends_on_signal, - EditProfileActivity.class, - false), - READ_RECEIPTS(299, - new IntroPage(0xFF2090EA, - ReadReceiptsIntroFragment.newInstance()), - R.string.experience_upgrade_preference_fragment__read_receipts_are_here, - R.string.experience_upgrade_preference_fragment__optionally_see_and_share_when_messages_have_been_read, - R.string.experience_upgrade_preference_fragment__optionally_see_and_share_when_messages_have_been_read, - null, - false), - TYPING_INDICATORS(432, - new IntroPage(0xFF2090EA, - TypingIndicatorIntroFragment.newInstance()), - R.string.ExperienceUpgradeActivity_introducing_typing_indicators, - R.string.ExperienceUpgradeActivity_now_you_can_optionally_see_and_share_when_messages_are_being_typed, - R.string.ExperienceUpgradeActivity_now_you_can_optionally_see_and_share_when_messages_are_being_typed, - null, - true), - LINK_PREVIEWS(449, - new IntroPage(0xFF2090EA, LinkPreviewsIntroFragment.newInstance()), - R.string.ExperienceUpgradeActivity_introducing_link_previews, - R.string.ExperienceUpgradeActivity_optional_link_previews_are_now_supported, - R.string.ExperienceUpgradeActivity_optional_link_previews_are_now_supported, - null, - true), - STICKERS(580, - new IntroPage(0xFF2090EA, StickersIntroFragment.newInstance()), - R.string.ExperienceUpgradeActivity_introducing_stickers, - R.string.ExperienceUpgradeActivity_why_use_words_when_you_can_use_stickers, - R.string.ExperienceUpgradeActivity_why_use_words_when_you_can_use_stickers, - null, - true); - - private int version; - private List pages; - private @StringRes int notificationTitle; - private @StringRes int notificationText; - private @StringRes int notificationBigText; - private @Nullable Class nextIntent; - private boolean handlesNavigation; - - ExperienceUpgrade(int version, - @NonNull List pages, - @StringRes int notificationTitle, - @StringRes int notificationText, - @StringRes int notificationBigText, - @Nullable Class nextIntent, - boolean handlesNavigation) - { - this.version = version; - this.pages = pages; - this.notificationTitle = notificationTitle; - this.notificationText = notificationText; - this.notificationBigText = notificationBigText; - this.nextIntent = nextIntent; - this.handlesNavigation = handlesNavigation; - } - - ExperienceUpgrade(int version, - @NonNull IntroPage page, - @StringRes int notificationTitle, - @StringRes int notificationText, - @StringRes int notificationBigText, - @Nullable Class nextIntent, - boolean handlesNavigation) - { - this(version, Collections.singletonList(page), notificationTitle, notificationText, notificationBigText, nextIntent, handlesNavigation); - } - - public int getVersion() { - return version; - } - - public List getPages() { - return pages; - } - - public IntroPage getPage(int i) { - return pages.get(i); - } - - public int getNotificationTitle() { - return notificationTitle; - } - - public int getNotificationText() { - return notificationText; - } - - public int getNotificationBigText() { - return notificationBigText; - } - - public boolean handlesNavigation() { - return handlesNavigation; - } - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - dynamicTheme.onCreate(this); - - final Optional upgrade = getExperienceUpgrade(this); - if (!upgrade.isPresent()) { - onContinue(upgrade); - return; - } - - setContentView(R.layout.experience_upgrade_activity); - final ViewPager pager = ViewUtil.findById(this, R.id.pager); - final FloatingActionButton fab = ViewUtil.findById(this, R.id.fab); - - pager.setAdapter(new IntroPagerAdapter(getSupportFragmentManager(), upgrade.get().getPages())); - - if (upgrade.get().handlesNavigation()) { - fab.setVisibility(View.GONE); - } else { - fab.setVisibility(View.VISIBLE); - fab.setOnClickListener(v -> onContinue(upgrade)); - } - - getWindow().setBackgroundDrawable(new ColorDrawable(upgrade.get().getPage(0).backgroundColor)); - ServiceUtil.getNotificationManager(this).cancel(NotificationIds.EXPERIENCE_UPGRADE); - } - - @Override - protected void onResume() { - super.onResume(); - dynamicTheme.onResume(this); - } - - private void onContinue(Optional seenUpgrade) { - ServiceUtil.getNotificationManager(this).cancel(NotificationIds.EXPERIENCE_UPGRADE); - int latestVersion = seenUpgrade.isPresent() ? seenUpgrade.get().getVersion() - : Util.getCanonicalVersionCode(); - TextSecurePreferences.setLastExperienceVersionCode(this, latestVersion); - if (seenUpgrade.isPresent() && seenUpgrade.get().nextIntent != null) { - Intent intent = new Intent(this, seenUpgrade.get().nextIntent); - // TODO [greyson] Navigation - Intent nextIntent = new Intent(this, MainActivity.class); - intent.putExtra("next_intent", nextIntent); - startActivity(intent); - } else { - startActivity(getIntent().getParcelableExtra("next_intent")); - } - - finish(); - } - - public static boolean isUpdate(Context context) { - return getExperienceUpgrade(context).isPresent(); - } - - public static Optional getExperienceUpgrade(Context context) { - final int currentVersionCode = Util.getCanonicalVersionCode(); - final int lastSeenVersion = TextSecurePreferences.getLastExperienceVersionCode(context); - Log.i(TAG, "getExperienceUpgrade(" + lastSeenVersion + ")"); - - if (lastSeenVersion >= currentVersionCode) { - TextSecurePreferences.setLastExperienceVersionCode(context, currentVersionCode); - return Optional.absent(); - } - - Optional eligibleUpgrade = Optional.absent(); - for (ExperienceUpgrade upgrade : ExperienceUpgrade.values()) { - if (lastSeenVersion < upgrade.getVersion()) eligibleUpgrade = Optional.of(upgrade); - } - - return eligibleUpgrade; - } - - @Override - public void onTypingIndicatorsFinished() { - onContinue(Optional.of(ExperienceUpgrade.TYPING_INDICATORS)); - } - - @Override - public void onLinkPreviewsFinished() { - onContinue(Optional.of(ExperienceUpgrade.LINK_PREVIEWS)); - } - - @Override - public void onStickersFinished() { - onContinue(Optional.of(ExperienceUpgrade.STICKERS)); - } - - public static class AppUpgradeReceiver extends BroadcastReceiver { - @Override - public void onReceive(Context context, Intent intent) { - if (Intent.ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction()) && - intent.getData().getSchemeSpecificPart().equals(context.getPackageName())) - { - if (TextSecurePreferences.getLastExperienceVersionCode(context) < 339 && - !TextSecurePreferences.isPasswordDisabled(context)) - { - Notification notification = new NotificationCompat.Builder(context, NotificationChannels.OTHER) - .setSmallIcon(R.drawable.icon_notification) - .setColor(context.getResources().getColor(R.color.signal_primary)) - .setContentTitle(context.getString(R.string.ExperienceUpgradeActivity_unlock_to_complete_update)) - .setContentText(context.getString(R.string.ExperienceUpgradeActivity_please_unlock_signal_to_complete_update)) - .setStyle(new NotificationCompat.BigTextStyle().bigText(context.getString(R.string.ExperienceUpgradeActivity_please_unlock_signal_to_complete_update))) - .setAutoCancel(true) - .setContentIntent(PendingIntent.getActivity(context, 0, - context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()), - PendingIntent.FLAG_UPDATE_CURRENT)) - .build(); - - ServiceUtil.getNotificationManager(context).notify(NotificationIds.EXPERIENCE_UPGRADE, notification); - } - - Optional experienceUpgrade = getExperienceUpgrade(context); - - if (!experienceUpgrade.isPresent()) { - return; - } - - if (experienceUpgrade.get().getVersion() == TextSecurePreferences.getExperienceDismissedVersionCode(context)) { - return; - } - - Intent targetIntent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()); - Intent dismissIntent = new Intent(context, AppUpgradeReceiver.class); - dismissIntent.setAction(DISMISS_ACTION); - - Notification notification = new NotificationCompat.Builder(context, NotificationChannels.OTHER) - .setSmallIcon(R.drawable.icon_notification) - .setColor(context.getResources().getColor(R.color.signal_primary)) - .setContentTitle(context.getString(experienceUpgrade.get().getNotificationTitle())) - .setContentText(context.getString(experienceUpgrade.get().getNotificationText())) - .setStyle(new NotificationCompat.BigTextStyle().bigText(context.getString(experienceUpgrade.get().getNotificationBigText()))) - .setAutoCancel(true) - .setContentIntent(PendingIntent.getActivity(context, 0, - targetIntent, - PendingIntent.FLAG_UPDATE_CURRENT)) - - .setDeleteIntent(PendingIntent.getBroadcast(context, 0, - dismissIntent, - PendingIntent.FLAG_UPDATE_CURRENT)) - .build(); - ServiceUtil.getNotificationManager(context).notify(NotificationIds.EXPERIENCE_UPGRADE, notification); - } else if (DISMISS_ACTION.equals(intent.getAction())) { - TextSecurePreferences.setExperienceDismissedVersionCode(context, Util.getCanonicalVersionCode()); - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/GroupCreateActivity.java b/app/src/main/java/org/thoughtcrime/securesms/GroupCreateActivity.java index bd5f68dba..f5a827f64 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/GroupCreateActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/GroupCreateActivity.java @@ -54,6 +54,7 @@ import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupManager; import org.thoughtcrime.securesms.groups.GroupManager.GroupActionResult; import org.thoughtcrime.securesms.logging.Log; @@ -208,7 +209,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity } private void initializeExistingGroup() { - final String groupId = getIntent().getStringExtra(GROUP_ID_EXTRA); + final GroupId groupId = GroupId.parseNullable(getIntent().getStringExtra(GROUP_ID_EXTRA)); if (groupId != null) { new FillExistingGroupInfoAsyncTask(this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, groupId); @@ -361,7 +362,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity } memberAddresses.add(Recipient.self().getId()); - String groupId = DatabaseFactory.getGroupDatabase(activity).getOrCreateGroupForMembers(memberAddresses, true); + GroupId groupId = DatabaseFactory.getGroupDatabase(activity).getOrCreateGroupForMembers(memberAddresses, true); RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(activity).getOrInsertFromGroupId(groupId); Recipient groupRecipient = Recipient.resolved(groupRecipientId); long threadId = DatabaseFactory.getThreadDatabase(activity).getThreadIdFor(groupRecipient, ThreadDatabase.DistributionTypes.DEFAULT); @@ -443,9 +444,9 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity } private static class UpdateSignalGroupTask extends SignalGroupTask { - private String groupId; + private final GroupId groupId; - public UpdateSignalGroupTask(GroupCreateActivity activity, String groupId, + public UpdateSignalGroupTask(GroupCreateActivity activity, GroupId groupId, Bitmap avatar, String name, Set members) { super(activity, avatar, name, members); @@ -467,7 +468,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity if (!activity.isFinishing()) { Intent intent = activity.getIntent(); intent.putExtra(GROUP_THREAD_EXTRA, result.get().getThreadId()); - intent.putExtra(GROUP_ID_EXTRA, result.get().getGroupRecipient().requireGroupId()); + intent.putExtra(GROUP_ID_EXTRA, result.get().getGroupRecipient().requireGroupId().toString()); activity.setResult(RESULT_OK, intent); activity.finish(); } @@ -534,7 +535,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity } } - private static class FillExistingGroupInfoAsyncTask extends ProgressDialogAsyncTask> { + private static class FillExistingGroupInfoAsyncTask extends ProgressDialogAsyncTask> { private GroupCreateActivity activity; public FillExistingGroupInfoAsyncTask(GroupCreateActivity activity) { @@ -545,7 +546,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity } @Override - protected Optional doInBackground(String... groupIds) { + protected Optional doInBackground(GroupId... groupIds) { final GroupDatabase db = DatabaseFactory.getGroupDatabase(activity); final List recipients = db.getGroupMembers(groupIds[0], false); final Optional group = db.getGroup(groupIds[0]); @@ -593,13 +594,13 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity } private static class GroupData { - String id; + GroupId id; Set recipients; Bitmap avatarBmp; byte[] avatarBytes; String name; - public GroupData(String id, Set recipients, Bitmap avatarBmp, byte[] avatarBytes, String name) { + GroupData(GroupId id, Set recipients, Bitmap avatarBmp, byte[] avatarBytes, String name) { this.id = id; this.recipients = recipients; this.avatarBmp = avatarBmp; diff --git a/app/src/main/java/org/thoughtcrime/securesms/IntroPagerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/IntroPagerAdapter.java deleted file mode 100644 index a176c28bb..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/IntroPagerAdapter.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.thoughtcrime.securesms; - -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentStatePagerAdapter; - -import java.util.List; - -public class IntroPagerAdapter extends FragmentStatePagerAdapter { - - public static class IntroPage { - final int backgroundColor; - final Fragment fragment; - - public IntroPage(int backgroundColor, Fragment fragment) { - this.backgroundColor = backgroundColor; - this.fragment = fragment; - } - } - - private List pages; - - public IntroPagerAdapter(FragmentManager fm, List pages) { - super(fm); - this.pages = pages; - } - - @Override - public Fragment getItem(int i) { - IntroPage page = pages.get(i); - return page.fragment; - } - - @Override - public int getCount() { - return pages.size(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java b/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java index ab4ab8431..088579204 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java @@ -175,17 +175,17 @@ public class InviteActivity extends PassphraseRequiredActionBarActivity implemen } private void setPrimaryColorsToolbarForSms() { - primaryToolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.signal_primary)); + primaryToolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.core_ultramarine)); primaryToolbar.getNavigationIcon().setColorFilter(ThemeUtil.getThemedColor(this, R.attr.conversation_subtitle_color), PorterDuff.Mode.SRC_IN); primaryToolbar.setTitleTextColor(ThemeUtil.getThemedColor(this, R.attr.conversation_title_color)); if (Build.VERSION.SDK_INT >= 23) { - getWindow().setStatusBarColor(ContextCompat.getColor(this, R.color.signal_primary)); + getWindow().setStatusBarColor(ContextCompat.getColor(this, R.color.core_ultramarine)); WindowUtil.clearLightStatusBar(getWindow()); } if (Build.VERSION.SDK_INT >= 27) { - getWindow().setNavigationBarColor(ContextCompat.getColor(this, R.color.signal_primary)); + getWindow().setNavigationBarColor(ContextCompat.getColor(this, R.color.core_ultramarine)); WindowUtil.clearLightNavigationBar(getWindow()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/LinkPreviewsIntroFragment.java b/app/src/main/java/org/thoughtcrime/securesms/LinkPreviewsIntroFragment.java deleted file mode 100644 index 3ebb914b6..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/LinkPreviewsIntroFragment.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.thoughtcrime.securesms; - - -import android.content.Context; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob; -import org.thoughtcrime.securesms.util.TextSecurePreferences; - -public class LinkPreviewsIntroFragment extends Fragment { - - private Controller controller; - - public static LinkPreviewsIntroFragment newInstance() { - LinkPreviewsIntroFragment fragment = new LinkPreviewsIntroFragment(); - Bundle args = new Bundle(); - fragment.setArguments(args); - return fragment; - } - - public LinkPreviewsIntroFragment() {} - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - - if (!(getActivity() instanceof Controller)) { - throw new IllegalStateException("Parent activity must implement the Controller interface."); - } - - controller = (Controller) getActivity(); - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.experience_upgrade_link_previews_fragment, container, false); - - view.findViewById(R.id.experience_ok_button).setOnClickListener(v -> { - ApplicationDependencies.getJobManager().add(new MultiDeviceConfigurationUpdateJob(TextSecurePreferences.isReadReceiptsEnabled(requireContext()), - TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()), - TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(requireContext()), - TextSecurePreferences.isLinkPreviewsEnabled(requireContext()))); - controller.onLinkPreviewsFinished(); - }); - - return view; - } - - public interface Controller { - void onLinkPreviewsFinished(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java index 51df05ed2..8a047f071 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java @@ -238,7 +238,7 @@ public class PassphrasePromptActivity extends PassphraseActivity { EditorInfo.IME_ACTION_DONE); fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_primary), PorterDuff.Mode.SRC_IN); + fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN); lockScreenButton.setOnClickListener(v -> resumeScreenLock()); } @@ -358,7 +358,7 @@ public class PassphrasePromptActivity extends PassphraseActivity { handleAuthenticated(); fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_primary), PorterDuff.Mode.SRC_IN); + fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN); } }).start(); } @@ -381,7 +381,7 @@ public class PassphrasePromptActivity extends PassphraseActivity { @Override public void onAnimationEnd(Animation animation) { fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_primary), PorterDuff.Mode.SRC_IN); + fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java index 5daa8d181..0f8cbf89d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.migrations.ApplicationMigrations; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; +import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity; import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.util.CensorshipUtil; @@ -39,10 +40,9 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA private static final int STATE_CREATE_PASSPHRASE = 1; private static final int STATE_PROMPT_PASSPHRASE = 2; private static final int STATE_UI_BLOCKING_UPGRADE = 3; - private static final int STATE_EXPERIENCE_UPGRADE = 4; - private static final int STATE_WELCOME_PUSH_SCREEN = 5; - private static final int STATE_CREATE_PROFILE_NAME = 6; - private static final int STATE_CREATE_KBS_PIN = 7; + private static final int STATE_WELCOME_PUSH_SCREEN = 4; + private static final int STATE_CREATE_PROFILE_NAME = 5; + private static final int STATE_CREATE_KBS_PIN = 6; private SignalServiceNetworkAccess networkAccess; private BroadcastReceiver clearKeyReceiver; @@ -157,7 +157,6 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA case STATE_PROMPT_PASSPHRASE: return getPromptPassphraseIntent(); case STATE_UI_BLOCKING_UPGRADE: return getUiBlockingUpgradeIntent(); case STATE_WELCOME_PUSH_SCREEN: return getPushRegistrationIntent(); - case STATE_EXPERIENCE_UPGRADE: return getExperienceUpgradeIntent(); case STATE_CREATE_KBS_PIN: return getCreateKbsPinIntent(); case STATE_CREATE_PROFILE_NAME: return getCreateProfileNameIntent(); default: return null; @@ -173,8 +172,6 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA return STATE_UI_BLOCKING_UPGRADE; } else if (!TextSecurePreferences.hasPromptedPushRegistration(this)) { return STATE_WELCOME_PUSH_SCREEN; - } else if (ExperienceUpgradeActivity.isUpdate(this)) { - return STATE_EXPERIENCE_UPGRADE; } else if (userMustSetProfileName()) { return STATE_CREATE_PROFILE_NAME; } else if (userMustSetKbsPin()) { @@ -191,7 +188,7 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA } private boolean userMustSetProfileName() { - return !SignalStore.registrationValues().isRegistrationComplete() && TextSecurePreferences.getProfileName(this) == ProfileName.EMPTY; + return !SignalStore.registrationValues().isRegistrationComplete() && Recipient.self().getProfileName() == ProfileName.EMPTY; } private Intent getCreatePassphraseIntent() { @@ -209,10 +206,6 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA : getPushRegistrationIntent()); } - private Intent getExperienceUpgradeIntent() { - return getRoutedIntent(ExperienceUpgradeActivity.class, getIntent()); - } - private Intent getPushRegistrationIntent() { return RegistrationNavigationActivity.newIntentForNewRegistration(this); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ReadReceiptsIntroFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ReadReceiptsIntroFragment.java deleted file mode 100644 index 3c1daa232..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/ReadReceiptsIntroFragment.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.thoughtcrime.securesms; - - -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.appcompat.widget.SwitchCompat; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob; -import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.util.ViewUtil; - -public class ReadReceiptsIntroFragment extends Fragment { - - public static ReadReceiptsIntroFragment newInstance() { - ReadReceiptsIntroFragment fragment = new ReadReceiptsIntroFragment(); - Bundle args = new Bundle(); - fragment.setArguments(args); - return fragment; - } - - public ReadReceiptsIntroFragment() {} - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View v = inflater.inflate(R.layout.experience_upgrade_preference_fragment, container, false); - SwitchCompat preference = ViewUtil.findById(v, R.id.preference); - - preference.setChecked(TextSecurePreferences.isReadReceiptsEnabled(getContext())); - preference.setOnCheckedChangeListener((buttonView, isChecked) -> { - TextSecurePreferences.setReadReceiptsEnabled(getContext(), isChecked); - ApplicationDependencies.getJobManager() - .add(new MultiDeviceConfigurationUpdateJob(isChecked, - TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()), - TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()), - TextSecurePreferences.isLinkPreviewsEnabled(getContext()))); - }); - - return v; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/RecipientPreferenceActivity.java b/app/src/main/java/org/thoughtcrime/securesms/RecipientPreferenceActivity.java index 764d8295a..27221a7fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/RecipientPreferenceActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/RecipientPreferenceActivity.java @@ -221,7 +221,7 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi } private void setHeader(@NonNull Recipient recipient) { - ContactPhoto contactPhoto = recipient.isLocalNumber() ? new ProfileContactPhoto(recipient.getId(), String.valueOf(TextSecurePreferences.getProfileAvatarId(this))) + ContactPhoto contactPhoto = recipient.isLocalNumber() ? new ProfileContactPhoto(recipient, recipient.getProfileAvatar()) : recipient.getContactPhoto(); FallbackContactPhoto fallbackPhoto = recipient.isLocalNumber() ? new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20, R.drawable.ic_person_large) : recipient.getFallbackContactPhoto(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/TransportOptions.java b/app/src/main/java/org/thoughtcrime/securesms/TransportOptions.java index d4ed37c42..5a5b3fae3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/TransportOptions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/TransportOptions.java @@ -109,7 +109,7 @@ public class TransportOptions { public static @NonNull TransportOption getPushTransportOption(@NonNull Context context) { return new TransportOption(Type.TEXTSECURE, R.drawable.ic_send_lock_24, - context.getResources().getColor(R.color.textsecure_primary), + context.getResources().getColor(R.color.core_ultramarine), context.getString(R.string.ConversationActivity_transport_signal), context.getString(R.string.conversation_activity__type_message_push), new PushCharacterCalculator()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/TypingIndicatorIntroFragment.java b/app/src/main/java/org/thoughtcrime/securesms/TypingIndicatorIntroFragment.java deleted file mode 100644 index f04ae2372..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/TypingIndicatorIntroFragment.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.thoughtcrime.securesms; - - -import android.content.Context; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import org.thoughtcrime.securesms.components.TypingIndicatorView; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob; -import org.thoughtcrime.securesms.util.TextSecurePreferences; - -public class TypingIndicatorIntroFragment extends Fragment { - - private Controller controller; - - public static TypingIndicatorIntroFragment newInstance() { - TypingIndicatorIntroFragment fragment = new TypingIndicatorIntroFragment(); - Bundle args = new Bundle(); - fragment.setArguments(args); - return fragment; - } - - public TypingIndicatorIntroFragment() {} - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - - if (!(getActivity() instanceof Controller)) { - throw new IllegalStateException("Parent activity must implement the Controller interface."); - } - - controller = (Controller) getActivity(); - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.experience_upgrade_typing_indicators_fragment, container, false); - View yesButton = view.findViewById(R.id.experience_yes_button); - View noButton = view.findViewById(R.id.experience_no_button); - - ((TypingIndicatorView) view.findViewById(R.id.typing_indicator)).startAnimation(); - - yesButton.setOnClickListener(v -> onButtonClicked(true)); - noButton.setOnClickListener(v -> onButtonClicked(false)); - - return view; - } - - private void onButtonClicked(boolean typingEnabled) { - TextSecurePreferences.setTypingIndicatorsEnabled(getContext(), typingEnabled); - ApplicationDependencies.getJobManager().add(new MultiDeviceConfigurationUpdateJob(TextSecurePreferences.isReadReceiptsEnabled(requireContext()), - typingEnabled, - TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()), - TextSecurePreferences.isLinkPreviewsEnabled(getContext()))); - - controller.onTypingIndicatorsFinished(); - } - - public interface Controller { - void onTypingIndicatorsFinished(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java b/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java index 1f4acd054..ec304aaaa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java @@ -77,6 +77,7 @@ import org.thoughtcrime.securesms.qr.ScanningThread; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.util.DynamicDarkActionBarTheme; import org.thoughtcrime.securesms.util.DynamicLanguage; import org.thoughtcrime.securesms.util.DynamicTheme; @@ -605,7 +606,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActionBarActivity remoteIdentity, isChecked ? VerifiedStatus.VERIFIED : VerifiedStatus.DEFAULT)); - ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + StorageSyncHelper.scheduleSyncForDataChange(); IdentityUtil.markIdentityVerified(getActivity(), recipient.get(), isChecked, false); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java new file mode 100644 index 000000000..7a2e06d90 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.components.emoji; + +import androidx.annotation.NonNull; + +import org.whispersystems.libsignal.util.Pair; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public final class EmojiUtil { + + private static final Map VARIATION_MAP = new HashMap<>(); + + static { + for (EmojiPageModel page : EmojiPages.DATA_PAGES) { + for (Emoji emoji : page.getDisplayEmoji()) { + for (String variation : emoji.getVariations()) { + VARIATION_MAP.put(variation, emoji.getValue()); + } + } + } + } + + public static final int MAX_EMOJI_LENGTH; + static { + int max = 0; + for (EmojiPageModel page : EmojiPages.DATA_PAGES) { + for (String emoji : page.getEmoji()) { + max = Math.max(max, emoji.length()); + } + } + MAX_EMOJI_LENGTH = max; + } + + private EmojiUtil() {} + + /** + * This will return all ways we know of expressing a singular emoji. This is to aid in search, + * where some platforms may send an emoji we've locally marked as 'obsolete'. + */ + public static @NonNull Set getAllRepresentations(@NonNull String emoji) { + Set out = new HashSet<>(); + + out.add(emoji); + + for (Pair pair : EmojiPages.OBSOLETE) { + if (pair.first().equals(emoji)) { + out.add(pair.second()); + } else if (pair.second().equals(emoji)) { + out.add(pair.first()); + } + } + + return out; + } + + /** + * When provided an emoji that is a skin variation of another, this will return the default yellow + * version. This is to aid in search, so using a variation will still find all emojis tagged with + * the default version. + * + * If the emoji has no skin variations, this function will return the original emoji. + */ + public static @NonNull String getCanonicalRepresentation(@NonNull String emoji) { + String canonical = VARIATION_MAP.get(emoji); + return canonical != null ? canonical : emoji; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java index b9450e635..973561cc9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java @@ -202,7 +202,7 @@ public class ContactAccessor { reader = DatabaseFactory.getGroupDatabase(context).getGroupsFilteredByTitle(constraint, true); while ((record = reader.getNext()) != null) { - numberList.add(record.getEncodedId()); + numberList.add(record.getId().toString()); } } finally { if (reader != null) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java index 30407e2ac..7b07b2122 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java @@ -235,7 +235,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter Util.getSecretBytes(16); - - private static KeyGenerator testKeyGenerator = null; - - /** - * 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). - * - * @param currentManifestVersion What you think the version is locally. - * @param currentLocalKeys All local keys you have. This assumes that 'inserts' were given keys - * already, and that deletes still have keys. - * @param updates Contacts that have been altered. - * @param inserts Contacts that have been inserted (or newly marked as registered). - * @param deletes Contacts that are no longer registered. - * - * @return If changes need to be written, then it will return those changes. If no changes need - * to be written, this will return {@link Optional#absent()}. - */ - public static @NonNull Optional buildStorageUpdatesForLocal(long currentManifestVersion, - @NonNull List currentLocalKeys, - @NonNull List updates, - @NonNull List inserts, - @NonNull List deletes) - { - Set completeKeys = new LinkedHashSet<>(Stream.of(currentLocalKeys).map(ByteBuffer::wrap).toList()); - Set storageInserts = new LinkedHashSet<>(); - Set storageDeletes = new LinkedHashSet<>(); - Map storageKeyUpdates = new HashMap<>(); - - for (RecipientSettings insert : inserts) { - storageInserts.add(localToRemoteRecord(insert)); - } - - for (RecipientSettings delete : deletes) { - byte[] key = Objects.requireNonNull(delete.getStorageKey()); - storageDeletes.add(ByteBuffer.wrap(key)); - completeKeys.remove(ByteBuffer.wrap(key)); - } - - for (RecipientSettings update : updates) { - byte[] oldKey = Objects.requireNonNull(update.getStorageKey()); - byte[] newKey = generateKey(); - - 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 (storageInserts.isEmpty() && storageDeletes.isEmpty()) { - return Optional.absent(); - } else { - List contactDeleteBytes = Stream.of(storageDeletes).map(ByteBuffer::array).toList(); - List completeKeysBytes = Stream.of(completeKeys).map(ByteBuffer::array).toList(); - SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, completeKeysBytes); - WriteOperationResult writeOperationResult = new WriteOperationResult(manifest, new ArrayList<>(storageInserts), contactDeleteBytes); - - return Optional.of(new LocalWriteResult(writeOperationResult, storageKeyUpdates)); - } - } - - /** - * Given a list of all the local and remote keys you know about, this will return a result telling - * you which keys are exclusively remote and which are exclusively local. - * - * @param remoteKeys All remote keys available. - * @param localKeys All local keys available. - * - * @return An object describing which keys are exclusive to the remote data set and which keys are - * exclusive to the local data set. - */ - public static @NonNull KeyDifferenceResult findKeyDifference(@NonNull List remoteKeys, - @NonNull List localKeys) - { - Set allRemoteKeys = Stream.of(remoteKeys).map(ByteBuffer::wrap).collect(LinkedHashSet::new, HashSet::add); - Set allLocalKeys = Stream.of(localKeys).map(ByteBuffer::wrap).collect(LinkedHashSet::new, HashSet::add); - - Set remoteOnlyKeys = SetUtil.difference(allRemoteKeys, allLocalKeys); - Set localOnlyKeys = SetUtil.difference(allLocalKeys, allRemoteKeys); - - return new KeyDifferenceResult(Stream.of(remoteOnlyKeys).map(ByteBuffer::array).toList(), - Stream.of(localOnlyKeys).map(ByteBuffer::array).toList()); - } - - /** - * Given two sets of storage records, this will resolve the data into a set of actions that need - * to be applied to resolve the differences. This will handle discovering which records between - * the two collections refer to the same contacts and are actually updates, which are brand new, - * etc. - * - * @param remoteOnlyRecords Records that are only present remotely. - * @param localOnlyRecords Records that are only present locally. - * - * @return A set of actions that should be applied to resolve the conflict. - */ - public static @NonNull MergeResult resolveConflict(@NonNull Collection remoteOnlyRecords, - @NonNull Collection localOnlyRecords) - { - List remoteOnlyContacts = Stream.of(remoteOnlyRecords).filter(r -> r.getContact().isPresent()).map(r -> r.getContact().get()).toList(); - List localOnlyContacts = Stream.of(localOnlyRecords).filter(r -> r.getContact().isPresent()).map(r -> r.getContact().get()).toList(); - - List remoteOnlyGroupV1 = Stream.of(remoteOnlyRecords).filter(r -> r.getGroupV1().isPresent()).map(r -> r.getGroupV1().get()).toList(); - List localOnlyGroupV1 = Stream.of(localOnlyRecords).filter(r -> r.getGroupV1().isPresent()).map(r -> r.getGroupV1().get()).toList(); - - List remoteOnlyUnknowns = Stream.of(remoteOnlyRecords).filter(SignalStorageRecord::isUnknown).toList(); - List localOnlyUnknowns = Stream.of(localOnlyRecords).filter(SignalStorageRecord::isUnknown).toList(); - - ContactRecordMergeResult contactMergeResult = resolveContactConflict(remoteOnlyContacts, localOnlyContacts); - GroupV1RecordMergeResult groupV1MergeResult = resolveGroupV1Conflict(remoteOnlyGroupV1, localOnlyGroupV1); - - Set remoteInserts = new HashSet<>(); - remoteInserts.addAll(Stream.of(contactMergeResult.remoteInserts).map(SignalStorageRecord::forContact).toList()); - remoteInserts.addAll(Stream.of(groupV1MergeResult.remoteInserts).map(SignalStorageRecord::forGroupV1).toList()); - - Set 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, - groupV1MergeResult.localInserts, - groupV1MergeResult.localUpdates, - new LinkedHashSet<>(remoteOnlyUnknowns), - new LinkedHashSet<>(localOnlyUnknowns), - remoteInserts, - remoteUpdates); - } - - /** - * Assumes that the merge result has *not* yet been applied to the local data. That means that - * this method will handle generating the correct final key set based on the merge result. - */ - public static @NonNull WriteOperationResult createWriteOperation(long currentManifestVersion, - @NonNull List currentLocalStorageKeys, - @NonNull MergeResult mergeResult) - { - Set completeKeys = new LinkedHashSet<>(Stream.of(currentLocalStorageKeys).map(ByteBuffer::wrap).toList()); - - for (SignalContactRecord insert : mergeResult.getLocalContactInserts()) { - completeKeys.add(ByteBuffer.wrap(insert.getKey())); - } - - for (SignalGroupV1Record insert : mergeResult.getLocalGroupV1Inserts()) { - completeKeys.add(ByteBuffer.wrap(insert.getKey())); - } - - for (SignalStorageRecord insert : mergeResult.getRemoteInserts()) { - completeKeys.add(ByteBuffer.wrap(insert.getKey())); - } - - for (SignalStorageRecord insert : mergeResult.getLocalUnknownInserts()) { - completeKeys.add(ByteBuffer.wrap(insert.getKey())); - } - - for (ContactUpdate update : mergeResult.getLocalContactUpdates()) { - completeKeys.remove(ByteBuffer.wrap(update.getOld().getKey())); - completeKeys.add(ByteBuffer.wrap(update.getNew().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 inserts = new ArrayList<>(); - inserts.addAll(mergeResult.getRemoteInserts()); - inserts.addAll(Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getNew).toList()); - - List deletes = Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getOld).map(SignalStorageRecord::getKey).toList(); - - return new WriteOperationResult(manifest, inserts, deletes); - } - - public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings) { - if (settings.getStorageKey() == null) { - throw new AssertionError("Must have a storage key!"); - } - - 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) { - if (recipient.getUuid() == null && recipient.getE164() == null) { - throw new AssertionError("Must have either a UUID or a phone number!"); - } - - return new SignalContactRecord.Builder(storageKey, new SignalServiceAddress(recipient.getUuid(), recipient.getE164())) - .setProfileKey(recipient.getProfileKey()) - .setGivenName(recipient.getProfileName().getGivenName()) - .setFamilyName(recipient.getProfileName().getFamilyName()) - .setBlocked(recipient.isBlocked()) - .setProfileSharingEnabled(recipient.isProfileSharing()) - .setIdentityKey(recipient.getIdentityKey()) - .setIdentityState(localToRemoteIdentityState(recipient.getIdentityStatus())) - .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; - case UNVERIFIED: return IdentityDatabase.VerifiedStatus.UNVERIFIED; - default: return IdentityDatabase.VerifiedStatus.DEFAULT; - } - } - - public static @NonNull byte[] generateKey() { - if (testKeyGenerator != null) { - return testKeyGenerator.generate(); - } else { - return KEY_GENERATOR.generate(); - } - } - - @VisibleForTesting - static @NonNull SignalContactRecord mergeContacts(@NonNull SignalContactRecord remote, - @NonNull SignalContactRecord local) - { - 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 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()).or(""); - IdentityState identityState = remote.getIdentityState(); - byte[] identityKey = remote.getIdentityKey().or(local.getIdentityKey()).orNull(); - 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, 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."); - } - - if (matchesRemote) { - return remote; - } else if (matchesLocal) { - return local; - } else { - return new SignalContactRecord.Builder(generateKey(), address) - .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(); - } - } - - @VisibleForTesting - static void setTestKeyGenerator(@Nullable KeyGenerator keyGenerator) { - testKeyGenerator = keyGenerator; - } - - private static IdentityState localToRemoteIdentityState(@NonNull IdentityDatabase.VerifiedStatus local) { - switch (local) { - case VERIFIED: return IdentityState.VERIFIED; - case UNVERIFIED: return IdentityState.UNVERIFIED; - default: return IdentityState.DEFAULT; - } - } - - private static boolean doParamsMatchContact(@NonNull SignalContactRecord contact, - @NonNull SignalServiceAddress address, - @Nullable String givenName, - @Nullable String familyName, - @Nullable byte[] profileKey, - @Nullable String username, - @Nullable IdentityState identityState, - @Nullable byte[] identityKey, - boolean blocked, - boolean profileSharing, - @Nullable String 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 remoteOnlyRecords, - @NonNull Collection localOnlyRecords) - { - Map localByUuid = new HashMap<>(); - Map localByE164 = new HashMap<>(); - - for (SignalContactRecord contact : localOnlyRecords) { - if (contact.getAddress().getUuid().isPresent()) { - localByUuid.put(contact.getAddress().getUuid().get(), contact); - } - if (contact.getAddress().getNumber().isPresent()) { - localByE164.put(contact.getAddress().getNumber().get(), contact); - } - } - - Set localInserts = new LinkedHashSet<>(remoteOnlyRecords); - Set remoteInserts = new LinkedHashSet<>(localOnlyRecords); - Set localUpdates = new LinkedHashSet<>(); - Set remoteUpdates = new LinkedHashSet<>(); - - for (SignalContactRecord remote : remoteOnlyRecords) { - SignalContactRecord localUuid = remote.getAddress().getUuid().isPresent() ? localByUuid.get(remote.getAddress().getUuid().get()) : null; - SignalContactRecord localE164 = remote.getAddress().getNumber().isPresent() ? localByE164.get(remote.getAddress().getNumber().get()) : null; - - Optional local = Optional.fromNullable(localUuid).or(Optional.fromNullable(localE164)); - - if (local.isPresent()) { - SignalContactRecord merged = mergeContacts(remote, local.get()); - - if (!merged.equals(remote)) { - remoteUpdates.add(new ContactUpdate(remote, merged)); - } - - if (!merged.equals(local.get())) { - localUpdates.add(new ContactUpdate(local.get(), merged)); - } - - localInserts.remove(remote); - remoteInserts.remove(local.get()); - } - } - - return new ContactRecordMergeResult(localInserts, localUpdates, remoteInserts, remoteUpdates); - } - - private static @NonNull GroupV1RecordMergeResult resolveGroupV1Conflict(@NonNull Collection remoteOnlyRecords, - @NonNull Collection localOnlyRecords) - { - Map remoteByGroupId = Stream.of(remoteOnlyRecords).collect(Collectors.toMap(g -> GroupUtil.getEncodedId(g.getGroupId(), false), g -> g)); - Map localByGroupId = Stream.of(localOnlyRecords).collect(Collectors.toMap(g -> GroupUtil.getEncodedId(g.getGroupId(), false), g -> g)); - - Set localInserts = new LinkedHashSet<>(remoteOnlyRecords); - Set remoteInserts = new LinkedHashSet<>(localOnlyRecords); - Set localUpdates = new LinkedHashSet<>(); - Set remoteUpdates = new LinkedHashSet<>(); - - for (Map.Entry 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; - - ContactUpdate(@NonNull SignalContactRecord oldContact, @NonNull SignalContactRecord newContact) { - this.oldContact = oldContact; - this.newContact = newContact; - } - - public @NonNull SignalContactRecord getOld() { - return oldContact; - } - - public @NonNull SignalContactRecord getNew() { - return newContact; - } - - public boolean profileKeyChanged() { - return !OptionalUtil.byteArrayEquals(oldContact.getProfileKey(), newContact.getProfileKey()); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - ContactUpdate that = (ContactUpdate) o; - return oldContact.equals(that.oldContact) && - newContact.equals(that.newContact); - } - - @Override - public int hashCode() { - return Objects.hash(oldContact, newContact); - } - } - - 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 remoteOnlyKeys; - private final List localOnlyKeys; - - private KeyDifferenceResult(@NonNull List remoteOnlyKeys, @NonNull List localOnlyKeys) { - this.remoteOnlyKeys = remoteOnlyKeys; - this.localOnlyKeys = localOnlyKeys; - } - - public @NonNull List getRemoteOnlyKeys() { - return remoteOnlyKeys; - } - - public @NonNull List getLocalOnlyKeys() { - return localOnlyKeys; - } - - public boolean isEmpty() { - return remoteOnlyKeys.isEmpty() && localOnlyKeys.isEmpty(); - } - } - - public static final class MergeResult { - private final Set localContactInserts; - private final Set localContactUpdates; - private final Set localGroupV1Inserts; - private final Set localGroupV1Updates; - private final Set localUnknownInserts; - private final Set localUnknownDeletes; - private final Set remoteInserts; - private final Set remoteUpdates; - - @VisibleForTesting - MergeResult(@NonNull Set localContactInserts, - @NonNull Set localContactUpdates, - @NonNull Set localGroupV1Inserts, - @NonNull Set localGroupV1Updates, - @NonNull Set localUnknownInserts, - @NonNull Set localUnknownDeletes, - @NonNull Set remoteInserts, - @NonNull Set remoteUpdates) - { - this.localContactInserts = localContactInserts; - this.localContactUpdates = localContactUpdates; - this.localGroupV1Inserts = localGroupV1Inserts; - this.localGroupV1Updates = localGroupV1Updates; - this.localUnknownInserts = localUnknownInserts; - this.localUnknownDeletes = localUnknownDeletes; - this.remoteInserts = remoteInserts; - this.remoteUpdates = remoteUpdates; - } - - public @NonNull Set getLocalContactInserts() { - return localContactInserts; - } - - public @NonNull Set getLocalContactUpdates() { - return localContactUpdates; - } - - public @NonNull Set getLocalGroupV1Inserts() { - return localGroupV1Inserts; - } - - public @NonNull Set getLocalGroupV1Updates() { - return localGroupV1Updates; - } - - public @NonNull Set getLocalUnknownInserts() { - return localUnknownInserts; - } - - public @NonNull Set getLocalUnknownDeletes() { - return localUnknownDeletes; - } - - public @NonNull Set getRemoteInserts() { - return remoteInserts; - } - - public @NonNull Set 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 { - private final SignalStorageManifest manifest; - private final List inserts; - private final List deletes; - - private WriteOperationResult(@NonNull SignalStorageManifest manifest, - @NonNull List inserts, - @NonNull List deletes) - { - this.manifest = manifest; - this.inserts = inserts; - this.deletes = deletes; - } - - public @NonNull SignalStorageManifest getManifest() { - return manifest; - } - - public @NonNull List getInserts() { - return inserts; - } - - public @NonNull List 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 { - private final WriteOperationResult writeResult; - private final Map storageKeyUpdates; - - private LocalWriteResult(WriteOperationResult writeResult, Map storageKeyUpdates) { - this.writeResult = writeResult; - this.storageKeyUpdates = storageKeyUpdates; - } - - public @NonNull WriteOperationResult getWriteResult() { - return writeResult; - } - - public @NonNull Map getStorageKeyUpdates() { - return storageKeyUpdates; - } - } - - private static final class ContactRecordMergeResult { - final Set localInserts; - final Set localUpdates; - final Set remoteInserts; - final Set remoteUpdates; - - ContactRecordMergeResult(@NonNull Set localInserts, - @NonNull Set localUpdates, - @NonNull Set remoteInserts, - @NonNull Set remoteUpdates) - { - this.localInserts = localInserts; - this.localUpdates = localUpdates; - this.remoteInserts = remoteInserts; - this.remoteUpdates = remoteUpdates; - } - } - - private static final class GroupV1RecordMergeResult { - final Set localInserts; - final Set localUpdates; - final Set remoteInserts; - final Set remoteUpdates; - - GroupV1RecordMergeResult(@NonNull Set localInserts, - @NonNull Set localUpdates, - @NonNull Set remoteInserts, - @NonNull Set remoteUpdates) - { - this.localInserts = localInserts; - this.localUpdates = localUpdates; - this.remoteInserts = remoteInserts; - this.remoteUpdates = remoteUpdates; - } - } - - interface KeyGenerator { - @NonNull byte[] generate(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 4dc0e81e0..8c2e864dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -28,10 +28,10 @@ import android.content.pm.PackageManager; import android.content.res.Configuration; import android.content.res.TypedArray; import android.graphics.Bitmap; -import android.graphics.BitmapFactory; import android.graphics.Color; import android.graphics.PorterDuff; import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; import android.hardware.Camera; import android.net.Uri; import android.os.AsyncTask; @@ -73,6 +73,8 @@ import androidx.core.graphics.drawable.IconCompat; import androidx.lifecycle.ViewModelProviders; import com.annimon.stream.Stream; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.transition.Transition; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; @@ -207,7 +209,7 @@ import org.thoughtcrime.securesms.stickers.StickerSearchRepository; import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; import org.thoughtcrime.securesms.util.CommunicationActions; -import org.thoughtcrime.securesms.util.Dialogs; +import org.thoughtcrime.securesms.util.DrawableUtil; import org.thoughtcrime.securesms.util.DynamicDarkToolbarTheme; import org.thoughtcrime.securesms.util.DynamicLanguage; import org.thoughtcrime.securesms.util.DynamicTheme; @@ -261,6 +263,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity StickerKeyboardProvider.StickerEventListener, AttachmentKeyboard.Callback { + + private static final int SHORTCUT_ICON_SIZE = Build.VERSION.SDK_INT >= 26 ? ViewUtil.dpToPx(72) : ViewUtil.dpToPx(48 + 16 * 2); + private static final String TAG = ConversationActivity.class.getSimpleName(); public static final String RECIPIENT_EXTRA = "recipient_id"; @@ -431,9 +436,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity return; } - if (!Util.isEmpty(composeText) || attachmentManager.isAttachmentPresent()) { + if (!Util.isEmpty(composeText) || attachmentManager.isAttachmentPresent() || inputPanel.getQuote().isPresent()) { saveDraft(); attachmentManager.clear(glideRequests, false); + inputPanel.clearQuote(); silentlySetComposeText(""); } @@ -1046,47 +1052,58 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private void handleAddShortcut() { Log.i(TAG, "Creating home screen shortcut for recipient " + recipient.get().getId()); - new AsyncTask() { + final Context context = getApplicationContext(); + final Recipient recipient = this.recipient.get(); - @Override - protected IconCompat doInBackground(Void... voids) { - Context context = getApplicationContext(); - IconCompat icon = null; + GlideApp.with(this) + .asBitmap() + .load(recipient.getContactPhoto()) + .error(recipient.getFallbackContactPhoto().asDrawable(this, recipient.getColor().toAvatarColor(this), false)) + .into(new CustomTarget() { + @Override + public void onLoadFailed(@Nullable Drawable errorDrawable) { + if (errorDrawable == null) { + throw new AssertionError(); + } - if (recipient.get().getContactPhoto() != null) { - try { - Bitmap bitmap = BitmapFactory.decodeStream(recipient.get().getContactPhoto().openInputStream(context)); - bitmap = BitmapUtil.createScaledBitmap(bitmap, 300, 300); - icon = IconCompat.createWithAdaptiveBitmap(bitmap); - } catch (IOException e) { - Log.w(TAG, "Failed to decode contact photo during shortcut creation. Falling back to generic icon.", e); - } - } + Log.w(TAG, "Utilizing fallback photo for shortcut for recipient " + recipient.getId()); - if (icon == null) { - icon = IconCompat.createWithResource(context, recipient.get().isGroup() ? R.mipmap.ic_group_shortcut - : R.mipmap.ic_person_shortcut); - } + SimpleTask.run(() -> DrawableUtil.toBitmap(errorDrawable, SHORTCUT_ICON_SIZE, SHORTCUT_ICON_SIZE), + bitmap -> addIconToHomeScreen(context, bitmap, recipient)); + } - return icon; - } + @Override + public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { + SimpleTask.run(() -> BitmapUtil.createScaledBitmap(resource, SHORTCUT_ICON_SIZE, SHORTCUT_ICON_SIZE), + bitmap -> addIconToHomeScreen(context, bitmap, recipient)); + } - @Override - protected void onPostExecute(IconCompat icon) { - Context context = getApplicationContext(); - String name = recipient.get().getDisplayName(ConversationActivity.this); + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + } + }); - ShortcutInfoCompat shortcutInfo = new ShortcutInfoCompat.Builder(context, recipient.get().getId().serialize() + '-' + System.currentTimeMillis()) - .setShortLabel(name) - .setIcon(icon) - .setIntent(ShortcutLauncherActivity.createIntent(context, recipient.getId())) - .build(); + } - if (ShortcutManagerCompat.requestPinShortcut(context, shortcutInfo, null)) { - Toast.makeText(context, getString(R.string.ConversationActivity_added_to_home_screen), Toast.LENGTH_LONG).show(); - } - } - }.execute(); + private static void addIconToHomeScreen(@NonNull Context context, + @NonNull Bitmap bitmap, + @NonNull Recipient recipient) + { + IconCompat icon = IconCompat.createWithAdaptiveBitmap(bitmap); + String name = recipient.isLocalNumber() ? context.getString(R.string.note_to_self) + : recipient.getDisplayName(context); + + ShortcutInfoCompat shortcutInfoCompat = new ShortcutInfoCompat.Builder(context, recipient.getId().serialize() + '-' + System.currentTimeMillis()) + .setShortLabel(name) + .setIcon(icon) + .setIntent(ShortcutLauncherActivity.createIntent(context, recipient.getId())) + .build(); + + if (ShortcutManagerCompat.requestPinShortcut(context, shortcutInfoCompat, null)) { + Toast.makeText(context, context.getString(R.string.ConversationActivity_added_to_home_screen), Toast.LENGTH_LONG).show(); + } + + bitmap.recycle(); } private void handleSearch() { @@ -1123,7 +1140,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private void handleEditPushGroup() { Intent intent = new Intent(ConversationActivity.this, GroupCreateActivity.class); - intent.putExtra(GroupCreateActivity.GROUP_ID_EXTRA, recipient.get().requireGroupId()); + intent.putExtra(GroupCreateActivity.GROUP_ID_EXTRA, recipient.get().requireGroupId().toString()); startActivityForResult(intent, GROUP_EDIT); } @@ -1787,7 +1804,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity inputPanel.setMediaKeyboardToggleMode(true); TooltipPopup.forTarget(inputPanel.getMediaKeyboardToggleAnchorView()) - .setBackgroundTint(getResources().getColor(R.color.core_blue)) + .setBackgroundTint(getResources().getColor(R.color.core_ultramarine)) .setTextColor(getResources().getColor(R.color.core_white)) .setText(R.string.ConversationActivity_new_say_it_with_stickers) .setOnDismissListener(() -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 5b09be243..ae6185788 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -336,10 +336,11 @@ public class ConversationFragment extends Fragment return; } - Recipient recipient = recipientInfo.getRecipient(); - boolean isSelf = Recipient.self().equals(recipient); - int memberCount = recipientInfo.getGroupMemberCount(); - List groups = recipientInfo.getSharedGroups(); + Recipient recipient = recipientInfo.getRecipient(); + boolean isSelf = Recipient.self().equals(recipient); + int memberCount = recipientInfo.getGroupMemberCount(); + int pendingMemberCount = recipientInfo.getGroupPendingMemberCount(); + List groups = recipientInfo.getSharedGroups(); if (recipient != null) { conversationBanner.setAvatar(GlideApp.with(context), recipient); @@ -348,7 +349,14 @@ public class ConversationFragment extends Fragment conversationBanner.setTitle(title); if (recipient.isGroup()) { - conversationBanner.setSubtitle(context.getResources().getQuantityString(R.plurals.MessageRequestProfileView_members, memberCount, memberCount)); + if (pendingMemberCount > 0) { + conversationBanner.setSubtitle(context.getResources() + .getQuantityString(R.plurals.MessageRequestProfileView_members_and_invited, memberCount, + memberCount, pendingMemberCount)); + } else { + 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 { @@ -1015,7 +1023,7 @@ public class ConversationFragment extends Fragment TooltipPopup.forTarget(requireActivity().findViewById(R.id.menu_context_reply)) .setText(text) .setTextColor(getResources().getColor(R.color.core_white)) - .setBackgroundTint(getResources().getColor(R.color.core_blue)) + .setBackgroundTint(getResources().getColor(R.color.core_ultramarine)) .show(TooltipPopup.POSITION_BELOW); TextSecurePreferences.setHasSeenSwipeToReplyTooltip(requireContext(), true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index c33e59c17..7ae320af1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -408,10 +408,10 @@ public class ConversationItem extends LinearLayout implements BindableConversati private void setAudioViewTint(MessageRecord messageRecord, Recipient recipient) { if (messageRecord.isOutgoing()) { - if (DynamicTheme.LIGHT.equals(TextSecurePreferences.getTheme(context))) { - audioViewStub.get().setTint(getContext().getResources().getColor(R.color.core_grey_60), defaultBubbleColor); - } else { + if (DynamicTheme.isDarkTheme(context)) { audioViewStub.get().setTint(Color.WHITE, defaultBubbleColor); + } else { + audioViewStub.get().setTint(getContext().getResources().getColor(R.color.core_grey_60), defaultBubbleColor); } } else { audioViewStub.get().setTint(Color.WHITE, recipient.getColor().toConversationColor(context)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationStickerViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationStickerViewModel.java index 8f8ff2a69..2403e4788 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationStickerViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationStickerViewModel.java @@ -10,6 +10,7 @@ import android.os.Handler; import androidx.annotation.NonNull; import android.text.TextUtils; +import org.thoughtcrime.securesms.components.emoji.EmojiUtil; import org.thoughtcrime.securesms.database.CursorList; import org.thoughtcrime.securesms.database.DatabaseContentProviders; import org.thoughtcrime.securesms.database.model.StickerRecord; @@ -17,21 +18,21 @@ import org.thoughtcrime.securesms.stickers.StickerSearchRepository; import org.thoughtcrime.securesms.util.CloseableLiveData; import org.thoughtcrime.securesms.util.Throttler; +import java.util.List; + class ConversationStickerViewModel extends ViewModel { - private static final int SEARCH_LIMIT = 10; - - private final Application application; - private final StickerSearchRepository repository; - private final CloseableLiveData> stickers; - private final MutableLiveData stickersAvailable; - private final Throttler availabilityThrottler; - private final ContentObserver packObserver; + private final Application application; + private final StickerSearchRepository repository; + private final MutableLiveData> stickers; + private final MutableLiveData stickersAvailable; + private final Throttler availabilityThrottler; + private final ContentObserver packObserver; private ConversationStickerViewModel(@NonNull Application application, @NonNull StickerSearchRepository repository) { this.application = application; this.repository = repository; - this.stickers = new CloseableLiveData<>(); + this.stickers = new MutableLiveData<>(); this.stickersAvailable = new MutableLiveData<>(); this.availabilityThrottler = new Throttler(500); this.packObserver = new ContentObserver(new Handler()) { @@ -44,7 +45,7 @@ class ConversationStickerViewModel extends ViewModel { application.getContentResolver().registerContentObserver(DatabaseContentProviders.StickerPack.CONTENT_URI, true, packObserver); } - @NonNull LiveData> getStickerResults() { + @NonNull LiveData> getStickerResults() { return stickers; } @@ -54,7 +55,7 @@ class ConversationStickerViewModel extends ViewModel { } void onInputTextUpdated(@NonNull String text) { - if (TextUtils.isEmpty(text) || text.length() > SEARCH_LIMIT) { + if (TextUtils.isEmpty(text) || text.length() > EmojiUtil.MAX_EMOJI_LENGTH) { stickers.setValue(CursorList.emptyList()); } else { repository.searchByEmoji(text, stickers::postValue); @@ -63,7 +64,6 @@ class ConversationStickerViewModel extends ViewModel { @Override protected void onCleared() { - stickers.close(); application.getContentResolver().unregisterContentObserver(packObserver); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index d6a7ba707..ddc929612 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -6,26 +6,27 @@ import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.graphics.Bitmap; +import android.text.TextUtils; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import android.text.TextUtils; import com.annimon.stream.Stream; import net.sqlcipher.database.SQLiteDatabase; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.BitmapUtil; -import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; import java.io.Closeable; -import java.io.IOException; import java.security.SecureRandom; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; import java.util.List; @@ -94,9 +95,9 @@ public class GroupDatabase extends Database { } } - public Optional getGroup(String groupId) { + public Optional getGroup(@NonNull GroupId groupId) { try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, GROUP_ID + " = ?", - new String[] {groupId}, + new String[] {groupId.toString()}, null, null, null)) { if (cursor != null && cursor.moveToNext()) { @@ -112,7 +113,7 @@ public class GroupDatabase extends Database { return Optional.fromNullable(reader.getCurrent()); } - public boolean isUnknownGroup(String groupId) { + public boolean isUnknownGroup(@NonNull GroupId groupId) { Optional group = getGroup(groupId); if (!group.isPresent()) { @@ -142,7 +143,7 @@ public class GroupDatabase extends Database { return new Reader(cursor); } - public String getOrCreateGroupForMembers(List members, boolean mms) { + public GroupId getOrCreateGroupForMembers(List members, boolean mms) { Collections.sort(members); Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[] {GROUP_ID}, @@ -151,9 +152,9 @@ public class GroupDatabase extends Database { null, null, null); try { if (cursor != null && cursor.moveToNext()) { - return cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID)); + return GroupId.parse(cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID))); } else { - String groupId = GroupUtil.getEncodedId(allocateGroupId(), mms); + GroupId groupId = allocateGroupId(mms); create(groupId, null, members, null, null); return groupId; } @@ -163,25 +164,31 @@ public class GroupDatabase extends Database { } public List getGroupNamesContainingMember(RecipientId recipientId) { + return Stream.of(getGroupsContainingMember(recipientId)) + .map(GroupRecord::getTitle) + .toList(); + } + + public List getGroupsContainingMember(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 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, projection, query, args, null, null, orderBy)) { + List groups = new LinkedList<>(); + + try (Cursor cursor = database.query(table, null, query, args, null, null, orderBy)) { while (cursor != null && cursor.moveToNext()) { List members = Util.split(cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS)), ","); if (members.contains(recipientId.serialize())) { - groupNames.add(cursor.getString(cursor.getColumnIndexOrThrow(TITLE))); + groups.add(new Reader(cursor).getCurrent()); } } } - return groupNames; + return groups; } public Reader getGroups() { @@ -190,7 +197,7 @@ public class GroupDatabase extends Database { return new Reader(cursor); } - public @NonNull List getGroupMembers(String groupId, boolean includeSelf) { + public @NonNull List getGroupMembers(@NonNull GroupId groupId, boolean includeSelf) { List members = getCurrentMembers(groupId); List recipients = new LinkedList<>(); @@ -205,14 +212,14 @@ public class GroupDatabase extends Database { return recipients; } - public void create(@NonNull String groupId, @Nullable String title, @NonNull List members, + public void create(@NonNull GroupId groupId, @Nullable String title, @NonNull List members, @Nullable SignalServiceAttachmentPointer avatar, @Nullable String relay) { Collections.sort(members); ContentValues contentValues = new ContentValues(); contentValues.put(RECIPIENT_ID, DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId).serialize()); - contentValues.put(GROUP_ID, groupId); + contentValues.put(GROUP_ID, groupId.toString()); contentValues.put(TITLE, title); contentValues.put(MEMBERS, RecipientId.toSerializedList(members)); @@ -226,7 +233,7 @@ public class GroupDatabase extends Database { contentValues.put(AVATAR_RELAY, relay); contentValues.put(TIMESTAMP, System.currentTimeMillis()); contentValues.put(ACTIVE, 1); - contentValues.put(MMS, GroupUtil.isMmsGroup(groupId)); + contentValues.put(MMS, groupId.isMmsGroup()); databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, contentValues); @@ -236,7 +243,7 @@ public class GroupDatabase extends Database { notifyConversationListListeners(); } - public void update(String groupId, String title, SignalServiceAttachmentPointer avatar) { + public void update(@NonNull GroupId groupId, String title, SignalServiceAttachmentPointer avatar) { ContentValues contentValues = new ContentValues(); if (title != null) contentValues.put(TITLE, title); @@ -249,7 +256,7 @@ public class GroupDatabase extends Database { databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?", - new String[] {groupId}); + new String[] {groupId.toString()}); RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); Recipient.live(groupRecipient).refresh(); @@ -257,21 +264,21 @@ public class GroupDatabase extends Database { notifyConversationListListeners(); } - public void updateTitle(String groupId, String title) { + public void updateTitle(@NonNull GroupId groupId, String title) { ContentValues contentValues = new ContentValues(); contentValues.put(TITLE, title); databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?", - new String[] {groupId}); + new String[] {groupId.toString()}); RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); Recipient.live(groupRecipient).refresh(); } - public void updateAvatar(String groupId, Bitmap avatar) { + public void updateAvatar(@NonNull GroupId groupId, @Nullable Bitmap avatar) { updateAvatar(groupId, BitmapUtil.toByteArray(avatar)); } - public void updateAvatar(String groupId, byte[] avatar) { + public void updateAvatar(@NonNull GroupId groupId, @Nullable byte[] avatar) { long avatarId; if (avatar != null) avatarId = Math.abs(new SecureRandom().nextLong()); @@ -283,13 +290,13 @@ public class GroupDatabase extends Database { contentValues.put(AVATAR_ID, avatarId); databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?", - new String[] {groupId}); + new String[] {groupId.toString()}); RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); Recipient.live(groupRecipient).refresh(); } - public void updateMembers(String groupId, List members) { + public void updateMembers(@NonNull GroupId groupId, List members) { Collections.sort(members); ContentValues contents = new ContentValues(); @@ -297,13 +304,13 @@ public class GroupDatabase extends Database { contents.put(ACTIVE, 1); databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", - new String[] {groupId}); + new String[] {groupId.toString()}); RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); Recipient.live(groupRecipient).refresh(); } - public void remove(String groupId, RecipientId source) { + public void remove(@NonNull GroupId groupId, RecipientId source) { List currentMembers = getCurrentMembers(groupId); currentMembers.remove(source); @@ -311,19 +318,19 @@ public class GroupDatabase extends Database { contents.put(MEMBERS, RecipientId.toSerializedList(currentMembers)); databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", - new String[] {groupId}); + new String[] {groupId.toString()}); RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); Recipient.live(groupRecipient).refresh(); } - private List getCurrentMembers(String groupId) { + private List getCurrentMembers(@NonNull GroupId groupId) { Cursor cursor = null; try { cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[] {MEMBERS}, GROUP_ID + " = ?", - new String[] {groupId}, + new String[] {groupId.toString()}, null, null, null); if (cursor != null && cursor.moveToFirst()) { @@ -338,23 +345,22 @@ public class GroupDatabase extends Database { } } - public boolean isActive(String groupId) { + public boolean isActive(@NonNull GroupId groupId) { Optional record = getGroup(groupId); return record.isPresent() && record.get().isActive(); } - public void setActive(String groupId, boolean active) { + public void setActive(@NonNull GroupId groupId, boolean active) { SQLiteDatabase database = databaseHelper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put(ACTIVE, active ? 1 : 0); - database.update(TABLE_NAME, values, GROUP_ID + " = ?", new String[] {groupId}); + database.update(TABLE_NAME, values, GROUP_ID + " = ?", new String[] {groupId.toString()}); } - - public byte[] allocateGroupId() { + public static GroupId allocateGroupId(boolean mms) { byte[] groupId = new byte[16]; new SecureRandom().nextBytes(groupId); - return groupId; + return mms ? GroupId.mms(groupId) : GroupId.v1(groupId); } public static class Reader implements Closeable { @@ -378,7 +384,7 @@ public class GroupDatabase extends Database { return null; } - return new GroupRecord(cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID)), + return new GroupRecord(GroupId.parse(cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID))), RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))), cursor.getString(cursor.getColumnIndexOrThrow(TITLE)), cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS)), @@ -401,7 +407,7 @@ public class GroupDatabase extends Database { public static class GroupRecord { - private final String id; + private final GroupId id; private final RecipientId recipientId; private final String title; private final List members; @@ -414,7 +420,7 @@ public class GroupDatabase extends Database { private final boolean active; private final boolean mms; - public GroupRecord(String id, @NonNull RecipientId recipientId, String title, String members, byte[] avatar, + public GroupRecord(@NonNull GroupId id, @NonNull RecipientId recipientId, String title, String members, byte[] avatar, long avatarId, byte[] avatarKey, String avatarContentType, String relay, boolean active, byte[] avatarDigest, boolean mms) { @@ -434,22 +440,14 @@ public class GroupDatabase extends Database { else this.members = new LinkedList<>(); } - public byte[] getId() { - try { - return GroupUtil.getDecodedId(id); - } catch (IOException ioe) { - throw new AssertionError(ioe); - } + public GroupId getId() { + return id; } public @NonNull RecipientId getRecipientId() { return recipientId; } - public String getEncodedId() { - return id; - } - public String getTitle() { return title; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index 37d12d224..ce8031b56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -17,29 +17,30 @@ import net.sqlcipher.database.SQLiteDatabase; import org.signal.zkgroup.profiles.ProfileKey; import org.signal.zkgroup.profiles.ProfileKeyCredential; import org.thoughtcrime.securesms.color.MaterialColor; -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.groups.GroupId; 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.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.RecordUpdate; +import org.thoughtcrime.securesms.storage.StorageSyncModels; import org.thoughtcrime.securesms.util.Base64; -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.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.storage.SignalAccountRecord; 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.storage.StorageId; import org.whispersystems.signalservice.api.util.UuidUtil; import java.io.Closeable; @@ -93,7 +94,7 @@ public class RecipientDatabase extends Database { private static final String FORCE_SMS_SELECTION = "force_sms_selection"; private static final String UUID_CAPABILITY = "uuid_supported"; private static final String GROUPS_V2_CAPABILITY = "gv2_capability"; - private static final String STORAGE_SERVICE_KEY = "storage_service_key"; + private static final String STORAGE_SERVICE_ID = "storage_service_key"; private static final String DIRTY = "dirty"; private static final String PROFILE_GIVEN_NAME = "signal_profile_name"; private static final String PROFILE_FAMILY_NAME = "profile_family_name"; @@ -114,7 +115,7 @@ public class RecipientDatabase extends Database { UNIDENTIFIED_ACCESS_MODE, FORCE_SMS_SELECTION, UUID_CAPABILITY, GROUPS_V2_CAPABILITY, - STORAGE_SERVICE_KEY, DIRTY + STORAGE_SERVICE_ID, DIRTY }; private static final String[] RECIPIENT_FULL_PROJECTION = ArrayUtils.concat( @@ -132,7 +133,7 @@ public class RecipientDatabase extends Database { }; private static final String[] ID_PROJECTION = new String[]{ID}; - private static final String[] SEARCH_PROJECTION = new String[]{ID, SYSTEM_DISPLAY_NAME, PHONE, EMAIL, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, REGISTERED, "COALESCE(" + PROFILE_JOINED_NAME + ", " + PROFILE_GIVEN_NAME + ") AS " + SEARCH_PROFILE_NAME, "COALESCE(" + SYSTEM_DISPLAY_NAME + ", " + PROFILE_JOINED_NAME + ", " + PROFILE_GIVEN_NAME + ", " + USERNAME + ") AS " + SORT_NAME}; + private static final String[] SEARCH_PROJECTION = new String[]{ID, SYSTEM_DISPLAY_NAME, PHONE, EMAIL, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, REGISTERED, "COALESCE(" + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ") AS " + SEARCH_PROFILE_NAME, "COALESCE(" + nullIfEmpty(SYSTEM_DISPLAY_NAME) + ", " + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ", " + nullIfEmpty(USERNAME) + ") AS " + SORT_NAME}; public static final String[] SEARCH_PROJECTION_NAMES = new String[]{ID, SYSTEM_DISPLAY_NAME, PHONE, EMAIL, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, REGISTERED, SEARCH_PROFILE_NAME, SORT_NAME}; static final List TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) .map(columnName -> TABLE_NAME + "." + columnName) @@ -214,7 +215,7 @@ public class RecipientDatabase extends Database { } } - enum DirtyState { + public enum DirtyState { CLEAN(0), UPDATE(1), INSERT(2), DELETE(3); private final int id; @@ -226,6 +227,10 @@ public class RecipientDatabase extends Database { int getId() { return id; } + + public static DirtyState fromId(int id) { + return values()[id]; + } } public enum GroupType { @@ -283,7 +288,7 @@ public class RecipientDatabase extends Database { FORCE_SMS_SELECTION + " INTEGER DEFAULT 0, " + UUID_CAPABILITY + " INTEGER DEFAULT " + Recipient.Capability.UNKNOWN.serialize() + ", " + GROUPS_V2_CAPABILITY + " INTEGER DEFAULT " + Recipient.Capability.UNKNOWN.serialize() + ", " + - STORAGE_SERVICE_KEY + " TEXT UNIQUE DEFAULT NULL, " + + STORAGE_SERVICE_ID + " TEXT UNIQUE DEFAULT NULL, " + DIRTY + " INTEGER DEFAULT " + DirtyState.CLEAN.getId() + ");"; private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID + @@ -345,18 +350,18 @@ public class RecipientDatabase extends Database { return getOrInsertByColumn(EMAIL, email).recipientId; } - public @NonNull RecipientId getOrInsertFromGroupId(@NonNull String groupId) { - GetOrInsertResult result = getOrInsertByColumn(GROUP_ID, groupId); + public @NonNull RecipientId getOrInsertFromGroupId(@NonNull GroupId groupId) { + GetOrInsertResult result = getOrInsertByColumn(GROUP_ID, groupId.toString()); if (result.neededInsert) { ContentValues values = new ContentValues(); - if (GroupUtil.isMmsGroup(groupId)) { + if (groupId.isMmsGroup()) { 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())); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey())); } update(result.recipientId, values); @@ -399,29 +404,41 @@ public class RecipientDatabase extends Database { } } + public @NonNull DirtyState getDirtyState(@NonNull RecipientId recipientId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { DIRTY }, ID_WHERE, new String[] { recipientId.serialize() }, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return DirtyState.fromId(cursor.getInt(cursor.getColumnIndexOrThrow(DIRTY))); + } + } + + return DirtyState.CLEAN; + } + public @NonNull List getPendingRecipientSyncUpdates() { - String query = DIRTY + " = ? AND " + STORAGE_SERVICE_KEY + " NOT NULL"; - String[] args = new String[] { String.valueOf(DirtyState.UPDATE.getId()) }; + String query = DIRTY + " = ? AND " + STORAGE_SERVICE_ID + " NOT NULL AND " + TABLE_NAME + "." + ID + " != ?"; + String[] args = new String[] { String.valueOf(DirtyState.UPDATE.getId()), Recipient.self().getId().serialize() }; return getRecipientSettings(query, args); } public @NonNull List getPendingRecipientSyncInsertions() { - String query = DIRTY + " = ? AND " + STORAGE_SERVICE_KEY + " NOT NULL"; - String[] args = new String[] { String.valueOf(DirtyState.INSERT.getId()) }; + String query = DIRTY + " = ? AND " + STORAGE_SERVICE_ID + " NOT NULL AND " + TABLE_NAME + "." + ID + " != ?"; + String[] args = new String[] { String.valueOf(DirtyState.INSERT.getId()), Recipient.self().getId().serialize() }; return getRecipientSettings(query, args); } public @NonNull List getPendingRecipientSyncDeletions() { - String query = DIRTY + " = ? AND " + STORAGE_SERVICE_KEY + " NOT NULL"; - String[] args = new String[] { String.valueOf(DirtyState.DELETE.getId()) }; + String query = DIRTY + " = ? AND " + STORAGE_SERVICE_ID + " NOT NULL AND " + TABLE_NAME + "." + ID + " != ?"; + String[] args = new String[] { String.valueOf(DirtyState.DELETE.getId()), Recipient.self().getId().serialize() }; return getRecipientSettings(query, args); } - public @Nullable RecipientSettings getByStorageSyncKey(@NonNull byte[] key) { - List result = getRecipientSettings(STORAGE_SERVICE_KEY + " = ?", new String[] { Base64.encodeBytes(key) }); + public @Nullable RecipientSettings getByStorageId(@NonNull byte[] storageId) { + List result = getRecipientSettings(STORAGE_SERVICE_ID + " = ?", new String[] { Base64.encodeBytes(storageId) }); if (result.size() > 0) { return result.get(0); @@ -430,16 +447,20 @@ public class RecipientDatabase extends Database { return null; } - public void applyStorageSyncKeyUpdates(@NonNull Map keys) { + public void markNeedsSync(@NonNull RecipientId recipientId) { + markDirty(recipientId, DirtyState.UPDATE); + } + + public void applyStorageIdUpdates(@NonNull Map storageIds) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); db.beginTransaction(); try { String query = ID + " = ?"; - for (Map.Entry entry : keys.entrySet()) { + for (Map.Entry entry : storageIds.entrySet()) { ContentValues values = new ContentValues(); - values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(entry.getValue())); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(entry.getValue().getRaw())); values.put(DIRTY, DirtyState.CLEAN.getId()); db.update(TABLE_NAME, values, query, new String[] { entry.getKey().serialize() }); @@ -450,13 +471,14 @@ public class RecipientDatabase extends Database { } } - public void applyStorageSyncUpdates(@NonNull Collection contactInserts, - @NonNull Collection contactUpdates, - @NonNull Collection groupV1Inserts, - @NonNull Collection groupV1Updates) + public void applyStorageSyncUpdates(@NonNull Collection contactInserts, + @NonNull Collection> contactUpdates, + @NonNull Collection groupV1Inserts, + @NonNull Collection> groupV1Updates) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context); + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); db.beginTransaction(); @@ -483,30 +505,29 @@ public class RecipientDatabase extends Database { try { IdentityKey identityKey = new IdentityKey(insert.getIdentityKey().get(), 0); - DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncHelper.remoteToLocalIdentityStatus(insert.getIdentityState())); + DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncModels.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())); - } + threadDatabase.setArchived(recipientId, insert.isArchived()); + Recipient.live(recipientId).refresh(); } } - for (StorageSyncHelper.ContactUpdate update : contactUpdates) { + for (RecordUpdate update : contactUpdates) { ContentValues values = getValuesForStorageContact(update.getNew()); - int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_KEY + " = ?", new String[]{Base64.encodeBytes(update.getOld().getKey())}); + int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(update.getOld().getId().getRaw())}); if (updateCount < 1) { throw new AssertionError("Had an update, but it didn't match any rows!"); } - RecipientId recipientId = getByStorageKeyOrThrow(update.getNew().getKey()); + RecipientId recipientId = getByStorageKeyOrThrow(update.getNew().getId().getRaw()); - if (update.profileKeyChanged()) { + if (StorageSyncHelper.profileKeyChanged(update)) { clearProfileKeyCredential(recipientId); } @@ -515,7 +536,7 @@ public class RecipientDatabase extends Database { 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())); + DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(update.getNew().getIdentityState())); } Optional newIdentityRecord = identityDatabase.getIdentity(recipientId); @@ -532,19 +553,32 @@ public class RecipientDatabase extends Database { } catch (InvalidKeyException e) { Log.w(TAG, "Failed to process identity key during update! Skipping.", e); } + + threadDatabase.setArchived(recipientId, update.getNew().isArchived()); + Recipient.live(recipientId).refresh(); } for (SignalGroupV1Record insert : groupV1Inserts) { db.insertOrThrow(TABLE_NAME, null, getValuesForStorageGroupV1(insert)); + + Recipient recipient = Recipient.externalGroup(context, GroupId.v1(insert.getGroupId())); + + threadDatabase.setArchived(recipient.getId(), insert.isArchived()); + recipient.live().refresh(); } - for (StorageSyncHelper.GroupV1Update update : groupV1Updates) { + for (RecordUpdate update : groupV1Updates) { ContentValues values = getValuesForStorageGroupV1(update.getNew()); - int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_KEY + " = ?", new String[]{Base64.encodeBytes(update.getOld().getKey())}); + int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(update.getOld().getId().getRaw())}); if (updateCount < 1) { throw new AssertionError("Had an update, but it didn't match any rows!"); } + + Recipient recipient = Recipient.externalGroup(context, GroupId.v1(update.getOld().getGroupId())); + + threadDatabase.setArchived(recipient.getId(), update.getNew().isArchived()); + recipient.live().refresh(); } db.setTransactionSuccessful(); @@ -553,6 +587,27 @@ public class RecipientDatabase extends Database { } } + public void applyStorageSyncUpdates(@NonNull StorageId storageId, SignalAccountRecord update) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + ContentValues values = new ContentValues(); + ProfileName profileName = ProfileName.fromParts(update.getGivenName().orNull(), update.getFamilyName().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, update.getProfileKey().transform(Base64::encodeBytes).orNull()); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(update.getId().getRaw())); + values.put(DIRTY, DirtyState.CLEAN.getId()); + + int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(storageId.getRaw())}); + if (updateCount < 1) { + throw new AssertionError("Account update didn't match any rows!"); + } + + Recipient.self().live().refresh(); + } + public void updatePhoneNumbers(@NonNull Map mapping) { if (mapping.isEmpty()) return; @@ -577,7 +632,7 @@ public class RecipientDatabase extends Database { private @NonNull RecipientId getByStorageKeyOrThrow(byte[] storageKey) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); - String query = STORAGE_SERVICE_KEY + " = ?"; + String query = STORAGE_SERVICE_ID + " = ?"; String[] args = new String[]{Base64.encodeBytes(storageKey)}; try (Cursor cursor = db.query(TABLE_NAME, ID_PROJECTION, query, args, null, null, null)) { @@ -598,27 +653,28 @@ public class RecipientDatabase extends Database { } ProfileName profileName = ProfileName.fromParts(contact.getGivenName().orNull(), contact.getFamilyName().orNull()); + String username = contact.getUsername().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().transform(Base64::encodeBytes).orNull()); - values.put(USERNAME, contact.getUsername().orNull()); + values.put(USERNAME, TextUtils.isEmpty(username) ? null : username); values.put(PROFILE_SHARING, contact.isProfileSharingEnabled() ? "1" : "0"); values.put(BLOCKED, contact.isBlocked() ? "1" : "0"); - values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(contact.getKey())); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(contact.getId().getRaw())); values.put(DIRTY, DirtyState.CLEAN.getId()); 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_ID, GroupId.v1(groupV1.getGroupId()).toString()); 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(STORAGE_SERVICE_ID, Base64.encodeBytes(groupV1.getId().getRaw())); values.put(DIRTY, DirtyState.CLEAN.getId()); return values; } @@ -638,40 +694,46 @@ public class RecipientDatabase extends Database { } /** - * @return All storage keys, excluding the ones that need to be deleted. + * @return All storage ids for ContactRecords, excluding the ones that need to be deleted. */ - public List getAllStorageSyncKeys() { - return new ArrayList<>(getAllStorageSyncKeysMap().values()); + public List getContactStorageSyncIds() { + return new ArrayList<>(getContactStorageSyncIdsMap().values()); } /** - * @return All storage keys, excluding the ones that need to be deleted. + * @return All storage IDs for ContactRecords, excluding the ones that need to be deleted. */ - public Map getAllStorageSyncKeysMap() { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - String query = STORAGE_SERVICE_KEY + " NOT NULL AND " + DIRTY + " != ?"; - String[] args = new String[]{String.valueOf(DirtyState.DELETE)}; - Map out = new HashMap<>(); + public @NonNull Map getContactStorageSyncIdsMap() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = STORAGE_SERVICE_ID + " NOT NULL AND " + DIRTY + " != ? AND " + ID + " != ?"; + String[] args = new String[]{String.valueOf(DirtyState.DELETE), Recipient.self().getId().serialize() }; + Map out = new HashMap<>(); - try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID, STORAGE_SERVICE_KEY }, query, args, null, null, null)) { + try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID, STORAGE_SERVICE_ID, GROUP_TYPE }, query, args, null, null, null)) { while (cursor != null && cursor.moveToNext()) { RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))); - String encodedKey = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_SERVICE_KEY)); + String encodedKey = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_SERVICE_ID)); + GroupType groupType = GroupType.fromId(cursor.getInt(cursor.getColumnIndexOrThrow(GROUP_TYPE))); + byte[] key = Base64.decodeOrThrow(encodedKey); - out.put(id, Base64.decodeOrThrow(encodedKey)); + if (groupType == GroupType.NONE) { + out.put(id, StorageId.forContact(key)); + } else { + out.put(id, StorageId.forGroupV1(key)); + } } } return out; } - private @NonNull RecipientSettings getRecipientSettings(@NonNull Cursor cursor) { + private static @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)); + GroupId groupId = GroupId.parseNullable(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)); @@ -699,7 +761,7 @@ public class RecipientDatabase extends Database { boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1; int uuidCapabilityValue = cursor.getInt(cursor.getColumnIndexOrThrow(UUID_CAPABILITY)); int groupsV2CapabilityValue = cursor.getInt(cursor.getColumnIndexOrThrow(GROUPS_V2_CAPABILITY)); - String storageKeyRaw = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_SERVICE_KEY)); + String storageKeyRaw = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_SERVICE_ID)); String identityKeyRaw = cursor.getString(cursor.getColumnIndexOrThrow(IDENTITY_KEY)); int identityStatusRaw = cursor.getInt(cursor.getColumnIndexOrThrow(IDENTITY_STATUS)); @@ -911,7 +973,7 @@ public class RecipientDatabase extends Database { if (update(updateQuery, valuesToSet)) { markDirty(id, DirtyState.UPDATE); Recipient.live(id).refresh(); - ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + StorageSyncHelper.scheduleSyncForDataChange(); return true; } else { return false; @@ -957,7 +1019,7 @@ public class RecipientDatabase extends Database { if (update(id, contentValues)) { markDirty(id, DirtyState.UPDATE); Recipient.live(id).refresh(); - ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + StorageSyncHelper.scheduleSyncForDataChange(); } } @@ -966,6 +1028,11 @@ public class RecipientDatabase extends Database { contentValues.put(SIGNAL_PROFILE_AVATAR, profileAvatar); if (update(id, contentValues)) { Recipient.live(id).refresh(); + + if (id.equals(Recipient.self().getId())) { + markDirty(id, DirtyState.UPDATE); + StorageSyncHelper.scheduleSyncForDataChange(); + } } } @@ -975,7 +1042,7 @@ public class RecipientDatabase extends Database { if (update(id, contentValues)) { markDirty(id, DirtyState.UPDATE); Recipient.live(id).refresh(); - ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + StorageSyncHelper.scheduleSyncForDataChange(); } } @@ -993,6 +1060,7 @@ public class RecipientDatabase extends Database { if (update(id, contentValues)) { markDirty(id, DirtyState.UPDATE); Recipient.live(id).refresh(); + StorageSyncHelper.scheduleSyncForDataChange(); } } @@ -1008,8 +1076,10 @@ public class RecipientDatabase extends Database { ContentValues contentValues = new ContentValues(1); contentValues.put(USERNAME, username); - update(id, contentValues); - Recipient.live(id).refresh(); + if (update(id, contentValues)) { + Recipient.live(id).refresh(); + StorageSyncHelper.scheduleSyncForDataChange(); + } } public void clearUsernameIfExists(@NonNull String username) { @@ -1106,7 +1176,7 @@ public class RecipientDatabase extends Database { contentValues.put(REGISTERED, registeredState.getId()); if (registeredState == RegisteredState.REGISTERED) { - contentValues.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey())); + contentValues.put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey())); } if (update(id, contentValues)) { @@ -1213,9 +1283,8 @@ public class RecipientDatabase extends Database { String selection = BLOCKED + " = ? AND " + REGISTERED + " = ? AND " + GROUP_ID + " IS NULL AND " + - "(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + PROFILE_SHARING + " = ?) AND " + "(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + SEARCH_PROFILE_NAME + " NOT NULL OR " + USERNAME + " NOT NULL)"; - String[] args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), "1" }; + String[] args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()) }; String orderBy = SORT_NAME + ", " + SYSTEM_DISPLAY_NAME + ", " + SEARCH_PROFILE_NAME + ", " + USERNAME + ", " + PHONE; return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy); @@ -1228,14 +1297,13 @@ public class RecipientDatabase extends Database { String selection = BLOCKED + " = ? AND " + REGISTERED + " = ? AND " + GROUP_ID + " IS NULL AND " + - "(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + PROFILE_SHARING + " = ? OR " + USERNAME + " NOT NULL) AND " + "(" + PHONE + " LIKE ? OR " + SYSTEM_DISPLAY_NAME + " LIKE ? OR " + SEARCH_PROFILE_NAME + " LIKE ? OR " + USERNAME + " LIKE ?" + ")"; - String[] args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), "1", query, query, query, query }; + String[] args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), query, query, query, query }; String orderBy = SORT_NAME + ", " + SYSTEM_DISPLAY_NAME + ", " + SEARCH_PROFILE_NAME + ", " + PHONE; return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy); @@ -1338,10 +1406,10 @@ public class RecipientDatabase extends Database { db.update(TABLE_NAME, setBlocked, UUID + " = ?", new String[] { uuid }); } - List groupIdStrings = Stream.of(groupIds).map(g -> GroupUtil.getEncodedId(g, false)).toList(); + List groupIdStrings = Stream.of(groupIds).map(GroupId::v1).toList(); - for (String groupId : groupIdStrings) { - db.update(TABLE_NAME, setBlocked, GROUP_ID + " = ?", new String[] { groupId }); + for (GroupId groupId : groupIdStrings) { + db.update(TABLE_NAME, setBlocked, GROUP_ID + " = ?", new String[] { groupId.toString() }); } db.setTransactionSuccessful(); @@ -1359,7 +1427,7 @@ public class RecipientDatabase extends Database { try { for (Map.Entry entry : keys.entrySet()) { ContentValues values = new ContentValues(); - values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(entry.getValue())); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(entry.getValue())); db.update(TABLE_NAME, values, ID_WHERE, new String[] { entry.getKey().serialize() }); } @@ -1399,7 +1467,7 @@ public class RecipientDatabase extends Database { query += "(" + DIRTY + " < ? OR " + DIRTY + " = ?)"; args = SqlUtil.appendArg(args, String.valueOf(DirtyState.DELETE.getId())); - contentValues.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey())); + contentValues.put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey())); break; case DELETE: query += "(" + DIRTY + " < ? OR " + DIRTY + " = ?)"; @@ -1528,7 +1596,7 @@ public class RecipientDatabase extends Database { } private void markAllRelevantEntriesDirty() { - String query = SYSTEM_INFO_PENDING + " = ? AND " + STORAGE_SERVICE_KEY + " NOT NULL AND " + DIRTY + " < ?"; + String query = SYSTEM_INFO_PENDING + " = ? AND " + STORAGE_SERVICE_ID + " NOT NULL AND " + DIRTY + " < ?"; String[] args = new String[] { "1", String.valueOf(DirtyState.UPDATE.getId()) }; ContentValues values = new ContentValues(1); @@ -1553,6 +1621,10 @@ public class RecipientDatabase extends Database { } } + private static @NonNull String nullIfEmpty(String column) { + return "NULLIF(" + column + ", '')"; + } + public interface ColorUpdater { MaterialColor update(@NonNull String name, @Nullable String color); } @@ -1563,7 +1635,7 @@ public class RecipientDatabase extends Database { private final String username; private final String e164; private final String email; - private final String groupId; + private final GroupId groupId; private final GroupType groupType; private final boolean blocked; private final long muteUntil; @@ -1590,7 +1662,7 @@ public class RecipientDatabase extends Database { private final Recipient.Capability uuidCapability; private final Recipient.Capability groupsV2Capability; private final InsightsBannerTier insightsBannerTier; - private final byte[] storageKey; + private final byte[] storageId; private final byte[] identityKey; private final IdentityDatabase.VerifiedStatus identityStatus; @@ -1599,7 +1671,7 @@ public class RecipientDatabase extends Database { @Nullable String username, @Nullable String e164, @Nullable String email, - @Nullable String groupId, + @Nullable GroupId groupId, @NonNull GroupType groupType, boolean blocked, long muteUntil, @@ -1626,7 +1698,7 @@ public class RecipientDatabase extends Database { Recipient.Capability uuidCapability, Recipient.Capability groupsV2Capability, @NonNull InsightsBannerTier insightsBannerTier, - @Nullable byte[] storageKey, + @Nullable byte[] storageId, @Nullable byte[] identityKey, @NonNull IdentityDatabase.VerifiedStatus identityStatus) { @@ -1662,7 +1734,7 @@ public class RecipientDatabase extends Database { this.uuidCapability = uuidCapability; this.groupsV2Capability = groupsV2Capability; this.insightsBannerTier = insightsBannerTier; - this.storageKey = storageKey; + this.storageId = storageId; this.identityKey = identityKey; this.identityStatus = identityStatus; } @@ -1687,7 +1759,7 @@ public class RecipientDatabase extends Database { return email; } - public @Nullable String getGroupId() { + public @Nullable GroupId getGroupId() { return groupId; } @@ -1795,8 +1867,8 @@ public class RecipientDatabase extends Database { return groupsV2Capability; } - public @Nullable byte[] getStorageKey() { - return storageKey; + public @Nullable byte[] getStorageId() { + return storageId; } public @Nullable byte[] getIdentityKey() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsMigrator.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsMigrator.java index 0ba794ab2..01e47b2a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsMigrator.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsMigrator.java @@ -20,6 +20,7 @@ import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteException; import android.net.Uri; + import androidx.annotation.Nullable; import com.annimon.stream.Stream; @@ -27,6 +28,7 @@ import com.annimon.stream.Stream; import net.sqlcipher.database.SQLiteDatabase; import net.sqlcipher.database.SQLiteStatement; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -231,7 +233,7 @@ public class SmsMigrator { List recipientIds = Stream.of(ourRecipients).map(Recipient::getId).toList(); - String ourGroupId = DatabaseFactory.getGroupDatabase(context).getOrCreateGroupForMembers(recipientIds, true); + GroupId ourGroupId = DatabaseFactory.getGroupDatabase(context).getOrCreateGroupForMembers(recipientIds, true); RecipientId ourGroupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(ourGroupId); Recipient ourGroupRecipient = Recipient.resolved(ourGroupRecipientId); long ourThreadId = threadDatabase.getThreadIdFor(ourGroupRecipient, ThreadDatabase.DistributionTypes.CONVERSATION); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/StickerDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/StickerDatabase.java index c77d90f27..871f25b9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/StickerDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/StickerDatabase.java @@ -30,6 +30,8 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; import java.util.List; public class StickerDatabase extends Database { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/StorageKeyDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/StorageKeyDatabase.java index f80b3a86b..c94fae16b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/StorageKeyDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/StorageKeyDatabase.java @@ -12,11 +12,11 @@ import net.sqlcipher.database.SQLiteDatabase; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.util.Base64; import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.api.storage.StorageId; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; import java.util.List; /** @@ -28,11 +28,11 @@ public class StorageKeyDatabase extends Database { private static final String TABLE_NAME = "storage_key"; private static final String ID = "_id"; private static final String TYPE = "type"; - private static final String KEY = "key"; + private static final String STORAGE_ID = "key"; - public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + - TYPE + " INTEGER, " + - KEY + " TEXT UNIQUE)"; + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + TYPE + " INTEGER, " + + STORAGE_ID + " TEXT UNIQUE)"; public static final String[] CREATE_INDEXES = new String[] { "CREATE INDEX IF NOT EXISTS storage_key_type_index ON " + TABLE_NAME + " (" + TYPE + ");" @@ -42,14 +42,15 @@ public class StorageKeyDatabase extends Database { super(context, databaseHelper); } - public List getAllKeys() { - List keys = new ArrayList<>(); + public List getAllKeys() { + List keys = new ArrayList<>(); try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null)) { while (cursor != null && cursor.moveToNext()) { - String keyEncoded = cursor.getString(cursor.getColumnIndexOrThrow(KEY)); + String keyEncoded = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_ID)); + int type = cursor.getInt(cursor.getColumnIndexOrThrow(TYPE)); try { - keys.add(Base64.decode(keyEncoded)); + keys.add(StorageId.forType(Base64.decode(keyEncoded), type)); } catch (IOException e) { throw new AssertionError(e); } @@ -59,14 +60,14 @@ public class StorageKeyDatabase extends Database { return keys; } - public @Nullable SignalStorageRecord getByKey(@NonNull byte[] key) { - String query = KEY + " = ?"; - String[] args = new String[] { Base64.encodeBytes(key) }; + public @Nullable SignalStorageRecord getById(@NonNull byte[] rawId) { + String query = STORAGE_ID + " = ?"; + String[] args = new String[] { Base64.encodeBytes(rawId) }; try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, query, args, null, null, null)) { if (cursor != null && cursor.moveToFirst()) { int type = cursor.getInt(cursor.getColumnIndexOrThrow(TYPE)); - return SignalStorageRecord.forUnknown(key, type); + return SignalStorageRecord.forUnknown(StorageId.forType(rawId, type)); } else { return null; } @@ -83,15 +84,15 @@ public class StorageKeyDatabase extends Database { for (SignalStorageRecord insert : inserts) { ContentValues values = new ContentValues(); values.put(TYPE, insert.getType()); - values.put(KEY, Base64.encodeBytes(insert.getKey())); + values.put(STORAGE_ID, Base64.encodeBytes(insert.getId().getRaw())); db.insert(TABLE_NAME, null, values); } - String deleteQuery = KEY + " = ?"; + String deleteQuery = STORAGE_ID + " = ?"; for (SignalStorageRecord delete : deletes) { - String[] args = new String[] { Base64.encodeBytes(delete.getKey()) }; + String[] args = new String[] { Base64.encodeBytes(delete.getId().getRaw()) }; db.delete(TABLE_NAME, deleteQuery, args); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 1091bdd22..4dee70da1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -45,6 +45,7 @@ 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.storage.StorageSyncHelper; import org.thoughtcrime.securesms.util.JsonUtils; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -53,6 +54,7 @@ import org.whispersystems.libsignal.util.guava.Optional; import java.io.Closeable; import java.io.IOException; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; @@ -422,10 +424,48 @@ public class ThreadDatabase extends Database { return getConversationList("1"); } + public boolean isArchived(@NonNull RecipientId recipientId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = RECIPIENT_ID + " = ?"; + String[] args = new String[]{ recipientId.serialize() }; + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { ARCHIVED }, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(cursor.getColumnIndexOrThrow(ARCHIVED)) == 1; + } + } + + return false; + } + + public void setArchived(@NonNull RecipientId recipientId, boolean status) { + setArchived(Collections.singletonMap(recipientId, status)); + } + + public void setArchived(@NonNull Map status) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + db.beginTransaction(); + try { + String query = RECIPIENT_ID + " = ?"; + + for (Map.Entry entry : status.entrySet()) { + ContentValues values = new ContentValues(1); + values.put(ARCHIVED, entry.getValue() ? "1" : "0"); + db.update(TABLE_NAME, values, query, new String[] { entry.getKey().serialize() }); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + notifyConversationListListeners(); + } + } + public @NonNull Set getArchivedRecipients() { Set archived = new HashSet<>(); - try (Cursor cursor = DatabaseFactory.getThreadDatabase(context).getArchivedConversationList()) { + try (Cursor cursor = getArchivedConversationList()) { while (cursor != null && cursor.moveToNext()) { archived.add(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.RECIPIENT_ID)))); } @@ -488,6 +528,12 @@ public class ThreadDatabase extends Database { db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId + ""}); notifyConversationListListeners(); + + Recipient recipient = getRecipientForThreadId(threadId); + if (recipient != null) { + DatabaseFactory.getRecipientDatabase(context).markNeedsSync(recipient.getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + } } public void unarchiveConversation(long threadId) { @@ -497,6 +543,12 @@ public class ThreadDatabase extends Database { db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId + ""}); notifyConversationListListeners(); + + Recipient recipient = getRecipientForThreadId(threadId); + if (recipient != null) { + DatabaseFactory.getRecipientDatabase(context).markNeedsSync(recipient.getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + } } public void setLastSeen(long threadId) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java index 1cd078486..f0f91150f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java @@ -10,9 +10,9 @@ import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.net.Uri; import android.provider.ContactsContract; -import androidx.annotation.Nullable; import android.text.TextUtils; -import org.thoughtcrime.securesms.logging.Log; + +import androidx.annotation.Nullable; import com.fasterxml.jackson.annotation.JsonProperty; import com.google.i18n.phonenumbers.NumberParseException; @@ -35,13 +35,14 @@ import org.thoughtcrime.securesms.database.PushDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.migrations.LegacyMigrationJob; import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.phonenumbers.NumberUtil; import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.DelimiterUtil; -import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.Hex; import org.thoughtcrime.securesms.util.JsonUtils; import org.thoughtcrime.securesms.util.MediaUtil; @@ -1274,7 +1275,7 @@ public class ClassicOpenHelper extends SQLiteOpenHelper { while (cursor != null && cursor.moveToNext()) { String address = cursor.getString(cursor.getColumnIndexOrThrow("recipient_ids")); - if (!TextUtils.isEmpty(address) && !GroupUtil.isEncodedGroup(address) && !NumberUtil.isValidEmail(address)) { + if (!TextUtils.isEmpty(address) && !GroupId.isEncodedGroup(address) && !NumberUtil.isValidEmail(address)) { Uri lookup = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(address)); try (Cursor contactCursor = context.getContentResolver().query(lookup, new String[] {ContactsContract.PhoneLookup.DISPLAY_NAME, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/RecipientIdMigrationHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/RecipientIdMigrationHelper.java index a1e4e6610..eed375d65 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/RecipientIdMigrationHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/RecipientIdMigrationHelper.java @@ -9,10 +9,10 @@ import androidx.annotation.Nullable; import net.sqlcipher.database.SQLiteDatabase; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.phonenumbers.NumberUtil; import org.thoughtcrime.securesms.util.DelimiterUtil; -import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.Util; import java.util.HashSet; @@ -153,7 +153,7 @@ public class RecipientIdMigrationHelper { try (Cursor cursor = db.query("recipient_preferences", null, null, null, null, null, null)) { while (cursor != null && cursor.moveToNext()) { String address = cursor.getString(cursor.getColumnIndexOrThrow("recipient_ids")); - boolean isGroup = GroupUtil.isEncodedGroup(address); + boolean isGroup = GroupId.isEncodedGroup(address); boolean isEmail = !isGroup && NumberUtil.isValidEmail(address); boolean isPhone = !isGroup && !isEmail; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 4e6992480..f4ae251b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -21,7 +21,8 @@ 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.profiles.ProfileName; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.crypto.DatabaseSecret; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.AttachmentDatabase; @@ -44,20 +45,20 @@ import org.thoughtcrime.securesms.database.StickerDatabase; import org.thoughtcrime.securesms.database.StorageKeyDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.util.Base64; -import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.SqlUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import java.io.File; -import java.io.FilenameFilter; import java.util.List; public class SQLCipherOpenHelper extends SQLiteOpenHelper { @@ -116,8 +117,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int STORAGE_SERVICE_ACTIVE = 50; private static final int GROUPS_V2_RECIPIENT_CAPABILITY = 51; private static final int TRANSFER_FILE_CLEANUP = 52; + private static final int PROFILE_DATA_MIGRATION = 53; - private static final int DATABASE_VERSION = 52; + private static final int DATABASE_VERSION = 53; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -350,7 +352,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { String displayName = NotificationChannels.getChannelDisplayNameFor(context, systemName, profileName, null, address); boolean vibrateEnabled = vibrateState == 0 ? TextSecurePreferences.isNotificationVibrateEnabled(context) : vibrateState == 1; - if (GroupUtil.isEncodedGroup(address)) { + if (GroupId.isEncodedGroup(address)) { try(Cursor groupCursor = db.rawQuery("SELECT title FROM groups WHERE group_id = ?", new String[] { address })) { if (groupCursor != null && groupCursor.moveToFirst()) { String title = groupCursor.getString(groupCursor.getColumnIndexOrThrow("title")); @@ -546,11 +548,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { values.put("phone", localNumber); values.put("registered", 1); values.put("profile_sharing", 1); - values.put("signal_profile_name", TextSecurePreferences.getProfileName(context).getGivenName()); db.insert("recipient", null, values); } else { - db.execSQL("UPDATE recipient SET registered = ?, profile_sharing = ?, signal_profile_name = ? WHERE phone = ?", - new String[] { "1", "1", TextSecurePreferences.getProfileName(context).getGivenName(), localNumber }); + db.execSQL("UPDATE recipient SET registered = ?, profile_sharing = ? WHERE phone = ?", + new String[] { "1", "1", localNumber }); } } } @@ -790,6 +791,17 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { } } + if (oldVersion < PROFILE_DATA_MIGRATION) { + String localNumber = TextSecurePreferences.getLocalNumber(context); + if (localNumber != null) { + String encodedProfileName = PreferenceManager.getDefaultSharedPreferences(context).getString("pref_profile_name", null); + ProfileName profileName = ProfileName.fromSerialized(encodedProfileName); + + db.execSQL("UPDATE recipient SET signal_profile_name = ?, profile_family_name = ?, profile_joined_name = ? WHERE phone = ?", + new String[] { profileName.getGivenName(), profileName.getFamilyName(), profileName.toString(), localNumber }); + } + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/experienceupgrades/StickersIntroFragment.java b/app/src/main/java/org/thoughtcrime/securesms/experienceupgrades/StickersIntroFragment.java deleted file mode 100644 index 917867ef7..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/experienceupgrades/StickersIntroFragment.java +++ /dev/null @@ -1,62 +0,0 @@ -package org.thoughtcrime.securesms.experienceupgrades; - - -import android.content.Context; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; - -import com.airbnb.lottie.LottieAnimationView; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.components.TypingIndicatorView; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob; -import org.thoughtcrime.securesms.util.TextSecurePreferences; - -public class StickersIntroFragment extends Fragment { - - private Controller controller; - - public static StickersIntroFragment newInstance() { - return new StickersIntroFragment(); - } - - public StickersIntroFragment() {} - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - - if (!(getActivity() instanceof Controller)) { - throw new IllegalStateException("Parent activity must implement the Controller interface."); - } - - controller = (Controller) getActivity(); - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.experience_upgrade_stickers_fragment, container, false); - View goButton = view.findViewById(R.id.stickers_experience_go_button); - - ((LottieAnimationView) view.findViewById(R.id.stickers_experience_animation)).playAnimation(); - - goButton.setOnClickListener(v -> controller.onStickersFinished()); - - return view; - } - - public interface Controller { - void onStickersFinished(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/gcm/RestStrategy.java b/app/src/main/java/org/thoughtcrime/securesms/gcm/RestStrategy.java index 1df1ff899..c3822975b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/gcm/RestStrategy.java +++ b/app/src/main/java/org/thoughtcrime/securesms/gcm/RestStrategy.java @@ -5,6 +5,7 @@ import androidx.annotation.WorkerThread; import org.thoughtcrime.securesms.IncomingMessageProcessor; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobmanager.JobTracker; import org.thoughtcrime.securesms.jobs.MarkerJob; @@ -75,7 +76,7 @@ public class RestStrategy implements MessageRetriever.Strategy { jobManager.addListener(markerJob.getId(), new JobTracker.JobListener() { @Override - public void onStateChanged(@NonNull JobTracker.JobState jobState) { + public void onStateChanged(@NonNull Job job, @NonNull JobTracker.JobState jobState) { if (jobState.isComplete()) { jobManager.removeListener(this); latch.countDown(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java index 681cf8cf3..6a8a76ff4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java @@ -108,7 +108,7 @@ public class GiphyActivity extends PassphraseRequiredActionBarActivity } private @ColorInt int getConversationColor() { - return getIntent().getIntExtra(EXTRA_COLOR, ActivityCompat.getColor(this, R.color.signal_primary)); + return getIntent().getIntExtra(EXTRA_COLOR, ActivityCompat.getColor(this, R.color.core_ultramarine)); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedCacheEncoder.java b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedCacheEncoder.java index 361f7bba9..1d546ccf5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedCacheEncoder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedCacheEncoder.java @@ -13,6 +13,7 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.net.SocketException; public class EncryptedCacheEncoder extends EncryptedCoder implements Encoder { @@ -42,12 +43,14 @@ public class EncryptedCacheEncoder extends EncryptedCoder implements Encoder members, @Nullable Bitmap avatar, @Nullable String name) @@ -51,7 +51,7 @@ public final class GroupManager { @WorkerThread public static boolean leaveGroup(@NonNull Context context, @NonNull Recipient groupRecipient) { - String groupId = groupRecipient.requireGroupId(); + GroupId groupId = groupRecipient.requireGroupId(); return V1GroupManager.leaveGroup(context, groupId, groupRecipient); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java index c4e32eb1f..b257b6773 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java @@ -28,7 +28,6 @@ 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; import org.whispersystems.signalservice.api.messages.SignalServiceContent; @@ -63,7 +62,7 @@ public class GroupMessageProcessor { GroupDatabase database = DatabaseFactory.getGroupDatabase(context); SignalServiceGroup group = message.getGroupInfo().get(); - String id = GroupUtil.getEncodedId(group.getGroupId(), false); + GroupId id = GroupId.v1(group.getGroupId()); Optional record = database.getGroup(id); if (record.isPresent() && group.getType() == Type.UPDATE) { @@ -73,7 +72,7 @@ public class GroupMessageProcessor { } else if (record.isPresent() && group.getType() == Type.QUIT) { return handleGroupLeave(context, content, group, record.get(), outgoing); } else if (record.isPresent() && group.getType() == Type.REQUEST_INFO) { - return handleGroupInfoRequest(context, content, group, record.get()); + return handleGroupInfoRequest(context, content, record.get()); } else { Log.w(TAG, "Received unknown type, ignoring..."); return null; @@ -86,7 +85,7 @@ public class GroupMessageProcessor { boolean outgoing) { GroupDatabase database = DatabaseFactory.getGroupDatabase(context); - String id = GroupUtil.getEncodedId(group.getGroupId(), false); + GroupId id = GroupId.v1(group.getGroupId()); GroupContext.Builder builder = createGroupContext(group); builder.setType(GroupContext.Type.UPDATE); @@ -106,7 +105,7 @@ public class GroupMessageProcessor { 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); + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(Recipient.externalGroup(context, id).getId(), true); } return storeMessage(context, content, group, builder.build(), outgoing); @@ -120,7 +119,7 @@ public class GroupMessageProcessor { { GroupDatabase database = DatabaseFactory.getGroupDatabase(context); - String id = GroupUtil.getEncodedId(group.getGroupId(), false); + GroupId id = GroupId.v1(group.getGroupId()); Set recordMembers = new HashSet<>(groupRecord.getMembers()); Set messageMembers = new HashSet<>(); @@ -178,13 +177,12 @@ public class GroupMessageProcessor { private static Long handleGroupInfoRequest(@NonNull Context context, @NonNull SignalServiceContent content, - @NonNull SignalServiceGroup group, @NonNull GroupRecord record) { Recipient sender = Recipient.externalPush(context, content.getSender()); if (record.getMembers().contains(sender.getId())) { - ApplicationDependencies.getJobManager().add(new PushGroupUpdateJob(sender.getId(), group.getGroupId())); + ApplicationDependencies.getJobManager().add(new PushGroupUpdateJob(sender.getId(), record.getId())); } return null; @@ -197,7 +195,7 @@ public class GroupMessageProcessor { boolean outgoing) { GroupDatabase database = DatabaseFactory.getGroupDatabase(context); - String id = GroupUtil.getEncodedId(group.getGroupId(), false); + GroupId id = GroupId.v1(group.getGroupId()); List members = record.getMembers(); GroupContext.Builder builder = createGroupContext(group); @@ -222,13 +220,13 @@ public class GroupMessageProcessor { { if (group.getAvatar().isPresent()) { ApplicationDependencies.getJobManager() - .add(new AvatarDownloadJob(group.getGroupId())); + .add(new AvatarDownloadJob(GroupId.v1(group.getGroupId()))); } try { if (outgoing) { MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); - RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(GroupUtil.getEncodedId(group.getGroupId(), false)); + RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(GroupId.v1(group.getGroupId())); Recipient recipient = Recipient.resolved(recipientId); OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(recipient, storage, null, content.getTimestamp(), 0, false, null, Collections.emptyList(), Collections.emptyList()); long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); @@ -240,7 +238,7 @@ public class GroupMessageProcessor { } else { SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); String body = Base64.encodeBytes(storage.toByteArray()); - IncomingTextMessage incoming = new IncomingTextMessage(Recipient.externalPush(context, content.getSender()).getId(), content.getSenderDevice(), content.getTimestamp(), body, Optional.of(GroupUtil.getEncodedId(group.getGroupId(), false)), 0, content.isNeedsReceipt()); + IncomingTextMessage incoming = new IncomingTextMessage(Recipient.externalPush(context, content.getSender()).getId(), content.getSenderDevice(), content.getTimestamp(), body, Optional.of(GroupId.v1(group.getGroupId())), 0, content.isNeedsReceipt()); IncomingGroupMessage groupMessage = new IncomingGroupMessage(incoming, storage, body); Optional insertResult = smsDatabase.insertMessageInbox(groupMessage); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/V1GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/V1GroupManager.java index ae01f6479..451ea29a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/V1GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/V1GroupManager.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups; import android.content.Context; import android.graphics.Bitmap; import android.net.Uri; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; @@ -11,7 +12,6 @@ import com.google.protobuf.ByteString; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.UriAttachment; -import org.thoughtcrime.securesms.blurhash.BlurHash; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; @@ -28,12 +28,10 @@ import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.MediaUtil; -import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.util.InvalidNumberException; import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; -import java.io.IOException; import java.util.Collections; import java.util.LinkedList; import java.util.List; @@ -49,7 +47,7 @@ final class V1GroupManager { { final byte[] avatarBytes = BitmapUtil.toByteArray(avatar); final GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); - final String groupId = GroupUtil.getEncodedId(groupDatabase.allocateGroupId(), mms); + final GroupId groupId = GroupDatabase.allocateGroupId(mms); final RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); final Recipient groupRecipient = Recipient.resolved(groupRecipientId); @@ -67,7 +65,7 @@ final class V1GroupManager { } static GroupActionResult updateGroup(@NonNull Context context, - @NonNull String groupId, + @NonNull GroupId groupId, @NonNull Set memberAddresses, @Nullable Bitmap avatar, @Nullable String name) @@ -81,7 +79,7 @@ final class V1GroupManager { groupDatabase.updateTitle(groupId, name); groupDatabase.updateAvatar(groupId, avatarBytes); - if (!GroupUtil.isMmsGroup(groupId)) { + if (!groupId.isMmsGroup()) { return sendGroupUpdate(context, groupId, memberAddresses, name, avatarBytes); } else { RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); @@ -92,48 +90,44 @@ final class V1GroupManager { } private static GroupActionResult sendGroupUpdate(@NonNull Context context, - @NonNull String groupId, + @NonNull GroupId groupId, @NonNull Set members, @Nullable String groupName, @Nullable byte[] avatar) { - try { - Attachment avatarAttachment = null; - RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); - Recipient groupRecipient = Recipient.resolved(groupRecipientId); + Attachment avatarAttachment = null; + RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); + Recipient groupRecipient = Recipient.resolved(groupRecipientId); - List uuidMembers = new LinkedList<>(); - List e164Members = new LinkedList<>(); + List uuidMembers = new LinkedList<>(); + List e164Members = new LinkedList<>(); - for (RecipientId member : members) { - Recipient recipient = Recipient.resolved(member); - uuidMembers.add(GroupMessageProcessor.createMember(RecipientUtil.toSignalServiceAddress(context, recipient))); - } - - GroupContext.Builder groupContextBuilder = GroupContext.newBuilder() - .setId(ByteString.copyFrom(GroupUtil.getDecodedId(groupId))) - .setType(GroupContext.Type.UPDATE) - .addAllMembersE164(e164Members) - .addAllMembers(uuidMembers); - if (groupName != null) groupContextBuilder.setName(groupName); - GroupContext groupContext = groupContextBuilder.build(); - - if (avatar != null) { - Uri avatarUri = BlobProvider.getInstance().forData(avatar).createForSingleUseInMemory(); - avatarAttachment = new UriAttachment(avatarUri, MediaUtil.IMAGE_PNG, AttachmentDatabase.TRANSFER_PROGRESS_DONE, avatar.length, null, false, false, null, null, null, null); - } - - OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(groupRecipient, groupContext, avatarAttachment, System.currentTimeMillis(), 0, false, null, Collections.emptyList(), Collections.emptyList()); - long threadId = MessageSender.send(context, outgoingMessage, -1, false, null); - - return new GroupActionResult(groupRecipient, threadId); - } catch (IOException e) { - throw new AssertionError(e); + for (RecipientId member : members) { + Recipient recipient = Recipient.resolved(member); + uuidMembers.add(GroupMessageProcessor.createMember(RecipientUtil.toSignalServiceAddress(context, recipient))); } + + GroupContext.Builder groupContextBuilder = GroupContext.newBuilder() + .setId(ByteString.copyFrom(groupId.getDecodedId())) + .setType(GroupContext.Type.UPDATE) + .addAllMembersE164(e164Members) + .addAllMembers(uuidMembers); + if (groupName != null) groupContextBuilder.setName(groupName); + GroupContext groupContext = groupContextBuilder.build(); + + if (avatar != null) { + Uri avatarUri = BlobProvider.getInstance().forData(avatar).createForSingleUseInMemory(); + avatarAttachment = new UriAttachment(avatarUri, MediaUtil.IMAGE_PNG, AttachmentDatabase.TRANSFER_PROGRESS_DONE, avatar.length, null, false, false, null, null, null, null); + } + + OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(groupRecipient, groupContext, avatarAttachment, System.currentTimeMillis(), 0, false, null, Collections.emptyList(), Collections.emptyList()); + long threadId = MessageSender.send(context, outgoingMessage, -1, false, null); + + return new GroupActionResult(groupRecipient, threadId); } @WorkerThread - static boolean leaveGroup(@NonNull Context context, @NonNull String groupId, @NonNull Recipient groupRecipient) { + static boolean leaveGroup(@NonNull Context context, @NonNull GroupId groupId, @NonNull Recipient groupRecipient) { long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); Optional leaveMessage = GroupUtil.createGroupLeaveMessage(context, groupRecipient); diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java index c66908fc0..750ae7ae6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java @@ -320,7 +320,7 @@ public final class ImageEditorView extends FrameLayout { private EditSession startADrawingSession(@NonNull PointF point) { BezierDrawingRenderer renderer = new BezierDrawingRenderer(color, thickness * Bounds.FULL_BOUNDS.width(), cap, model.findCropRelativeToRoot()); - EditorElement element = new EditorElement(renderer); + EditorElement element = new EditorElement(renderer, EditorModel.Z_DRAWING); model.addElementCentered(element, 1); Matrix elementInverseMatrix = model.findElementInverseMatrix(element, viewMatrix); diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java index 9460907f9..cb30c3106 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java @@ -3,12 +3,15 @@ package org.thoughtcrime.securesms.imageeditor.model; import android.graphics.Matrix; import android.os.Parcel; import android.os.Parcelable; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.thoughtcrime.securesms.imageeditor.Renderer; import org.thoughtcrime.securesms.imageeditor.RendererContext; +import java.util.Collections; +import java.util.Comparator; import java.util.Iterator; import java.util.LinkedList; import java.util.List; @@ -32,10 +35,13 @@ import java.util.UUID; */ public final class EditorElement implements Parcelable { + private static final Comparator Z_ORDER_COMPARATOR = (e1, e2) -> Integer.compare(e1.zOrder, e2.zOrder); + private final UUID id; private final EditorFlags flags; private final Matrix localMatrix = new Matrix(); private final Matrix editorMatrix = new Matrix(); + private final int zOrder; @Nullable private final Renderer renderer; @@ -54,9 +60,14 @@ public final class EditorElement implements Parcelable { private AlphaAnimation alphaAnimation = AlphaAnimation.NULL_1; public EditorElement(@Nullable Renderer renderer) { + this(renderer, 0); + } + + public EditorElement(@Nullable Renderer renderer, int zOrder) { this.id = UUID.randomUUID(); this.flags = new EditorFlags(); this.renderer = renderer; + this.zOrder = zOrder; } private EditorElement(Parcel in) { @@ -64,6 +75,7 @@ public final class EditorElement implements Parcelable { flags = new EditorFlags(in.readInt()); ParcelUtils.readMatrix(localMatrix, in); renderer = in.readParcelable(Renderer.class.getClassLoader()); + zOrder = in.readInt(); in.readTypedList(children, EditorElement.CREATOR); } @@ -127,6 +139,7 @@ public final class EditorElement implements Parcelable { public void addElement(@NonNull EditorElement element) { children.add(element); + Collections.sort(children, Z_ORDER_COMPARATOR); } public Matrix getLocalMatrix() { @@ -328,6 +341,7 @@ public final class EditorElement implements Parcelable { dest.writeInt(this.flags.asInt()); ParcelUtils.writeMatrix(dest, localMatrix); dest.writeParcelable(renderer, flags); + dest.writeInt(zOrder); dest.writeTypedList(children); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java index a102642d9..79cca5bab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java @@ -35,6 +35,10 @@ import java.util.UUID; */ public final class EditorModel implements Parcelable, RendererContext.Ready { + public static final int Z_DRAWING = 0; + public static final int Z_STICKERS = 0; + public static final int Z_TEXT = 1; + private static final Runnable NULL_RUNNABLE = () -> { }; @@ -545,9 +549,17 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { */ @WorkerThread public @NonNull Bitmap render(@NonNull Context context) { + return render(context, null); + } + + /** + * Blocking render of the model. + */ + @WorkerThread + public @NonNull Bitmap render(@NonNull Context context, @Nullable Point size) { EditorElement image = editorElementHierarchy.getFlipRotate(); RectF cropRect = editorElementHierarchy.getCropRect(); - Point outputSize = getOutputSize(); + Point outputSize = size != null ? size : getOutputSize(); Bitmap bitmap = Bitmap.createBitmap(outputSize.x, outputSize.y, Bitmap.Config.ARGB_8888); try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsRepository.java index dee0b3968..e02ff8496 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsRepository.java @@ -67,14 +67,14 @@ public class InsightsRepository implements InsightsDashboardViewModel.Repository public void getUserAvatar(@NonNull Consumer avatarConsumer) { SimpleTask.run(() -> { Recipient self = Recipient.self().resolve(); - String name = Optional.fromNullable(self.getName(context)).or(Optional.fromNullable(TextSecurePreferences.getProfileName(context).toString())).or(""); + String name = Optional.fromNullable(self.getName(context)).or(""); MaterialColor fallbackColor = self.getColor(); if (fallbackColor == ContactColors.UNKNOWN_COLOR && !TextUtils.isEmpty(name)) { fallbackColor = ContactColors.generateFor(name); } - return new InsightsUserAvatar(new ProfileContactPhoto(self.getId(), String.valueOf(TextSecurePreferences.getProfileAvatarId(context))), + return new InsightsUserAvatar(new ProfileContactPhoto(self, self.getProfileAvatar()), fallbackColor, new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40)); }, avatarConsumer::accept); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java index 539677d25..340fad3af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java @@ -85,7 +85,7 @@ class JobController { if (chainExceedsMaximumInstances(chain)) { Job solo = chain.get(0).get(0); - jobTracker.onStateChange(solo.getId(), JobTracker.JobState.IGNORED); + jobTracker.onStateChange(solo, JobTracker.JobState.IGNORED); Log.w(TAG, JobLogger.format(solo, "Already at the max instance count of " + solo.getParameters().getMaxInstances() + ". Skipping.")); return; } @@ -101,7 +101,7 @@ class JobController { List> chain = Collections.singletonList(Collections.singletonList(job)); if (chainExceedsMaximumInstances(chain)) { - jobTracker.onStateChange(job.getId(), JobTracker.JobState.IGNORED); + jobTracker.onStateChange(job, JobTracker.JobState.IGNORED); Log.w(TAG, JobLogger.format(job, "Already at the max instance count of " + job.getParameters().getMaxInstances() + ". Skipping.")); return; } @@ -149,7 +149,7 @@ class JobController { String serializedData = dataSerializer.serialize(job.serialize()); jobStorage.updateJobAfterRetry(job.getId(), false, nextRunAttempt, nextRunAttemptTime, serializedData); - jobTracker.onStateChange(job.getId(), JobTracker.JobState.PENDING); + jobTracker.onStateChange(job, JobTracker.JobState.PENDING); List constraints = Stream.of(jobStorage.getConstraintSpecs(job.getId())) .map(ConstraintSpec::getFactoryKey) @@ -172,7 +172,7 @@ class JobController { @WorkerThread synchronized void onSuccess(@NonNull Job job) { jobStorage.deleteJob(job.getId()); - jobTracker.onStateChange(job.getId(), JobTracker.JobState.SUCCESS); + jobTracker.onStateChange(job, JobTracker.JobState.SUCCESS); notifyAll(); } @@ -196,7 +196,7 @@ class JobController { all.addAll(dependents); jobStorage.deleteJobs(Stream.of(all).map(Job::getId).toList()); - Stream.of(all).forEach(j -> jobTracker.onStateChange(j.getId(), JobTracker.JobState.FAILURE)); + Stream.of(all).forEach(j -> jobTracker.onStateChange(j, JobTracker.JobState.FAILURE)); return dependents; } @@ -224,7 +224,7 @@ class JobController { jobStorage.updateJobRunningState(job.getId(), true); runningJobs.put(job.getId(), job); - jobTracker.onStateChange(job.getId(), JobTracker.JobState.RUNNING); + jobTracker.onStateChange(job, JobTracker.JobState.RUNNING); return job; } catch (InterruptedException e) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java index 47ef72424..c0cbec208 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java @@ -100,13 +100,21 @@ public class JobManager implements ConstraintObserver.Notifier { }); } + /** + * Convenience method for {@link #addListener(JobTracker.JobFilter, JobTracker.JobListener)} that + * takes in an ID to filter on. + */ + public void addListener(@NonNull String id, @NonNull JobTracker.JobListener listener) { + jobTracker.addListener(new JobIdFilter(id), listener); + } + /** * Add a listener to subscribe to job state updates. Listeners will be invoked on an arbitrary * background thread. You must eventually call {@link #removeListener(JobTracker.JobListener)} to avoid * memory leaks. */ - public void addListener(@NonNull String id, @NonNull JobTracker.JobListener listener) { - jobTracker.addListener(id, listener); + public void addListener(@NonNull JobTracker.JobFilter filter, @NonNull JobTracker.JobListener listener) { + jobTracker.addListener(filter, listener); } /** @@ -127,7 +135,7 @@ public class JobManager implements ConstraintObserver.Notifier { * Enqueues a single job that depends on a collection of job ID's. */ public void add(@NonNull Job job, @NonNull Collection dependsOn) { - jobTracker.onStateChange(job.getId(), JobTracker.JobState.PENDING); + jobTracker.onStateChange(job, JobTracker.JobState.PENDING); executor.execute(() -> { jobController.submitJobWithExistingDependencies(job, dependsOn); @@ -177,7 +185,7 @@ public class JobManager implements ConstraintObserver.Notifier { addListener(job.getId(), new JobTracker.JobListener() { @Override - public void onStateChanged(@NonNull JobTracker.JobState jobState) { + public void onStateChanged(@NonNull Job job, @NonNull JobTracker.JobState jobState) { if (jobState.isComplete()) { removeListener(this); resultState.set(jobState); @@ -248,7 +256,7 @@ public class JobManager implements ConstraintObserver.Notifier { private void enqueueChain(@NonNull Chain chain) { for (List jobList : chain.getJobListChain()) { for (Job job : jobList) { - jobTracker.onStateChange(job.getId(), JobTracker.JobState.PENDING); + jobTracker.onStateChange(job, JobTracker.JobState.PENDING); } } @@ -270,6 +278,19 @@ public class JobManager implements ConstraintObserver.Notifier { void onQueueEmpty(); } + public static class JobIdFilter implements JobTracker.JobFilter { + private final String id; + + public JobIdFilter(@NonNull String id) { + this.id = id; + } + + @Override + public boolean matches(@NonNull Job job) { + return id.equals(job.getId()); + } + } + /** * Allows enqueuing work that depends on each other. Jobs that appear later in the chain will * only run after all jobs earlier in the chain have been completed. If a job fails, all jobs diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobTracker.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobTracker.java index 1b51c9bb5..d00509462 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobTracker.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobTracker.java @@ -3,13 +3,15 @@ package org.thoughtcrime.securesms.jobmanager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.annimon.stream.Stream; + import org.thoughtcrime.securesms.util.LRUCache; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; -import java.util.Collection; +import java.util.ArrayList; +import java.util.Iterator; import java.util.List; import java.util.Map; -import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.Executor; /** @@ -17,11 +19,13 @@ import java.util.concurrent.Executor; */ public class JobTracker { - private final Map trackingStates; - private final Executor listenerExecutor; + private final Map jobInfos; + private final List jobListeners; + private final Executor listenerExecutor; JobTracker() { - this.trackingStates = new LRUCache<>(1000); + this.jobInfos = new LRUCache<>(1000); + this.jobListeners = new ArrayList<>(); this.listenerExecutor = SignalExecutors.BOUNDED; } @@ -30,54 +34,63 @@ public class JobTracker { * background thread. You must eventually call {@link #removeListener(JobListener)} to avoid * memory leaks. */ - synchronized void addListener(@NonNull String id, @NonNull JobListener jobListener) { - TrackingState state = getOrCreateTrackingState(id); - JobState currentJobState = state.getJobState(); + synchronized void addListener(@NonNull JobFilter filter, @NonNull JobListener listener) { + jobListeners.add(new ListenerInfo(filter, listener)); - state.addListener(jobListener); - - if (currentJobState != null) { - listenerExecutor.execute(() -> jobListener.onStateChanged(currentJobState)); - } + Stream.of(jobInfos.values()) + .filter(info -> info.getJobState() != null) + .filter(info -> filter.matches(info.getJob())) + .forEach(state-> { + //noinspection ConstantConditions We already filter for nulls above + listenerExecutor.execute(() -> listener.onStateChanged(state.getJob(), state.getJobState())); + }); } /** * Unsubscribe the provided listener from all job updates. */ - synchronized void removeListener(@NonNull JobListener jobListener) { - Collection allTrackingState = trackingStates.values(); + synchronized void removeListener(@NonNull JobListener listener) { + Iterator iter = jobListeners.iterator(); - for (TrackingState state : allTrackingState) { - state.removeListener(jobListener); + while (iter.hasNext()) { + if (listener.equals(iter.next().getListener())) { + iter.remove(); + } } } /** * Update the state of a job with the associated ID. */ - synchronized void onStateChange(@NonNull String id, @NonNull JobState jobState) { - TrackingState trackingState = getOrCreateTrackingState(id); - trackingState.setJobState(jobState); + synchronized void onStateChange(@NonNull Job job, @NonNull JobState state) { + getOrCreateJobInfo(job).setJobState(state); - for (JobListener listener : trackingState.getListeners()) { - listenerExecutor.execute(() -> listener.onStateChanged(jobState)); - } + Stream.of(jobListeners) + .filter(info -> info.getFilter().matches(job)) + .map(ListenerInfo::getListener) + .forEach(listener -> { + listenerExecutor.execute(() -> listener.onStateChanged(job, state)); + }); } - private @NonNull TrackingState getOrCreateTrackingState(@NonNull String id) { - TrackingState state = trackingStates.get(id); + private @NonNull JobInfo getOrCreateJobInfo(@NonNull Job job) { + JobInfo jobInfo = jobInfos.get(job.getId()); - if (state == null) { - state = new TrackingState(); + if (jobInfo == null) { + jobInfo = new JobInfo(job); } - trackingStates.put(id, state); + jobInfos.put(job.getId(), jobInfo); - return state; + return jobInfo; + } + + public interface JobFilter { + boolean matches(@NonNull Job job); } public interface JobListener { - void onStateChanged(@NonNull JobState jobState); + void onStateChanged(@NonNull Job job, @NonNull JobState jobState); } public enum JobState { @@ -94,21 +107,34 @@ public class JobTracker { } } - private static class TrackingState { - private JobState jobState; + private static class ListenerInfo { + private final JobFilter filter; + private final JobListener listener; - private final CopyOnWriteArraySet listeners = new CopyOnWriteArraySet<>(); - - void addListener(@NonNull JobListener jobListener) { - listeners.add(jobListener); + private ListenerInfo(JobFilter filter, JobListener listener) { + this.filter = filter; + this.listener = listener; } - void removeListener(@NonNull JobListener jobListener) { - listeners.remove(jobListener); + @NonNull JobFilter getFilter() { + return filter; } - @NonNull Collection getListeners() { - return listeners; + @NonNull JobListener getListener() { + return listener; + } + } + + private static class JobInfo { + private final Job job; + private JobState jobState; + + private JobInfo(Job job) { + this.job = job; + } + + @NonNull Job getJob() { + return job; } void setJobState(@NonNull JobState jobState) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarDownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarDownloadJob.java index aae85b7eb..90b113d86 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarDownloadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarDownloadJob.java @@ -1,12 +1,14 @@ package org.thoughtcrime.securesms.jobs; import android.graphics.Bitmap; + import androidx.annotation.NonNull; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; @@ -14,7 +16,6 @@ import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.AttachmentStreamUriLoader.AttachmentModel; import org.thoughtcrime.securesms.util.BitmapDecodingException; import org.thoughtcrime.securesms.util.BitmapUtil; -import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.Hex; import org.whispersystems.libsignal.InvalidMessageException; import org.whispersystems.libsignal.util.guava.Optional; @@ -36,9 +37,9 @@ public class AvatarDownloadJob extends BaseJob { private static final String KEY_GROUP_ID = "group_id"; - private byte[] groupId; + private @NonNull GroupId groupId; - public AvatarDownloadJob(@NonNull byte[] groupId) { + public AvatarDownloadJob(@NonNull GroupId groupId) { this(new Job.Parameters.Builder() .addConstraint(NetworkConstraint.KEY) .setMaxAttempts(10) @@ -46,14 +47,14 @@ public class AvatarDownloadJob extends BaseJob { groupId); } - private AvatarDownloadJob(@NonNull Job.Parameters parameters, @NonNull byte[] groupId) { + private AvatarDownloadJob(@NonNull Job.Parameters parameters, @NonNull GroupId groupId) { super(parameters); this.groupId = groupId; } @Override public @NonNull Data serialize() { - return new Data.Builder().putString(KEY_GROUP_ID, GroupUtil.getEncodedId(groupId, false)).build(); + return new Data.Builder().putString(KEY_GROUP_ID, groupId.toString()).build(); } @Override @@ -63,9 +64,8 @@ public class AvatarDownloadJob extends BaseJob { @Override public void onRun() throws IOException { - String encodeId = GroupUtil.getEncodedId(groupId, false); GroupDatabase database = DatabaseFactory.getGroupDatabase(context); - Optional record = database.getGroup(encodeId); + Optional record = database.getGroup(groupId); File attachment = null; try { @@ -93,7 +93,7 @@ public class AvatarDownloadJob extends BaseJob { InputStream inputStream = receiver.retrieveAttachment(pointer, attachment, MAX_AVATAR_SIZE); Bitmap avatar = BitmapUtil.createScaledBitmap(context, new AttachmentModel(attachment, key, 0, digest), 500, 500); - database.updateAvatar(encodeId, avatar); + database.updateAvatar(groupId, avatar); inputStream.close(); } } catch (BitmapDecodingException | NonSuccessfulResponseCodeException | InvalidMessageException e) { @@ -116,11 +116,7 @@ public class AvatarDownloadJob extends BaseJob { public static final class Factory implements Job.Factory { @Override public @NonNull AvatarDownloadJob create(@NonNull Parameters parameters, @NonNull Data data) { - try { - return new AvatarDownloadJob(parameters, GroupUtil.getDecodedId(data.getString(KEY_GROUP_ID))); - } catch (IOException e) { - throw new AssertionError(e); - } + return new AvatarDownloadJob(parameters, GroupId.parse(data.getString(KEY_GROUP_ID))); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/FcmRefreshJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/FcmRefreshJob.java index 0dae4d868..ea2637df4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/FcmRefreshJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/FcmRefreshJob.java @@ -125,7 +125,7 @@ public class FcmRefreshJob extends BaseJob { PendingIntent pendingIntent = PendingIntent.getActivity(context, 1122, intent, PendingIntent.FLAG_CANCEL_CURRENT); NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NotificationChannels.FAILURES); - builder.setSmallIcon(R.drawable.icon_notification); + builder.setSmallIcon(R.drawable.ic_notification); builder.setLargeIcon(BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_action_warning_red)); builder.setContentTitle(context.getString(R.string.GcmRefreshJob_Permanent_Signal_communication_failure)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 0a172bf00..afbe60cae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.jobmanager.migrations.RecipientIdFollowUpJobMi 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.AvatarIdRemovalMigrationJob; import org.thoughtcrime.securesms.migrations.PassingMigrationJob; import org.thoughtcrime.securesms.migrations.AvatarMigrationJob; import org.thoughtcrime.securesms.migrations.CachedAttachmentsMigrationJob; @@ -85,6 +86,7 @@ public final class JobManagerFactories { put(RefreshPreKeysJob.KEY, new RefreshPreKeysJob.Factory()); put(RemoteConfigRefreshJob.KEY, new RemoteConfigRefreshJob.Factory()); put(RequestGroupInfoJob.KEY, new RequestGroupInfoJob.Factory()); + put(StorageAccountRestoreJob.KEY, new StorageAccountRestoreJob.Factory()); put(RetrieveProfileAvatarJob.KEY, new RetrieveProfileAvatarJob.Factory()); put(RetrieveProfileJob.KEY, new RetrieveProfileJob.Factory()); put(RotateCertificateJob.KEY, new RotateCertificateJob.Factory()); @@ -107,6 +109,7 @@ public final class JobManagerFactories { put(ProfileUploadJob.KEY, new ProfileUploadJob.Factory()); // Migrations + put(AvatarIdRemovalMigrationJob.KEY, new AvatarIdRemovalMigrationJob.Factory()); put(AvatarMigrationJob.KEY, new AvatarMigrationJob.Factory()); put(CachedAttachmentsMigrationJob.KEY, new CachedAttachmentsMigrationJob.Factory()); put(DatabaseMigrationJob.KEY, new DatabaseMigrationJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LeaveGroupJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LeaveGroupJob.java index 2d7d99063..be14b8086 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LeaveGroupJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LeaveGroupJob.java @@ -3,12 +3,12 @@ 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.groups.GroupId; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; @@ -18,7 +18,6 @@ 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; @@ -27,8 +26,6 @@ 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; @@ -54,7 +51,7 @@ public class LeaveGroupJob extends BaseJob { private static final String KEY_MEMBERS = "members"; private static final String KEY_RECIPIENTS = "recipients"; - private final byte[] groupId; + private final GroupId groupId; private final String name; private final List members; private final List recipients; @@ -63,7 +60,7 @@ public class LeaveGroupJob extends BaseJob { List members = Stream.of(group.resolve().getParticipants()).map(Recipient::getId).toList(); members.remove(Recipient.self().getId()); - return new LeaveGroupJob(GroupUtil.getDecodedIdOrThrow(group.getGroupId().get()), + return new LeaveGroupJob(group.getGroupId().get(), group.resolve().getDisplayName(ApplicationDependencies.getApplication()), members, members, @@ -75,7 +72,7 @@ public class LeaveGroupJob extends BaseJob { .build()); } - private LeaveGroupJob(@NonNull byte[] groupId, + private LeaveGroupJob(@NonNull GroupId groupId, @NonNull String name, @NonNull List members, @NonNull List recipients, @@ -90,7 +87,7 @@ public class LeaveGroupJob extends BaseJob { @Override public @NonNull Data serialize() { - return new Data.Builder().putString(KEY_GROUP_ID, Base64.encodeBytes(groupId)) + return new Data.Builder().putString(KEY_GROUP_ID, Base64.encodeBytes(groupId.getDecodedId())) .putString(KEY_GROUP_NAME, name) .putString(KEY_MEMBERS, RecipientId.toSerializedList(members)) .putString(KEY_RECIPIENTS, RecipientId.toSerializedList(recipients)) @@ -128,7 +125,7 @@ public class LeaveGroupJob extends BaseJob { } private static @NonNull List deliver(@NonNull Context context, - @NonNull byte[] groupId, + @NonNull GroupId groupId, @NonNull String name, @NonNull List members, @NonNull List destinations) @@ -138,7 +135,7 @@ public class LeaveGroupJob extends BaseJob { List addresses = Stream.of(destinations).map(Recipient::resolved).map(t -> RecipientUtil.toSignalServiceAddress(context, t)).toList(); List memberAddresses = Stream.of(members).map(Recipient::resolved).map(t -> RecipientUtil.toSignalServiceAddress(context, t)).toList(); List> 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); + SignalServiceGroup serviceGroup = new SignalServiceGroup(SignalServiceGroup.Type.QUIT, groupId.getDecodedId(), name, memberAddresses, null); SignalServiceDataMessage.Builder dataMessage = SignalServiceDataMessage.newBuilder() .withTimestamp(System.currentTimeMillis()) .asGroupMessage(serviceGroup); @@ -169,7 +166,7 @@ public class LeaveGroupJob extends BaseJob { public static class Factory implements Job.Factory { @Override public @NonNull LeaveGroupJob create(@NonNull Parameters parameters, @NonNull Data data) { - return new LeaveGroupJob(Base64.decodeOrThrow(data.getString(KEY_GROUP_ID)), + return new LeaveGroupJob(GroupId.v1(Base64.decodeOrThrow(data.getString(KEY_GROUP_ID))), data.getString(KEY_GROUP_NAME), RecipientId.fromSerializedList(data.getString(KEY_MEMBERS)), RecipientId.fromSerializedList(data.getString(KEY_RECIPIENTS)), diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java index bb3bdf0fd..5468db6da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.jobs; import android.net.Uri; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -16,6 +17,7 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MessagingDatabase.InsertResult; import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.logging.Log; @@ -177,11 +179,11 @@ public class MmsDownloadJob extends BaseJob { int subscriptionId, @Nullable RecipientId notificationFrom) throws MmsException { - MmsDatabase database = DatabaseFactory.getMmsDatabase(context); - Optional group = Optional.absent(); - Set members = new HashSet<>(); - String body = null; - List attachments = new LinkedList<>(); + MmsDatabase database = DatabaseFactory.getMmsDatabase(context); + Optional group = Optional.absent(); + Set members = new HashSet<>(); + String body = null; + List attachments = new LinkedList<>(); RecipientId from = null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceBlockedUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceBlockedUpdateJob.java index d848a647f..cc70898eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceBlockedUpdateJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceBlockedUpdateJob.java @@ -13,7 +13,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientUtil; -import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.whispersystems.signalservice.api.SignalServiceMessageSender; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; @@ -76,7 +75,7 @@ public class MultiDeviceBlockedUpdateJob extends BaseJob { while ((recipient = reader.getNext()) != null) { if (recipient.isPushGroup()) { - blockedGroups.add(GroupUtil.getDecodedId(recipient.requireGroupId())); + blockedGroups.add(recipient.requireGroupId().getDecodedId()); } else if (recipient.hasServiceIdentifier()) { blockedIndividuals.add(RecipientUtil.toSignalServiceAddress(context, recipient)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java index 3d292d69d..ca4405acc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java @@ -14,7 +14,6 @@ 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; @@ -92,13 +91,13 @@ public class MultiDeviceGroupUpdateJob extends BaseJob { members.add(RecipientUtil.toSignalServiceAddress(context, Recipient.resolved(member))); } - RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(GroupUtil.getEncodedId(record.getId(), record.isMms())); + RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(record.getId()); Recipient recipient = Recipient.resolved(recipientId); Optional expirationTimer = recipient.getExpireMessages() > 0 ? Optional.of(recipient.getExpireMessages()) : Optional.absent(); Map inboxPositions = DatabaseFactory.getThreadDatabase(context).getInboxPositions(); Set archived = DatabaseFactory.getThreadDatabase(context).getArchivedRecipients(); - out.write(new DeviceGroup(record.getId(), + out.write(new DeviceGroup(record.getId().getDecodedId(), Optional.fromNullable(record.getTitle()), members, getAvatar(record.getAvatar()), diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java index f58b5dc7f..1bbbf9d93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java @@ -8,18 +8,13 @@ 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; @@ -100,7 +95,7 @@ public class MultiDeviceMessageRequestResponseJob extends BaseJob { MessageRequestResponseMessage response; if (recipient.isGroup()) { - response = MessageRequestResponseMessage.forGroup(GroupUtil.getDecodedId(recipient.getGroupId().get()), localToRemoteType(type)); + response = MessageRequestResponseMessage.forGroup(recipient.getGroupId().get().getDecodedId(), localToRemoteType(type)); } else { response = MessageRequestResponseMessage.forIndividual(RecipientUtil.toSignalServiceAddress(context, recipient), localToRemoteType(type)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileUploadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileUploadJob.java index c6d862771..a9e68df9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileUploadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ProfileUploadJob.java @@ -6,15 +6,20 @@ import androidx.annotation.NonNull; import org.signal.zkgroup.profiles.ProfileKey; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; 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.profiles.AvatarHelper; import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.ProfileUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.profiles.ProfileAndCredential; +import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.util.StreamDetails; public final class ProfileUploadJob extends BaseJob { @@ -44,16 +49,19 @@ public final class ProfileUploadJob extends BaseJob { @Override protected void onRun() throws Exception { ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey(); - ProfileName profileName = TextSecurePreferences.getProfileName(context); + ProfileName profileName = Recipient.self().getProfileName(); + String avatarPath = null; try (StreamDetails avatar = AvatarHelper.getSelfProfileAvatarStream(context)) { if (FeatureFlags.VERSIONED_PROFILES) { - accountManager.setVersionedProfile(profileKey, profileName.serialize(), avatar); + avatarPath = accountManager.setVersionedProfile(Recipient.self().getUuid().get(), profileKey, profileName.serialize(), avatar).orNull(); } else { accountManager.setProfileName(profileKey, profileName.serialize()); - accountManager.setProfileAvatar(profileKey, avatar); + avatarPath = accountManager.setProfileAvatar(profileKey, avatar).orNull(); } } + + DatabaseFactory.getRecipientDatabase(context).setProfileAvatar(Recipient.self().getId(), avatarPath); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDecryptMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDecryptMessageJob.java index d28ac89d7..670a87c70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDecryptMessageJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDecryptMessageJob.java @@ -29,13 +29,13 @@ import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.NoSuchMessageException; import org.thoughtcrime.securesms.database.PushDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.transport.RetryLaterException; -import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.whispersystems.libsignal.state.SignalProtocolStore; import org.whispersystems.libsignal.util.guava.Optional; @@ -137,7 +137,7 @@ public final class PushDecryptMessageJob extends BaseJob { // TODO [greyson] Navigation NotificationManagerCompat.from(context).notify(494949, new NotificationCompat.Builder(context, NotificationChannels.getMessagesChannel(context)) - .setSmallIcon(R.drawable.icon_notification) + .setSmallIcon(R.drawable.ic_notification) .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setContentTitle(context.getString(R.string.PushDecryptJob_new_locked_message)) @@ -233,7 +233,7 @@ public final class PushDecryptMessageJob extends BaseJob { return new PushProcessMessageJob.ExceptionMetadata(sender, e.getSenderDevice(), - e.getGroup().transform(g -> GroupUtil.getEncodedId(g.getGroupId(), false)).orNull()); + e.getGroup().transform(g -> GroupId.v1(g.getGroupId())).orNull()); } private static PushProcessMessageJob.ExceptionMetadata toExceptionMetadata(@NonNull ProtocolException e) throws NoSenderException { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java index e58e3538d..833659ac5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.jobs; import android.content.Context; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; @@ -18,6 +19,7 @@ import org.thoughtcrime.securesms.database.NoSuchMessageException; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.NetworkFailure; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.JobManager; @@ -31,8 +33,6 @@ 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; import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; @@ -242,7 +242,7 @@ public class PushGroupSendJob extends PushSendJob { rotateSenderCertificateIfNecessary(); SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); - String groupId = groupRecipient.requireGroupId(); + GroupId groupId = groupRecipient.requireGroupId(); Optional profileKey = getProfileKey(groupRecipient); Optional quote = getQuoteFor(message); Optional sticker = getStickerFor(message); @@ -266,7 +266,7 @@ public class PushGroupSendJob extends PushSendJob { List members = Stream.of(groupContext.getMembersList()) .map(m -> new SignalServiceAddress(UuidUtil.parseOrNull(m.getUuid()), m.getE164())) .toList(); - SignalServiceGroup group = new SignalServiceGroup(type, GroupUtil.getDecodedId(groupId), groupContext.getName(), members, avatar); + SignalServiceGroup group = new SignalServiceGroup(type, groupId.getDecodedId(), groupContext.getName(), members, avatar); SignalServiceDataMessage groupDataMessage = SignalServiceDataMessage.newBuilder() .withTimestamp(message.getSentTimeMillis()) .withExpiration(groupRecipient.getExpireMessages()) @@ -275,7 +275,7 @@ public class PushGroupSendJob extends PushSendJob { return messageSender.sendMessage(addresses, unidentifiedAccess, isRecipientUpdate, groupDataMessage); } else { - SignalServiceGroup group = new SignalServiceGroup(GroupUtil.getDecodedId(groupId)); + SignalServiceGroup group = new SignalServiceGroup(groupId.getDecodedId()); SignalServiceDataMessage groupMessage = SignalServiceDataMessage.newBuilder() .withTimestamp(message.getSentTimeMillis()) .asGroupMessage(group) @@ -295,7 +295,7 @@ public class PushGroupSendJob extends PushSendJob { } } - private @NonNull List getGroupMessageRecipients(String groupId, long messageId) { + private @NonNull List getGroupMessageRecipients(@NonNull GroupId groupId, long messageId) { List destinations = DatabaseFactory.getGroupReceiptDatabase(context).getGroupReceiptInfo(messageId); if (!destinations.isEmpty()) return Stream.of(destinations).map(GroupReceiptInfo::getRecipientId).toList(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupUpdateJob.java index 949eea389..54cb01b52 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupUpdateJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupUpdateJob.java @@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; @@ -15,7 +16,6 @@ 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.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.SignalServiceMessageSender; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; @@ -26,8 +26,6 @@ import org.whispersystems.signalservice.api.messages.SignalServiceGroup; import org.whispersystems.signalservice.api.messages.SignalServiceGroup.Type; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; -import org.whispersystems.signalservice.internal.push.SignalServiceProtos; -import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -44,10 +42,10 @@ public class PushGroupUpdateJob extends BaseJob { private static final String KEY_SOURCE = "source"; private static final String KEY_GROUP_ID = "group_id"; - private RecipientId source; - private byte[] groupId; + private final RecipientId source; + private final GroupId groupId; - public PushGroupUpdateJob(@NonNull RecipientId source, byte[] groupId) { + public PushGroupUpdateJob(@NonNull RecipientId source, @NonNull GroupId groupId) { this(new Job.Parameters.Builder() .addConstraint(NetworkConstraint.KEY) .setLifespan(TimeUnit.DAYS.toMillis(1)) @@ -57,7 +55,7 @@ public class PushGroupUpdateJob extends BaseJob { groupId); } - private PushGroupUpdateJob(@NonNull Job.Parameters parameters, RecipientId source, byte[] groupId) { + private PushGroupUpdateJob(@NonNull Job.Parameters parameters, RecipientId source, @NonNull GroupId groupId) { super(parameters); this.source = source; @@ -67,7 +65,7 @@ public class PushGroupUpdateJob extends BaseJob { @Override public @NonNull Data serialize() { return new Data.Builder().putString(KEY_SOURCE, source.serialize()) - .putString(KEY_GROUP_ID, GroupUtil.getEncodedId(groupId, false)) + .putString(KEY_GROUP_ID, groupId.toString()) .build(); } @@ -79,11 +77,11 @@ public class PushGroupUpdateJob extends BaseJob { @Override public void onRun() throws IOException, UntrustedIdentityException { GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); - Optional record = groupDatabase.getGroup(GroupUtil.getEncodedId(groupId, false)); + Optional record = groupDatabase.getGroup(groupId); SignalServiceAttachment avatar = null; if (record == null) { - Log.w(TAG, "No information for group record info request: " + new String(groupId)); + Log.w(TAG, "No information for group record info request: " + groupId.toString()); return; } @@ -104,12 +102,12 @@ public class PushGroupUpdateJob extends BaseJob { SignalServiceGroup groupContext = SignalServiceGroup.newBuilder(Type.UPDATE) .withAvatar(avatar) - .withId(groupId) + .withId(groupId.getDecodedId()) .withMembers(members) .withName(record.get().getTitle()) .build(); - RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(GroupUtil.getEncodedId(groupId, false)); + RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); Recipient groupRecipient = Recipient.resolved(groupRecipientId); SignalServiceDataMessage message = SignalServiceDataMessage.newBuilder() @@ -139,13 +137,9 @@ public class PushGroupUpdateJob extends BaseJob { public static final class Factory implements Job.Factory { @Override public @NonNull PushGroupUpdateJob create(@NonNull Parameters parameters, @NonNull org.thoughtcrime.securesms.jobmanager.Data data) { - try { - return new PushGroupUpdateJob(parameters, - RecipientId.from(data.getString(KEY_SOURCE)), - GroupUtil.getDecodedId(data.getString(KEY_GROUP_ID))); - } catch (IOException e) { - throw new AssertionError(e); - } + return new PushGroupUpdateJob(parameters, + RecipientId.from(data.getString(KEY_SOURCE)), + GroupId.parse(data.getString(KEY_GROUP_ID))); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java index 2a13171ac..26be5a3bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java @@ -43,6 +43,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.StickerRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupMessageProcessor; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; @@ -62,9 +63,9 @@ import org.thoughtcrime.securesms.mms.StickerSlide; import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.service.WebRtcCallService; import org.thoughtcrime.securesms.ringrtc.IceCandidateParcel; import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.service.WebRtcCallService; import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage; import org.thoughtcrime.securesms.sms.IncomingEndSessionMessage; import org.thoughtcrime.securesms.sms.IncomingTextMessage; @@ -72,8 +73,8 @@ import org.thoughtcrime.securesms.sms.OutgoingEncryptedMessage; import org.thoughtcrime.securesms.sms.OutgoingEndSessionMessage; import org.thoughtcrime.securesms.sms.OutgoingTextMessage; import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.util.Base64; -import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.Hex; import org.thoughtcrime.securesms.util.IdentityUtil; import org.thoughtcrime.securesms.util.MediaUtil; @@ -216,7 +217,7 @@ public final class PushProcessMessageJob extends BaseJob { //noinspection ConstantConditions dataBuilder.putString(KEY_EXCEPTION_SENDER, exceptionMetadata.sender) .putInt(KEY_EXCEPTION_DEVICE, exceptionMetadata.senderDevice) - .putString(KEY_EXCEPTION_GROUP_ID, exceptionMetadata.groupId); + .putString(KEY_EXCEPTION_GROUP_ID, exceptionMetadata.groupId == null ? null : exceptionMetadata.groupId.toString()); } return dataBuilder.build(); @@ -271,7 +272,7 @@ public final class PushProcessMessageJob extends BaseJob { else if (isMediaMessage) handleMediaMessage(content, message, smsMessageId); else if (message.getBody().isPresent()) handleTextMessage(content, message, smsMessageId); - if (message.getGroupInfo().isPresent() && groupDatabase.isUnknownGroup(GroupUtil.getEncodedId(message.getGroupInfo().get().getGroupId(), false))) { + if (message.getGroupInfo().isPresent() && groupDatabase.isUnknownGroup(GroupId.v1(message.getGroupInfo().get().getGroupId()))) { handleUnknownGroupMessage(content, message.getGroupInfo().get()); } @@ -326,8 +327,8 @@ public final class PushProcessMessageJob extends BaseJob { } } - private static @NonNull Optional toEncodedId(@NonNull Optional groupInfo) { - return groupInfo.transform(g -> GroupUtil.getEncodedId(g.getGroupId(), false)); + private static @NonNull Optional toEncodedId(@NonNull Optional groupInfo) { + return groupInfo.transform(g -> GroupId.v1(g.getGroupId())); } private void handleExceptionMessage(@NonNull ExceptionMetadata e, @NonNull Optional smsMessageId) { @@ -544,7 +545,11 @@ public final class PushProcessMessageJob extends BaseJob { private void handleUnknownGroupMessage(@NonNull SignalServiceContent content, @NonNull SignalServiceGroup group) { - ApplicationDependencies.getJobManager().add(new RequestGroupInfoJob(Recipient.externalPush(context, content.getSender()).getId(), group.getGroupId())); + if (group.getType() != SignalServiceGroup.Type.REQUEST_INFO) { + ApplicationDependencies.getJobManager().add(new RequestGroupInfoJob(Recipient.externalPush(context, content.getSender()).getId(), GroupId.v1(group.getGroupId()))); + } else { + Log.w(TAG, "Received a REQUEST_INFO message for a group we don't know about. Ignoring."); + } } private void handleExpirationUpdate(@NonNull SignalServiceContent content, @@ -661,7 +666,7 @@ public final class PushProcessMessageJob extends BaseJob { ApplicationDependencies.getJobManager().add(new RefreshOwnProfileJob()); break; case STORAGE_MANIFEST: - ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + StorageSyncHelper.scheduleSyncForDataChange(); break; default: Log.w(TAG, "Received a fetch message for an unknown type."); @@ -677,7 +682,7 @@ public final class PushProcessMessageJob extends BaseJob { if (response.getPerson().isPresent()) { recipient = Recipient.externalPush(context, response.getPerson().get()); } else if (response.getGroupId().isPresent()) { - String groupId = GroupUtil.getEncodedId(response.getGroupId().get(), false); + GroupId groupId = GroupId.v1(response.getGroupId().get()); recipient = Recipient.externalGroup(context, groupId); } else { Log.w(TAG, "Message request response was missing a thread recipient! Skipping."); @@ -738,7 +743,7 @@ public final class PushProcessMessageJob extends BaseJob { threadId = handleSynchronizeSentTextMessage(message); } - if (message.getMessage().getGroupInfo().isPresent() && groupDatabase.isUnknownGroup(GroupUtil.getEncodedId(message.getMessage().getGroupInfo().get().getGroupId(), false))) { + if (message.getMessage().getGroupInfo().isPresent() && groupDatabase.isUnknownGroup(GroupId.v1(message.getMessage().getGroupInfo().get().getGroupId()))) { handleUnknownGroupMessage(content, message.getMessage().getGroupInfo().get()); } @@ -1012,7 +1017,7 @@ public final class PushProcessMessageJob extends BaseJob { updateGroupReceiptStatus(message, record.getId(), recipient.requireGroupId()); } - private void updateGroupReceiptStatus(@NonNull SentTranscriptMessage message, long messageId, @NonNull String groupString) { + private void updateGroupReceiptStatus(@NonNull SentTranscriptMessage message, long messageId, @NonNull GroupId groupString) { GroupReceiptDatabase receiptDatabase = DatabaseFactory.getGroupReceiptDatabase(context); List messageRecipients = Stream.of(message.getRecipients()).map(address -> Recipient.externalPush(context, address)).toList(); List members = DatabaseFactory.getGroupDatabase(context).getGroupMembers(groupString, false); @@ -1178,7 +1183,7 @@ public final class PushProcessMessageJob extends BaseJob { private void handleUnsupportedDataMessage(@NonNull String sender, int senderDevice, - @NonNull Optional groupId, + @NonNull Optional groupId, long timestamp, @NonNull Optional smsMessageId) { @@ -1198,7 +1203,7 @@ public final class PushProcessMessageJob extends BaseJob { private void handleInvalidMessage(@NonNull SignalServiceAddress sender, int senderDevice, - @NonNull Optional groupId, + @NonNull Optional groupId, long timestamp, @NonNull Optional smsMessageId) { @@ -1308,7 +1313,7 @@ public final class PushProcessMessageJob extends BaseJob { long threadId; if (typingMessage.getGroupId().isPresent()) { - RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(GroupUtil.getEncodedId(typingMessage.getGroupId().get(), false)); + RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(GroupId.v1(typingMessage.getGroupId().get())); Recipient groupRecipient = Recipient.resolved(recipientId); threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); @@ -1473,7 +1478,7 @@ public final class PushProcessMessageJob extends BaseJob { return insertPlaceholder(sender, senderDevice, timestamp, Optional.absent()); } - private Optional insertPlaceholder(@NonNull String sender, int senderDevice, long timestamp, Optional groupId) { + private Optional insertPlaceholder(@NonNull String sender, int senderDevice, long timestamp, Optional groupId) { SmsDatabase database = DatabaseFactory.getSmsDatabase(context); IncomingTextMessage textMessage = new IncomingTextMessage(Recipient.external(context, sender).getId(), senderDevice, timestamp, "", @@ -1485,7 +1490,7 @@ public final class PushProcessMessageJob extends BaseJob { private Recipient getSyncMessageDestination(SentTranscriptMessage message) { if (message.getMessage().getGroupInfo().isPresent()) { - return Recipient.external(context, GroupUtil.getEncodedId(message.getMessage().getGroupInfo().get().getGroupId(), false)); + return Recipient.externalGroup(context, GroupId.v1(message.getMessage().getGroupInfo().get().getGroupId())); } else { return Recipient.externalPush(context, message.getDestination().get()); } @@ -1493,7 +1498,7 @@ public final class PushProcessMessageJob extends BaseJob { private Recipient getMessageDestination(SignalServiceContent content, SignalServiceDataMessage message) { if (message.getGroupInfo().isPresent()) { - return Recipient.external(context, GroupUtil.getEncodedId(message.getGroupInfo().get().getGroupId(), false)); + return Recipient.externalGroup(context, GroupId.v1(message.getGroupInfo().get().getGroupId())); } else { return Recipient.externalPush(context, content.getSender()); } @@ -1524,9 +1529,9 @@ public final class PushProcessMessageJob extends BaseJob { if (conversation.isGroup() && conversation.isBlocked()) { return true; } else if (conversation.isGroup()) { - GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); - Optional groupId = message.getGroupInfo().isPresent() ? Optional.of(GroupUtil.getEncodedId(message.getGroupInfo().get().getGroupId(), false)) - : Optional.absent(); + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + Optional groupId = message.getGroupInfo().isPresent() ? Optional.of(GroupId.v1(message.getGroupInfo().get().getGroupId())) + : Optional.absent(); if (groupId.isPresent() && groupDatabase.isUnknownGroup(groupId.get())) { return false; @@ -1611,7 +1616,7 @@ public final class PushProcessMessageJob extends BaseJob { } else { ExceptionMetadata exceptionMetadata = new ExceptionMetadata(data.getString(KEY_EXCEPTION_SENDER), data.getInt(KEY_EXCEPTION_DEVICE), - data.getStringOrDefault(KEY_EXCEPTION_GROUP_ID, null)); + GroupId.parseNullable(data.getStringOrDefault(KEY_EXCEPTION_GROUP_ID, null))); return new PushProcessMessageJob(parameters, state, @@ -1638,11 +1643,11 @@ public final class PushProcessMessageJob extends BaseJob { } static class ExceptionMetadata { - @NonNull private final String sender; - private final int senderDevice; - @Nullable private final String groupId; + @NonNull private final String sender; + private final int senderDevice; + @Nullable private final GroupId groupId; - ExceptionMetadata(@NonNull String sender, int senderDevice, @Nullable String groupId) { + ExceptionMetadata(@NonNull String sender, int senderDevice, @Nullable GroupId groupId) { this.sender = sender; this.senderDevice = senderDevice; this.groupId = groupId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java index ca9a08499..51ff0af18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java @@ -21,7 +21,6 @@ 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.GroupUtil; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.SignalServiceMessageSender; import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; @@ -217,7 +216,7 @@ public class ReactionSendJob extends BaseJob { .withReaction(buildReaction(context, reaction, remove, targetAuthor, targetSentTimestamp)); if (conversationRecipient.isGroup()) { - dataMessage.asGroupMessage(new SignalServiceGroup(GroupUtil.getDecodedId(conversationRecipient.requireGroupId()))); + dataMessage.asGroupMessage(new SignalServiceGroup(conversationRecipient.requireGroupId().getDecodedId())); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java index a8dc10225..d41eff7bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java @@ -106,7 +106,6 @@ public class RefreshOwnProfileJob extends BaseJob { ProfileName profileName = ProfileName.fromSerialized(plaintextName); DatabaseFactory.getRecipientDatabase(context).setProfileName(Recipient.self().getId(), profileName); - TextSecurePreferences.setProfileName(context, profileName); } catch (InvalidCiphertextException | IOException e) { Log.w(TAG, e); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupInfoJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupInfoJob.java index 0df50a256..81cf68968 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupInfoJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupInfoJob.java @@ -4,20 +4,18 @@ import androidx.annotation.NonNull; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +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.recipients.Recipient; -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.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceGroup; import org.whispersystems.signalservice.api.messages.SignalServiceGroup.Type; -import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; import java.io.IOException; @@ -33,10 +31,10 @@ public class RequestGroupInfoJob extends BaseJob { private static final String KEY_SOURCE = "source"; private static final String KEY_GROUP_ID = "group_id"; - private RecipientId source; - private byte[] groupId; + private final RecipientId source; + private final GroupId groupId; - public RequestGroupInfoJob(@NonNull RecipientId source, @NonNull byte[] groupId) { + public RequestGroupInfoJob(@NonNull RecipientId source, @NonNull GroupId groupId) { this(new Job.Parameters.Builder() .addConstraint(NetworkConstraint.KEY) .setLifespan(TimeUnit.DAYS.toMillis(1)) @@ -47,7 +45,7 @@ public class RequestGroupInfoJob extends BaseJob { } - private RequestGroupInfoJob(@NonNull Job.Parameters parameters, @NonNull RecipientId source, @NonNull byte[] groupId) { + private RequestGroupInfoJob(@NonNull Job.Parameters parameters, @NonNull RecipientId source, @NonNull GroupId groupId) { super(parameters); this.source = source; @@ -57,7 +55,7 @@ public class RequestGroupInfoJob extends BaseJob { @Override public @NonNull Data serialize() { return new Data.Builder().putString(KEY_SOURCE, source.serialize()) - .putString(KEY_GROUP_ID, GroupUtil.getEncodedId(groupId, false)) + .putString(KEY_GROUP_ID, groupId.toString()) .build(); } @@ -69,7 +67,7 @@ public class RequestGroupInfoJob extends BaseJob { @Override public void onRun() throws IOException, UntrustedIdentityException { SignalServiceGroup group = SignalServiceGroup.newBuilder(Type.REQUEST_INFO) - .withId(groupId) + .withId(groupId.getDecodedId()) .build(); SignalServiceDataMessage message = SignalServiceDataMessage.newBuilder() @@ -99,13 +97,9 @@ public class RequestGroupInfoJob extends BaseJob { @Override public @NonNull RequestGroupInfoJob create(@NonNull Parameters parameters, @NonNull Data data) { - try { - return new RequestGroupInfoJob(parameters, - RecipientId.from(data.getString(KEY_SOURCE)), - GroupUtil.getDecodedId(data.getString(KEY_GROUP_ID))); - } catch (IOException e) { - throw new AssertionError(e); - } + return new RequestGroupInfoJob(parameters, + RecipientId.from(data.getString(KEY_SOURCE)), + GroupId.parse(data.getString(KEY_GROUP_ID))); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java index 6790399df..96bf2fdc7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java @@ -48,7 +48,6 @@ public class RetrieveProfileAvatarJob extends BaseJob { .setQueue("RetrieveProfileAvatarJob::" + recipient.getId().toQueueKey()) .addConstraint(NetworkConstraint.KEY) .setLifespan(TimeUnit.HOURS.toMillis(1)) - .setMaxInstances(1) .build(), recipient, profileAvatar); @@ -121,10 +120,6 @@ public class RetrieveProfileAvatarJob extends BaseJob { } database.setProfileAvatar(recipient.getId(), profileAvatar); - - if (recipient.isLocalNumber()) { - TextSecurePreferences.setProfileAvatarId(context, Util.getSecureRandom().nextInt()); - } } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateProfileKeyJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateProfileKeyJob.java index 1c3a93b20..85f72f39a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateProfileKeyJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateProfileKeyJob.java @@ -18,6 +18,8 @@ import org.whispersystems.signalservice.api.SignalServiceAccountManager; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; import org.whispersystems.signalservice.api.util.StreamDetails; +import java.util.UUID; + public class RotateProfileKeyJob extends BaseJob { public static String KEY = "RotateProfileKeyJob"; @@ -55,11 +57,12 @@ public class RotateProfileKeyJob extends BaseJob { recipientDatabase.setProfileKey(self.getId(), profileKey); try (StreamDetails avatarStream = AvatarHelper.getSelfProfileAvatarStream(context)) { if (FeatureFlags.VERSIONED_PROFILES) { - accountManager.setVersionedProfile(profileKey, - TextSecurePreferences.getProfileName(context).serialize(), + accountManager.setVersionedProfile(self.getUuid().get(), + profileKey, + Recipient.self().getProfileName().serialize(), avatarStream); } else { - accountManager.setProfileName(profileKey, TextSecurePreferences.getProfileName(context).serialize()); + accountManager.setProfileName(profileKey, Recipient.self().getProfileName().serialize()); accountManager.setProfileAvatar(profileKey, avatarStream); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java new file mode 100644 index 000000000..6950f85d4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java @@ -0,0 +1,127 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +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.storage.StorageSyncHelper; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.storage.SignalAccountRecord; +import org.whispersystems.signalservice.api.storage.SignalStorageManifest; +import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.api.storage.StorageKey; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Restored the AccountRecord present in the storage service, if any. This will overwrite any local + * data that is stored in AccountRecord, so this should only be done immediately after registration. + */ +public class StorageAccountRestoreJob extends BaseJob { + + public static String KEY = "StorageAccountRestoreJob"; + + public static long LIFESPAN = TimeUnit.SECONDS.toMillis(10); + + private static final String TAG = Log.tag(StorageAccountRestoreJob.class); + + public StorageAccountRestoreJob() { + this(new Parameters.Builder() + .setQueue(StorageSyncJob.QUEUE_KEY) + .addConstraint(NetworkConstraint.KEY) + .setMaxInstances(1) + .setMaxAttempts(1) + .setLifespan(LIFESPAN) + .build()); + } + + private StorageAccountRestoreJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + StorageKey storageServiceKey = SignalStore.storageServiceValues().getOrCreateStorageMasterKey().deriveStorageServiceKey(); + + Optional manifest = accountManager.getStorageManifest(storageServiceKey); + + if (!manifest.isPresent()) { + Log.w(TAG, "Manifest did not exist or was undecryptable (bad key). Not restoring."); + return; + } + + Optional accountId = manifest.get().getAccountStorageId(); + + if (!accountId.isPresent()) { + Log.w(TAG, "Manifest had no account record! Not restoring."); + return; + } + + List records = accountManager.readStorageRecords(storageServiceKey, Collections.singletonList(accountId.get())); + SignalStorageRecord record = records.size() > 0 ? records.get(0) : null; + + if (record == null) { + Log.w(TAG, "Could not find account record, even though we had an ID! Not restoring."); + return; + } + + SignalAccountRecord accountRecord = record.getAccount().orNull(); + if (accountRecord == null) { + Log.w(TAG, "The storage record didn't actually have an account on it! Not restoring."); + return; + } + + StorageId selfStorageId = StorageId.forAccount(Recipient.self().getStorageServiceId()); + StorageSyncHelper.applyAccountStorageSyncUpdates(context, selfStorageId, accountRecord); + + if (accountRecord.getAvatarUrlPath().isPresent()) { + RetrieveProfileAvatarJob avatarJob = new RetrieveProfileAvatarJob(Recipient.self(), accountRecord.getAvatarUrlPath().get()); + try { + avatarJob.setContext(context); + avatarJob.onRun(); + } catch (IOException e) { + Log.w(TAG, "Failed to download avatar. Scheduling for later."); + ApplicationDependencies.getJobManager().add(avatarJob); + } + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull + StorageAccountRestoreJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new StorageAccountRestoreJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java index 273194421..19d4f40c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java @@ -4,7 +4,10 @@ import androidx.annotation.NonNull; import com.annimon.stream.Stream; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.storage.StorageSyncModels; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.StorageKeyDatabase; @@ -16,12 +19,10 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.transport.RetryLaterException; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; -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.StorageId; import org.whispersystems.signalservice.api.storage.StorageKey; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; import org.whispersystems.signalservice.api.storage.SignalStorageManifest; @@ -35,6 +36,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.TimeUnit; /** @@ -76,16 +78,18 @@ public class StorageForcePushJob extends BaseJob { RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context); - long currentVersion = accountManager.getStorageManifestVersion(); - Map oldStorageKeys = recipientDatabase.getAllStorageSyncKeysMap(); + long currentVersion = accountManager.getStorageManifestVersion(); + Map oldStorageKeys = recipientDatabase.getContactStorageSyncIdsMap(); - long newVersion = currentVersion + 1; - Map newStorageKeys = generateNewKeys(oldStorageKeys); - List inserts = Stream.of(oldStorageKeys.keySet()) - .map(recipientDatabase::getRecipientSettings) - .withoutNulls() - .map(s -> StorageSyncHelper.localToRemoteRecord(s, Objects.requireNonNull(newStorageKeys.get(s.getId())))) - .toList(); + long newVersion = currentVersion + 1; + Map newStorageKeys = generateNewKeys(oldStorageKeys); + Set archivedRecipients = DatabaseFactory.getThreadDatabase(context).getArchivedRecipients(); + List inserts = Stream.of(oldStorageKeys.keySet()) + .map(recipientDatabase::getRecipientSettings) + .withoutNulls() + .map(s -> StorageSyncModels.localToRemoteRecord(s, Objects.requireNonNull(newStorageKeys.get(s.getId())).getRaw(), archivedRecipients)) + .toList(); + inserts.add(StorageSyncHelper.buildAccountRecord(context, StorageId.forAccount(Recipient.self().fresh().getStorageServiceId()))); SignalStorageManifest manifest = new SignalStorageManifest(newVersion, new ArrayList<>(newStorageKeys.values())); @@ -110,7 +114,7 @@ public class StorageForcePushJob extends BaseJob { Log.i(TAG, "Force push succeeded. Updating local manifest version to: " + newVersion); TextSecurePreferences.setStorageManifestVersion(context, newVersion); - recipientDatabase.applyStorageSyncKeyUpdates(newStorageKeys); + recipientDatabase.applyStorageIdUpdates(newStorageKeys); storageKeyDatabase.deleteAll(); } @@ -123,11 +127,11 @@ public class StorageForcePushJob extends BaseJob { public void onFailure() { } - private static @NonNull Map generateNewKeys(@NonNull Map oldKeys) { - Map out = new HashMap<>(); + private static @NonNull Map generateNewKeys(@NonNull Map oldKeys) { + Map out = new HashMap<>(); - for (Map.Entry entry : oldKeys.entrySet()) { - out.put(entry.getKey(), StorageSyncHelper.generateKey()); + for (Map.Entry entry : oldKeys.entrySet()) { + out.put(entry.getKey(), entry.getValue().withNewBytes(StorageSyncHelper.generateKey())); } return out; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java index 987a66489..219aa071f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java @@ -6,11 +6,13 @@ import androidx.annotation.NonNull; import com.annimon.stream.Stream; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.KeyDifferenceResult; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.LocalWriteResult; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.MergeResult; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.WriteOperationResult; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.KeyDifferenceResult; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.LocalWriteResult; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.MergeResult; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.WriteOperationResult; +import org.thoughtcrime.securesms.storage.StorageSyncModels; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; @@ -22,6 +24,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.storage.StorageSyncValidations; import org.thoughtcrime.securesms.transport.RetryLaterException; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -29,16 +32,20 @@ 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.storage.SignalAccountRecord; +import org.whispersystems.signalservice.api.storage.StorageId; 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 org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.concurrent.TimeUnit; /** @@ -55,27 +62,14 @@ 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) - .setMaxInstances(1) + .setMaxInstances(2) .setLifespan(TimeUnit.DAYS.toMillis(1)) .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); } @@ -97,6 +91,11 @@ public class StorageSyncJob extends BaseJob { return; } + if (!TextSecurePreferences.isPushRegistered(context)) { + Log.i(TAG, "Not registered. Skipping."); + return; + } + try { boolean needsMultiDeviceSync = performSync(); @@ -112,11 +111,6 @@ 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)); - } } } @@ -145,26 +139,29 @@ public class StorageSyncJob extends BaseJob { if (remoteManifest.isPresent() && remoteManifestVersion > localManifestVersion) { Log.i(TAG, "[Remote Newer] Newer manifest version found!"); - List allLocalStorageKeys = getAllLocalStorageKeys(context); - KeyDifferenceResult keyDifference = StorageSyncHelper.findKeyDifference(remoteManifest.get().getStorageKeys(), allLocalStorageKeys); + List allLocalStorageKeys = getAllLocalStorageIds(context); + KeyDifferenceResult keyDifference = StorageSyncHelper.findKeyDifference(remoteManifest.get().getStorageIds(), 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 localOnly = buildLocalStorageRecords(context, keyDifference.getLocalOnlyKeys()); + Set archivedRecipients = DatabaseFactory.getThreadDatabase(context).getArchivedRecipients(); + List localOnly = buildLocalStorageRecords(context, keyDifference.getLocalOnlyKeys(), archivedRecipients); List remoteOnly = accountManager.readStorageRecords(storageServiceKey, keyDifference.getRemoteOnlyKeys()); MergeResult mergeResult = StorageSyncHelper.resolveConflict(remoteOnly, localOnly); WriteOperationResult writeOperationResult = StorageSyncHelper.createWriteOperation(remoteManifest.get().getVersion(), allLocalStorageKeys, mergeResult); + StorageSyncValidations.validate(writeOperationResult); + Log.i(TAG, "[Remote Newer] MergeResult :: " + mergeResult); 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()) { + if (writeOperationResult.getManifest().getStorageIds().size() != remoteManifest.get().getStorageIds().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())); + remoteManifest.get().getStorageIds().size(), writeOperationResult.getManifest().getStorageIds().size(), writeOperationResult.getInserts().size(), writeOperationResult.getDeletes().size())); } Optional conflict = accountManager.writeStorageRecords(storageServiceKey, writeOperationResult.getManifest(), writeOperationResult.getInserts(), writeOperationResult.getDeletes()); @@ -181,6 +178,7 @@ public class StorageSyncJob extends BaseJob { recipientDatabase.applyStorageSyncUpdates(mergeResult.getLocalContactInserts(), mergeResult.getLocalContactUpdates(), mergeResult.getLocalGroupV1Inserts(), mergeResult.getLocalGroupV1Updates()); storageKeyDatabase.applyStorageSyncUpdates(mergeResult.getLocalUnknownInserts(), mergeResult.getLocalUnknownDeletes()); + StorageSyncHelper.applyAccountStorageSyncUpdates(context, mergeResult.getLocalAccountUpdate()); needsMultiDeviceSync = true; Log.i(TAG, "[Remote Newer] Updating local manifest version to: " + remoteManifestVersion); @@ -194,20 +192,27 @@ public class StorageSyncJob extends BaseJob { localManifestVersion = TextSecurePreferences.getStorageManifestVersion(context); - List allLocalStorageKeys = recipientDatabase.getAllStorageSyncKeys(); - List pendingUpdates = recipientDatabase.getPendingRecipientSyncUpdates(); - List pendingInsertions = recipientDatabase.getPendingRecipientSyncInsertions(); - List pendingDeletions = recipientDatabase.getPendingRecipientSyncDeletions(); - Optional localWriteResult = StorageSyncHelper.buildStorageUpdatesForLocal(localManifestVersion, - allLocalStorageKeys, - pendingUpdates, - pendingInsertions, - pendingDeletions); + List allLocalStorageKeys = getAllLocalStorageIds(context); + List pendingUpdates = recipientDatabase.getPendingRecipientSyncUpdates(); + List pendingInsertions = recipientDatabase.getPendingRecipientSyncInsertions(); + List pendingDeletions = recipientDatabase.getPendingRecipientSyncDeletions(); + Optional pendingAccountUpdate = StorageSyncHelper.getPendingAccountSyncUpdate(context); + Optional pendingAccountInsert = StorageSyncHelper.getPendingAccountSyncInsert(context); + Set archivedRecipients = DatabaseFactory.getThreadDatabase(context).getArchivedRecipients(); + Optional localWriteResult = StorageSyncHelper.buildStorageUpdatesForLocal(localManifestVersion, + allLocalStorageKeys, + pendingUpdates, + pendingInsertions, + pendingDeletions, + pendingAccountUpdate, + pendingAccountInsert, + archivedRecipients); if (localWriteResult.isPresent()) { - Log.i(TAG, String.format(Locale.ENGLISH, "[Local Changes] Local changes present. %d updates, %d inserts, %d deletes.", pendingUpdates.size(), pendingInsertions.size(), pendingDeletions.size())); + Log.i(TAG, String.format(Locale.ENGLISH, "[Local Changes] Local changes present. %d updates, %d inserts, %d deletes, account update: %b, account insert %b.", pendingUpdates.size(), pendingInsertions.size(), pendingDeletions.size(), pendingAccountUpdate.isPresent(), pendingAccountInsert.isPresent())); WriteOperationResult localWrite = localWriteResult.get().getWriteResult(); + StorageSyncValidations.validate(localWrite); Log.i(TAG, "[Local Changes] WriteOperationResult :: " + localWrite); @@ -215,18 +220,19 @@ public class StorageSyncJob extends BaseJob { throw new AssertionError("Decided there were local writes, but our write result was empty!"); } - Optional conflict = accountManager.writeStorageRecords(storageServiceKey, localWrite.getManifest(), localWrite.getInserts(), localWrite.getDeletes()); + Optional conflict = accountManager.writeStorageRecords(storageServiceKey, localWrite.getManifest(), localWrite.getInserts(), localWrite.getDeletes()); if (conflict.isPresent()) { Log.w(TAG, "[Local Changes] Hit a conflict when trying to upload our local writes! Retrying."); throw new RetryLaterException(); } - List clearIds = new ArrayList<>(pendingUpdates.size() + pendingInsertions.size() + pendingDeletions.size()); + List clearIds = new ArrayList<>(pendingUpdates.size() + pendingInsertions.size() + pendingDeletions.size() + 1); clearIds.addAll(Stream.of(pendingUpdates).map(RecipientSettings::getId).toList()); clearIds.addAll(Stream.of(pendingInsertions).map(RecipientSettings::getId).toList()); clearIds.addAll(Stream.of(pendingDeletions).map(RecipientSettings::getId).toList()); + clearIds.add(Recipient.self().getId()); recipientDatabase.clearDirtyState(clearIds); recipientDatabase.updateStorageKeys(localWriteResult.get().getStorageKeyUpdates()); @@ -242,22 +248,44 @@ public class StorageSyncJob extends BaseJob { return needsMultiDeviceSync; } - private static @NonNull List getAllLocalStorageKeys(@NonNull Context context) { - return Util.concatenatedList(DatabaseFactory.getRecipientDatabase(context).getAllStorageSyncKeys(), + private static @NonNull List getAllLocalStorageIds(@NonNull Context context) { + Recipient self = Recipient.self().fresh(); + + return Util.concatenatedList(DatabaseFactory.getRecipientDatabase(context).getContactStorageSyncIds(), + Collections.singletonList(StorageId.forAccount(self.getStorageServiceId())), DatabaseFactory.getStorageKeyDatabase(context).getAllKeys()); } - private static @NonNull List buildLocalStorageRecords(@NonNull Context context, @NonNull List keys) { + private static @NonNull List buildLocalStorageRecords(@NonNull Context context, @NonNull List ids, @NonNull Set archivedRecipients) { RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context); - List records = new ArrayList<>(keys.size()); + List records = new ArrayList<>(ids.size()); - for (byte[] key : keys) { - SignalStorageRecord record = Optional.fromNullable(recipientDatabase.getByStorageSyncKey(key)) - .transform(StorageSyncHelper::localToRemoteRecord) - .or(() -> storageKeyDatabase.getByKey(key)); - records.add(record); + for (StorageId id : ids) { + switch (id.getType()) { + case ManifestRecord.Identifier.Type.CONTACT_VALUE: + case ManifestRecord.Identifier.Type.GROUPV1_VALUE: + case ManifestRecord.Identifier.Type.GROUPV2_VALUE: + RecipientSettings settings = recipientDatabase.getByStorageId(id.getRaw()); + if (settings != null) { + records.add(StorageSyncModels.localToRemoteRecord(settings, archivedRecipients)); + } else { + Log.w(TAG, "Missing local recipient model! Type: " + id.getType()); + } + break; + case ManifestRecord.Identifier.Type.ACCOUNT_VALUE: + records.add(StorageSyncHelper.buildAccountRecord(context, id)); + break; + default: + SignalStorageRecord unknown = storageKeyDatabase.getById(id.getRaw()); + if (unknown != null) { + records.add(unknown); + } else { + Log.w(TAG, "Missing local unknown model! Type: " + id.getType()); + } + break; + } } return records; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/TypingSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/TypingSendJob.java index 242270de3..fa3cd4b90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/TypingSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/TypingSendJob.java @@ -12,7 +12,6 @@ import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.Recipient; 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; @@ -87,7 +86,7 @@ public class TypingSendJob extends BaseJob { if (recipient.isGroup()) { recipients = DatabaseFactory.getGroupDatabase(context).getGroupMembers(recipient.requireGroupId(), false); - groupId = Optional.of(GroupUtil.getDecodedId(recipient.requireGroupId())); + groupId = Optional.of(recipient.requireGroupId().getDecodedId()); } recipients = Stream.of(recipients).map(Recipient::resolve).toList(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java index 603f1dd7a..1ec638286 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -18,7 +18,6 @@ public final class SignalStore { public static void onFirstEverAppLaunch() { registrationValues().onFirstEverAppLaunch(); - storageServiceValues().setFirstStorageSyncCompleted(false); } public static @NonNull KbsValues kbsValues() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java index 0d5cf65e0..4349bed77 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java @@ -9,9 +9,8 @@ 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 static final String STORAGE_MASTER_KEY = "storage.storage_master_key"; + private static final String LAST_SYNC_TIME = "storage.last_sync_time"; private final KeyValueStore store; @@ -38,14 +37,6 @@ public class StorageServiceValues { .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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java index 09fa733a6..37343f5b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.mediasend; import android.content.Context; import android.content.Intent; +import android.graphics.Point; import android.net.Uri; import android.os.Bundle; import android.widget.Toast; @@ -26,6 +27,8 @@ import java.util.Collections; public class AvatarSelectionActivity extends AppCompatActivity implements CameraFragment.Controller, ImageEditorFragment.Controller, MediaPickerFolderFragment.Controller, MediaPickerItemFragment.Controller { + private static final Point AVATAR_DIMENSIONS = new Point(1024, 1024); + private static final String IMAGE_CAPTURE = "IMAGE_CAPTURE"; private static final String IMAGE_EDITOR = "IMAGE_EDITOR"; private static final String ARG_GALLERY = "ARG_GALLERY"; @@ -199,9 +202,6 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera } ImageEditorFragment.Data data = (ImageEditorFragment.Data) fragment.saveState(); - if (data == null) { - throw new AssertionError(); - } EditorModel model = data.readModel(); if (model == null) { @@ -210,7 +210,7 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera MediaRepository.transformMedia(this, Collections.singletonList(currentMedia), - Collections.singletonMap(currentMedia, new ImageEditorModelRenderMediaTransform(model)), + Collections.singletonMap(currentMedia, new ImageEditorModelRenderMediaTransform(model, AVATAR_DIMENSIONS)), output -> { Media transformed = output.get(currentMedia); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraButtonView.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraButtonView.java index 7f90f532c..3dc8e4f39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraButtonView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraButtonView.java @@ -44,7 +44,6 @@ public class CameraButtonView extends View { private boolean isRecordingVideo; private float progressPercent = 0f; - private float latestIncrement = 0f; private @NonNull CameraButtonMode cameraButtonMode = CameraButtonMode.IMAGE; private @Nullable VideoCaptureListener videoCaptureListener; @@ -247,7 +246,6 @@ public class CameraButtonView extends View { int action = event.getAction(); switch (action) { case MotionEvent.ACTION_DOWN: - latestIncrement = 0f; if (isEnabled()) { startAnimation(shrinkAnimation); } @@ -258,11 +256,6 @@ public class CameraButtonView extends View { float deltaY = Math.abs(event.getY() - deadzoneRect.top); float increment = Math.min(1f, deltaY / maxRange); - if (Math.abs(increment - latestIncrement) < MINIMUM_ALLOWED_ZOOM_STEP) { - break; - } - - latestIncrement = increment; notifyZoomPercent(ZOOM_INTERPOLATOR.getInterpolation(increment)); invalidate(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java index 74db0b495..e22bd8923 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java @@ -101,7 +101,7 @@ class CameraContactsRepository { try (GroupDatabase.Reader reader = groupDatabase.getGroupsFilteredByTitle(query, false)) { GroupDatabase.GroupRecord groupRecord; while ((groupRecord = reader.getNext()) != null) { - RecipientId recipientId = recipientDatabase.getOrInsertFromGroupId(groupRecord.getEncodedId()); + RecipientId recipientId = recipientDatabase.getOrInsertFromGroupId(groupRecord.getId()); recipients.add(Recipient.resolved(recipientId)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java index 8ea39dad9..a7c65a1f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java @@ -22,8 +22,9 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; -import androidx.camera.core.CameraX; +import androidx.camera.core.CameraSelector; import androidx.camera.core.ImageCapture; +import androidx.camera.core.ImageCaptureException; import androidx.camera.core.ImageProxy; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; @@ -204,7 +205,9 @@ public class CameraXFragment extends Fragment implements CameraFragment { onCaptureClicked(); }); - if (camera.hasCameraWithLensFacing(CameraX.LensFacing.FRONT) && camera.hasCameraWithLensFacing(CameraX.LensFacing.BACK)) { + camera.setScaleType(CameraXView.ScaleType.CENTER_INSIDE); + + if (camera.hasCameraWithLensFacing(CameraSelector.LENS_FACING_FRONT) && camera.hasCameraWithLensFacing(CameraSelector.LENS_FACING_BACK)) { flipButton.setVisibility(View.VISIBLE); flipButton.setOnClickListener(v -> { camera.toggleCamera(); @@ -308,7 +311,7 @@ public class CameraXFragment extends Fragment implements CameraFragment { TooltipPopup.forTarget(captureButton) .setOnDismissListener(this::neverDisplayVideoRecordingTooltipAgain) - .setBackgroundTint(ContextCompat.getColor(requireContext(), R.color.signal_primary)) + .setBackgroundTint(ContextCompat.getColor(requireContext(), R.color.core_ultramarine)) .setTextColor(ThemeUtil.getThemedColor(requireContext(), R.attr.conversation_title_color)) .setText(R.string.CameraXFragment_tap_for_photo_hold_for_video) .show(displayRotation == Surface.ROTATION_0 || displayRotation == Surface.ROTATION_180 ? TooltipPopup.POSITION_ABOVE : TooltipPopup.POSITION_START); @@ -361,15 +364,15 @@ public class CameraXFragment extends Fragment implements CameraFragment { selfieFlash ); - camera.takePicture(Executors.mainThreadExecutor(), new ImageCapture.OnImageCapturedListener() { + camera.takePicture(Executors.mainThreadExecutor(), new ImageCapture.OnImageCapturedCallback() { @Override - public void onCaptureSuccess(ImageProxy image, int rotationDegrees) { + public void onCaptureSuccess(ImageProxy image) { flashHelper.endFlash(); SimpleTask.run(CameraXFragment.this.getViewLifecycleOwner().getLifecycle(), () -> { stopwatch.split("captured"); try { - return CameraXUtil.toJpeg(image, rotationDegrees, camera.getCameraLensFacing() == CameraX.LensFacing.FRONT); + return CameraXUtil.toJpeg(image, camera.getCameraLensFacing() == CameraSelector.LENS_FACING_FRONT); } catch (IOException e) { return null; } finally { @@ -388,7 +391,7 @@ public class CameraXFragment extends Fragment implements CameraFragment { } @Override - public void onError(ImageCapture.ImageCaptureError useCaseError, String message, @Nullable Throwable cause) { + public void onError(ImageCaptureException exception) { flashHelper.endFlash(); controller.onCameraError(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java index aa88dfb3b..2fa24a4d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java @@ -6,8 +6,9 @@ import android.view.WindowManager; import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; +import androidx.camera.core.CameraSelector; import androidx.camera.core.CameraX; -import androidx.camera.core.FlashMode; +import androidx.camera.core.ImageCapture; import org.thoughtcrime.securesms.mediasend.camerax.CameraXView; @@ -65,8 +66,10 @@ final class CameraXSelfieFlashHelper { } private boolean shouldUseViewBasedFlash() { - return camera.getFlash() == FlashMode.ON && + Integer cameraLensFacing = camera.getCameraLensFacing(); + + return camera.getFlash() == ImageCapture.FLASH_MODE_ON && !camera.hasFlash() && - camera.getCameraLensFacing() == CameraX.LensFacing.FRONT; + cameraLensFacing != null && cameraLensFacing == CameraSelector.LENS_FACING_BACK; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java index 362b34280..4c502c972 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java @@ -23,7 +23,6 @@ import org.thoughtcrime.securesms.mediasend.camerax.CameraXView; import org.thoughtcrime.securesms.mediasend.camerax.VideoCapture; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.util.MemoryFileDescriptor; -import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.video.VideoUtil; import java.io.FileDescriptor; @@ -45,12 +44,12 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener private boolean isRecording; private ValueAnimator cameraMetricsAnimator; - private final VideoCapture.OnVideoSavedListener videoSavedListener = new VideoCapture.OnVideoSavedListener() { + private final VideoCapture.OnVideoSavedCallback videoSavedListener = new VideoCapture.OnVideoSavedCallback() { @Override public void onVideoSaved(@NonNull FileDescriptor fileDescriptor) { try { isRecording = false; - camera.setZoomLevel(0f); + camera.setZoomRatio(camera.getMinZoomRatio()); memoryFileDescriptor.seek(0); callback.onVideoSaved(fileDescriptor); } catch (IOException e) { @@ -59,13 +58,9 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener } @Override - public void onError(@NonNull VideoCapture.VideoCaptureError videoCaptureError, - @NonNull String message, - @Nullable Throwable cause) - { + public void onError(int videoCaptureError, @NonNull String message, @Nullable Throwable cause) { isRecording = false; callback.onVideoError(cause); - Util.runOnMain(() -> resetCameraSizing()); } }; @@ -119,7 +114,7 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener } private void beginCameraRecording() { - this.camera.setZoomLevel(0f); + this.camera.setZoomRatio(this.camera.getMinZoomRatio()); callback.onVideoRecordStarted(); shrinkCaptureArea(); camera.startRecording(memoryFileDescriptor.getFileDescriptor(), Executors.mainThreadExecutor(), videoSavedListener); @@ -135,22 +130,24 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener if (scaleX == 1f) { float targetHeightForAnimation = videoRecordingSize.getHeight() * scale; + + if (screenSize.getHeight() == targetHeightForAnimation) { + return; + } + cameraMetricsAnimator = ValueAnimator.ofFloat(screenSize.getHeight(), targetHeightForAnimation); } else { + + if (screenSize.getWidth() == targetWidthForAnimation) { + return; + } + cameraMetricsAnimator = ValueAnimator.ofFloat(screenSize.getWidth(), targetWidthForAnimation); } ViewGroup.LayoutParams params = camera.getLayoutParams(); cameraMetricsAnimator.setInterpolator(new LinearInterpolator()); cameraMetricsAnimator.setDuration(200); - cameraMetricsAnimator.addListener(new AnimationEndCallback() { - @Override - public void onAnimationEnd(Animator animation) { - if (!isRecording) return; - - scaleCameraViewToMatchRecordingSizeAndAspectRatio(); - } - }); cameraMetricsAnimator.addUpdateListener(animation -> { if (scaleX == 1f) { params.height = Math.round((float) animation.getAnimatedValue()); @@ -162,20 +159,6 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener cameraMetricsAnimator.start(); } - private void scaleCameraViewToMatchRecordingSizeAndAspectRatio() { - ViewGroup.LayoutParams layoutParams = camera.getLayoutParams(); - - Size videoRecordingSize = VideoUtil.getVideoRecordingSize(); - float scale = getSurfaceScaleForRecording(); - - layoutParams.height = videoRecordingSize.getHeight(); - layoutParams.width = videoRecordingSize.getWidth(); - - camera.setLayoutParams(layoutParams); - camera.setScaleX(scale); - camera.setScaleY(scale); - } - private Size getScreenSize() { DisplayMetrics metrics = camera.getResources().getDisplayMetrics(); return new Size(metrics.widthPixels, metrics.heightPixels); @@ -187,16 +170,6 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener return Math.min(screenSize.getHeight(), screenSize.getWidth()) / (float) Math.min(videoRecordingSize.getHeight(), videoRecordingSize.getWidth()); } - private void resetCameraSizing() { - ViewGroup.LayoutParams layoutParams = camera.getLayoutParams(); - layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; - layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT; - - camera.setLayoutParams(layoutParams); - camera.setScaleX(1); - camera.setScaleY(1); - } - @Override public void onVideoCaptureComplete() { isRecording = false; @@ -214,8 +187,8 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener @Override public void onZoomIncremented(float increment) { - float range = camera.getMaxZoomLevel() - camera.getMinZoomLevel(); - camera.setZoomLevel(range * increment); + float range = camera.getMaxZoomRatio() - camera.getMinZoomRatio(); + camera.setZoomRatio((range * increment) + camera.getMinZoomRatio()); } static MemoryFileDescriptor createFileDescriptor(@NonNull Context context) throws MemoryFileDescriptor.MemoryFileException { @@ -226,7 +199,7 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener ); } - private abstract class AnimationEndCallback implements Animator.AnimatorListener { + private static abstract class AnimationEndCallback implements Animator.AnimatorListener { @Override public final void onAnimationStart(Animator animation) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/ImageEditorModelRenderMediaTransform.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/ImageEditorModelRenderMediaTransform.java index 9c20f81c5..23bbaea90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/ImageEditorModelRenderMediaTransform.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/ImageEditorModelRenderMediaTransform.java @@ -2,15 +2,18 @@ package org.thoughtcrime.securesms.mediasend; import android.content.Context; import android.graphics.Bitmap; +import android.graphics.Point; import android.net.Uri; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import org.thoughtcrime.securesms.imageeditor.model.EditorModel; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; import java.io.ByteArrayOutputStream; @@ -20,10 +23,16 @@ public final class ImageEditorModelRenderMediaTransform implements MediaTransfor private static final String TAG = Log.tag(ImageEditorModelRenderMediaTransform.class); - private final EditorModel modelToRender; + @NonNull private final EditorModel modelToRender; + @Nullable private final Point size; ImageEditorModelRenderMediaTransform(@NonNull EditorModel modelToRender) { + this(modelToRender, null); + } + + ImageEditorModelRenderMediaTransform(@NonNull EditorModel modelToRender, @Nullable Point size) { this.modelToRender = modelToRender; + this.size = size; } @WorkerThread @@ -31,7 +40,7 @@ public final class ImageEditorModelRenderMediaTransform implements MediaTransfor public @NonNull Media transform(@NonNull Context context, @NonNull Media media) { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - Bitmap bitmap = modelToRender.render(context); + Bitmap bitmap = modelToRender.render(context, size); try { bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream); @@ -46,11 +55,7 @@ public final class ImageEditorModelRenderMediaTransform implements MediaTransfor return media; } finally { bitmap.recycle(); - try { - outputStream.close(); - } catch (IOException e) { - Log.w(TAG, e); - } + Util.close(outputStream); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java index d04e93588..d57e7dfb6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -727,7 +727,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple case VIEW_ONCE_TOOLTIP: TooltipPopup.forTarget(revealButton) .setText(R.string.MediaSendActivity_tap_here_to_make_this_message_disappear_after_it_is_viewed) - .setBackgroundTint(getResources().getColor(R.color.core_blue)) + .setBackgroundTint(getResources().getColor(R.color.core_ultramarine)) .setTextColor(getResources().getColor(R.color.core_white)) .setOnDismissListener(() -> TextSecurePreferences.setHasSeenViewOnceTooltip(this, true)) .show(TooltipPopup.POSITION_ABOVE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXFlashToggleView.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXFlashToggleView.java index 588b44e45..28646bf95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXFlashToggleView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXFlashToggleView.java @@ -5,10 +5,9 @@ import android.os.Bundle; import android.os.Parcelable; import android.util.AttributeSet; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatImageView; -import androidx.camera.core.FlashMode; +import androidx.camera.core.ImageCapture; import org.thoughtcrime.securesms.R; @@ -43,7 +42,7 @@ public final class CameraXFlashToggleView extends AppCompatImageView { public CameraXFlashToggleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); - super.setOnClickListener((v) -> setFlash(FLASH_MODES.get((flashIndex + 1) % FLASH_ENUM.length))); + super.setOnClickListener((v) -> setFlash(FLASH_MODES.get((flashIndex + 1) % FLASH_ENUM.length).getFlashMode())); } @Override @@ -61,10 +60,12 @@ public final class CameraXFlashToggleView extends AppCompatImageView { public void setAutoFlashEnabled(boolean isAutoEnabled) { supportsFlashModeAuto = isAutoEnabled; - setFlash(FLASH_MODES.get(flashIndex)); + setFlash(FLASH_MODES.get(flashIndex).getFlashMode()); } - public void setFlash(@NonNull FlashMode flashMode) { + public void setFlash(@ImageCapture.FlashMode int mode) { + FlashMode flashMode = FlashMode.fromImageCaptureFlashMode(mode); + flashIndex = resolveFlashIndex(FLASH_MODES.indexOf(flashMode), supportsFlashModeAuto); refreshDrawableState(); notifyListener(); @@ -92,7 +93,7 @@ public final class CameraXFlashToggleView extends AppCompatImageView { supportsFlashModeAuto = savedState.getBoolean(STATE_SUPPORT_AUTO); setFlash(FLASH_MODES.get( - resolveFlashIndex(savedState.getInt(STATE_FLASH_INDEX), supportsFlashModeAuto)) + resolveFlashIndex(savedState.getInt(STATE_FLASH_INDEX), supportsFlashModeAuto)).getFlashMode() ); super.onRestoreInstanceState(savedState.getParcelable(STATE_PARENT)); @@ -104,7 +105,7 @@ public final class CameraXFlashToggleView extends AppCompatImageView { private void notifyListener() { if (flashModeChangedListener == null) return; - flashModeChangedListener.flashModeChanged(FLASH_MODES.get(flashIndex)); + flashModeChangedListener.flashModeChanged(FLASH_MODES.get(flashIndex).getFlashMode()); } private static int resolveFlashIndex(int desiredFlashIndex, boolean supportsFlashModeAuto) { @@ -126,6 +127,33 @@ public final class CameraXFlashToggleView extends AppCompatImageView { } public interface OnFlashModeChangedListener { - void flashModeChanged(FlashMode flashMode); + void flashModeChanged(@ImageCapture.CaptureMode int flashMode); + } + + private enum FlashMode { + + AUTO(ImageCapture.FLASH_MODE_AUTO), + OFF(ImageCapture.FLASH_MODE_OFF), + ON(ImageCapture.FLASH_MODE_ON); + + private final @ImageCapture.FlashMode int flashMode; + + FlashMode(@ImageCapture.FlashMode int flashMode) { + this.flashMode = flashMode; + } + + @ImageCapture.FlashMode int getFlashMode() { + return flashMode; + } + + private static FlashMode fromImageCaptureFlashMode(@ImageCapture.FlashMode int flashMode) { + for (FlashMode mode : values()) { + if (mode.getFlashMode() == flashMode) { + return mode; + } + } + + throw new AssertionError(); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModule.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModule.java index 3d6d9983c..d4d54531b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModule.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModule.java @@ -20,14 +20,6 @@ import android.Manifest.permission; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.Resources; -import android.graphics.Matrix; -import android.graphics.Rect; -import android.graphics.SurfaceTexture; -import android.hardware.camera2.CameraAccessException; -import android.hardware.camera2.CameraCharacteristics; -import android.hardware.camera2.CameraManager; -import android.os.Build; -import android.os.Looper; import android.util.Log; import android.util.Rational; import android.util.Size; @@ -36,41 +28,53 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.RequiresPermission; -import androidx.annotation.UiThread; import androidx.camera.core.AspectRatio; -import androidx.camera.core.CameraInfo; -import androidx.camera.core.CameraInfoUnavailableException; -import androidx.camera.core.CameraOrientationUtil; +import androidx.camera.core.Camera; +import androidx.camera.core.CameraSelector; import androidx.camera.core.CameraX; -import androidx.camera.core.FlashMode; import androidx.camera.core.ImageCapture; -import androidx.camera.core.ImageCaptureConfig; +import androidx.camera.core.ImageCapture.OnImageCapturedCallback; import androidx.camera.core.Preview; -import androidx.camera.core.PreviewConfig; -import androidx.camera.core.VideoCaptureConfig; +import androidx.camera.core.TorchState; +import androidx.camera.core.UseCase; +import androidx.camera.core.impl.CameraInternal; +import androidx.camera.core.impl.LensFacingConverter; +import androidx.camera.core.impl.VideoCaptureConfig; +import androidx.camera.core.impl.utils.CameraOrientationUtil; +import androidx.camera.core.impl.utils.executor.CameraXExecutors; +import androidx.camera.core.impl.utils.futures.FutureCallback; +import androidx.camera.core.impl.utils.futures.Futures; +import androidx.camera.lifecycle.ProcessCameraProvider; +import androidx.core.util.Preconditions; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleObserver; import androidx.lifecycle.LifecycleOwner; -import androidx.lifecycle.LiveData; import androidx.lifecycle.OnLifecycleEvent; +import com.google.common.util.concurrent.ListenableFuture; + import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.video.VideoUtil; -import java.io.File; import java.io.FileDescriptor; +import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicBoolean; +import static androidx.camera.core.ImageCapture.FLASH_MODE_OFF; + /** CameraX use case operation built on @{link androidx.camera.core}. */ +// Begin Signal Custom Code Block @RequiresApi(21) +// End Signal Custom Code Block final class CameraXModule { public static final String TAG = "CameraXModule"; - private static final int MAX_VIEW_DIMENSION = 2000; private static final float UNITY_ZOOM_SCALE = 1f; private static final float ZOOM_NOT_SUPPORTED = UNITY_ZOOM_SCALE; private static final Rational ASPECT_RATIO_16_9 = new Rational(16, 9); @@ -78,22 +82,27 @@ final class CameraXModule { private static final Rational ASPECT_RATIO_9_16 = new Rational(9, 16); private static final Rational ASPECT_RATIO_3_4 = new Rational(3, 4); - private final CameraManager mCameraManager; - private final PreviewConfig.Builder mPreviewConfigBuilder; + private final Preview.Builder mPreviewBuilder; private final VideoCaptureConfig.Builder mVideoCaptureConfigBuilder; - private final ImageCaptureConfig.Builder mImageCaptureConfigBuilder; - private final CameraXView mCameraView; + private final ImageCapture.Builder mImageCaptureBuilder; + private final CameraXView mCameraXView; final AtomicBoolean mVideoIsRecording = new AtomicBoolean(false); private CameraXView.CaptureMode mCaptureMode = CameraXView.CaptureMode.IMAGE; private long mMaxVideoDuration = CameraXView.INDEFINITE_VIDEO_DURATION; private long mMaxVideoSize = CameraXView.INDEFINITE_VIDEO_SIZE; - private FlashMode mFlash = FlashMode.OFF; + @ImageCapture.FlashMode + private int mFlash = FLASH_MODE_OFF; + @Nullable + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ + Camera mCamera; @Nullable private ImageCapture mImageCapture; @Nullable private VideoCapture mVideoCapture; + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ @Nullable Preview mPreview; + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ @Nullable LifecycleOwner mCurrentLifecycle; private final LifecycleObserver mCurrentLifecycleObserver = @@ -102,27 +111,44 @@ final class CameraXModule { public void onDestroy(LifecycleOwner owner) { if (owner == mCurrentLifecycle) { clearCurrentLifecycle(); - mPreview.removePreviewOutputListener(); + mPreview.setSurfaceProvider(null); } } }; @Nullable private LifecycleOwner mNewLifecycle; - private float mZoomLevel = UNITY_ZOOM_SCALE; + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ @Nullable - private Rect mCropRegion; + Integer mCameraLensFacing = CameraSelector.LENS_FACING_BACK; + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ @Nullable - private CameraX.LensFacing mCameraLensFacing = CameraX.LensFacing.BACK; + ProcessCameraProvider mCameraProvider; CameraXModule(CameraXView view) { - this.mCameraView = view; + mCameraXView = view; - mCameraManager = (CameraManager) view.getContext().getSystemService(Context.CAMERA_SERVICE); + Futures.addCallback(ProcessCameraProvider.getInstance(view.getContext()), + new FutureCallback() { + // TODO(b/124269166): Rethink how we can handle permissions here. + @SuppressLint("MissingPermission") + @Override + public void onSuccess(@Nullable ProcessCameraProvider provider) { + Preconditions.checkNotNull(provider); + mCameraProvider = provider; + if (mCurrentLifecycle != null) { + bindToLifecycle(mCurrentLifecycle); + } + } - mPreviewConfigBuilder = new PreviewConfig.Builder().setTargetName("Preview"); + @Override + public void onFailure(Throwable t) { + throw new RuntimeException("CameraX failed to initialize.", t); + } + }, CameraXExecutors.mainThreadExecutor()); - mImageCaptureConfigBuilder = - new ImageCaptureConfig.Builder().setTargetName("ImageCapture"); + mPreviewBuilder = new Preview.Builder().setTargetName("Preview"); + + mImageCaptureBuilder = new ImageCapture.Builder().setTargetName("ImageCapture"); // Begin Signal Custom Code Block mVideoCaptureConfigBuilder = @@ -132,42 +158,8 @@ final class CameraXModule { .setBitRate(VideoUtil.VIDEO_BIT_RATE); // End Signal Custom Code Block } - - /** - * Rescales view rectangle with dimensions in [-1000, 1000] to a corresponding rectangle in the - * sensor coordinate frame. - */ - private static Rect rescaleViewRectToSensorRect(Rect view, Rect sensor) { - // Scale width and height. - int newWidth = Math.round(view.width() * sensor.width() / (float) MAX_VIEW_DIMENSION); - int newHeight = Math.round(view.height() * sensor.height() / (float) MAX_VIEW_DIMENSION); - - // Scale top/left corner. - int halfViewDimension = MAX_VIEW_DIMENSION / 2; - int leftOffset = - Math.round( - (view.left + halfViewDimension) - * sensor.width() - / (float) MAX_VIEW_DIMENSION) - + sensor.left; - int topOffset = - Math.round( - (view.top + halfViewDimension) - * sensor.height() - / (float) MAX_VIEW_DIMENSION) - + sensor.top; - - // Now, produce the scaled rect. - Rect scaled = new Rect(); - scaled.left = leftOffset; - scaled.top = topOffset; - scaled.right = scaled.left + newWidth; - scaled.bottom = scaled.top + newHeight; - return scaled; - } - @RequiresPermission(permission.CAMERA) - public void bindToLifecycle(LifecycleOwner lifecycleOwner) { + void bindToLifecycle(LifecycleOwner lifecycleOwner) { mNewLifecycle = lifecycleOwner; if (getMeasuredWidth() > 0 && getMeasuredHeight() > 0) { @@ -189,38 +181,33 @@ final class CameraXModule { throw new IllegalArgumentException("Cannot bind to lifecycle in a destroyed state."); } - final int cameraOrientation; - try { - Set available = getAvailableCameraLensFacing(); + if (mCameraProvider == null) { + // try again once the camera provider is no longer null + return; + } - if (available.isEmpty()) { - Log.w(TAG, "Unable to bindToLifeCycle since no cameras available"); - mCameraLensFacing = null; - } + Set available = getAvailableCameraLensFacing(); - // Ensure the current camera exists, or default to another camera - if (mCameraLensFacing != null && !available.contains(mCameraLensFacing)) { - Log.w(TAG, "Camera does not exist with direction " + mCameraLensFacing); + if (available.isEmpty()) { + Log.w(TAG, "Unable to bindToLifeCycle since no cameras available"); + mCameraLensFacing = null; + } - // Default to the first available camera direction - mCameraLensFacing = available.iterator().next(); + // Ensure the current camera exists, or default to another camera + if (mCameraLensFacing != null && !available.contains(mCameraLensFacing)) { + Log.w(TAG, "Camera does not exist with direction " + mCameraLensFacing); - Log.w(TAG, "Defaulting to primary camera with direction " + mCameraLensFacing); - } + // Default to the first available camera direction + mCameraLensFacing = available.iterator().next(); - // Do not attempt to create use cases for a null cameraLensFacing. This could occur if - // the - // user explicitly sets the LensFacing to null, or if we determined there - // were no available cameras, which should be logged in the logic above. - if (mCameraLensFacing == null) { - return; - } - CameraInfo cameraInfo = CameraX.getCameraInfo(getLensFacing()); - cameraOrientation = cameraInfo.getSensorRotationDegrees(); - } catch (CameraInfoUnavailableException e) { - throw new IllegalStateException("Unable to get Camera Info.", e); - } catch (Exception e) { - throw new IllegalStateException("Unable to bind to lifecycle.", e); + Log.w(TAG, "Defaulting to primary camera with direction " + mCameraLensFacing); + } + + // Do not attempt to create use cases for a null cameraLensFacing. This could occur if + // the user explicitly sets the LensFacing to null, or if we determined there + // were no available cameras, which should be logged in the logic above. + if (mCameraLensFacing == null) { + return; } // Set the preferred aspect ratio as 4:3 if it is IMAGE only mode. Set the preferred aspect @@ -230,23 +217,32 @@ final class CameraXModule { boolean isDisplayPortrait = getDisplayRotationDegrees() == 0 || getDisplayRotationDegrees() == 180; - // Begin Signal Custom Code Block Rational targetAspectRatio; + + // Begin Signal Custom Code Block int resolution = CameraXUtil.getIdealResolution(Resources.getSystem().getDisplayMetrics().widthPixels, Resources.getSystem().getDisplayMetrics().heightPixels); - Log.i(TAG, "Ideal resolution: " + resolution); - if (getCaptureMode() == CameraXView.CaptureMode.IMAGE) { - mImageCaptureConfigBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_4_3, isDisplayPortrait)); - targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_3_4 : ASPECT_RATIO_4_3; - } else { - mImageCaptureConfigBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, isDisplayPortrait)); - targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9; - } - mImageCaptureConfigBuilder.setCaptureMode(CameraXUtil.getOptimalCaptureMode()); - mImageCaptureConfigBuilder.setLensFacing(mCameraLensFacing); // End Signal Custom Code Block - mImageCaptureConfigBuilder.setTargetRotation(getDisplaySurfaceRotation()); - mImageCapture = new ImageCapture(mImageCaptureConfigBuilder.build()); + if (getCaptureMode() == CameraXView.CaptureMode.IMAGE) { +// mImageCaptureBuilder.setTargetAspectRatio(AspectRatio.RATIO_4_3); + // Begin Signal Custom Code Block + mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_4_3, isDisplayPortrait)); + // End Signal Custom Code Block + targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_3_4 : ASPECT_RATIO_4_3; + } else { + // Begin Signal Custom Code Block + mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, isDisplayPortrait)); + // End Signal Custom Code Block +// mImageCaptureBuilder.setTargetAspectRatio(AspectRatio.RATIO_16_9); + targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9; + } + + // Begin Signal Custom Code Block + mImageCaptureBuilder.setCaptureMode(CameraXUtil.getOptimalCaptureMode()); + // End Signal Custom Code Block + + mImageCaptureBuilder.setTargetRotation(getDisplaySurfaceRotation()); + mImageCapture = mImageCaptureBuilder.build(); // Begin Signal Custom Code Block Size size = VideoUtil.getVideoRecordingSize(); @@ -255,46 +251,37 @@ final class CameraXModule { // End Signal Custom Code Block mVideoCaptureConfigBuilder.setTargetRotation(getDisplaySurfaceRotation()); - mVideoCaptureConfigBuilder.setLensFacing(mCameraLensFacing); // Begin Signal Custom Code Block if (MediaConstraints.isVideoTranscodeAvailable()) { - mVideoCapture = new VideoCapture(mVideoCaptureConfigBuilder.build()); + mVideoCapture = new VideoCapture(mVideoCaptureConfigBuilder.getUseCaseConfig()); } - mPreviewConfigBuilder.setLensFacing(mCameraLensFacing); + // End Signal Custom Code Block // Adjusts the preview resolution according to the view size and the target aspect ratio. int height = (int) (getMeasuredWidth() / targetAspectRatio.floatValue()); - mPreviewConfigBuilder.setTargetResolution(new Size(getMeasuredWidth(), height)); + mPreviewBuilder.setTargetResolution(new Size(getMeasuredWidth(), height)); - mPreview = new Preview(mPreviewConfigBuilder.build()); - mPreview.setOnPreviewOutputUpdateListener( - new Preview.OnPreviewOutputUpdateListener() { - @Override - public void onUpdated(@NonNull Preview.PreviewOutput output) { - boolean needReverse = cameraOrientation != 0 && cameraOrientation != 180; - int textureWidth = - needReverse - ? output.getTextureSize().getHeight() - : output.getTextureSize().getWidth(); - int textureHeight = - needReverse - ? output.getTextureSize().getWidth() - : output.getTextureSize().getHeight(); - CameraXModule.this.onPreviewSourceDimensUpdated(textureWidth, - textureHeight); - CameraXModule.this.setSurfaceTexture(output.getSurfaceTexture()); - } - }); + mPreview = mPreviewBuilder.build(); + mPreview.setSurfaceProvider(mCameraXView.getPreviewView().getPreviewSurfaceProvider()); + CameraSelector cameraSelector = + new CameraSelector.Builder().requireLensFacing(mCameraLensFacing).build(); if (getCaptureMode() == CameraXView.CaptureMode.IMAGE) { - CameraX.bindToLifecycle(mCurrentLifecycle, mImageCapture, mPreview); + mCamera = mCameraProvider.bindToLifecycle(mCurrentLifecycle, cameraSelector, + mImageCapture, + mPreview); } else if (getCaptureMode() == CameraXView.CaptureMode.VIDEO) { - CameraX.bindToLifecycle(mCurrentLifecycle, mVideoCapture, mPreview); + mCamera = mCameraProvider.bindToLifecycle(mCurrentLifecycle, cameraSelector, + mVideoCapture, + mPreview); } else { - CameraX.bindToLifecycle(mCurrentLifecycle, mImageCapture, mVideoCapture, mPreview); + mCamera = mCameraProvider.bindToLifecycle(mCurrentLifecycle, cameraSelector, + mImageCapture, + mVideoCapture, mPreview); } - setZoomLevel(mZoomLevel); + + setZoomRatio(UNITY_ZOOM_SCALE); mCurrentLifecycle.getLifecycle().addObserver(mCurrentLifecycleObserver); // Enable flash setting in ImageCapture after use cases are created and binded. setFlash(getFlash()); @@ -310,7 +297,7 @@ final class CameraXModule { "Explicit open/close of camera not yet supported. Use bindtoLifecycle() instead."); } - public void takePicture(Executor executor, ImageCapture.OnImageCapturedListener listener) { + public void takePicture(Executor executor, OnImageCapturedCallback callback) { if (mImageCapture == null) { return; } @@ -319,35 +306,19 @@ final class CameraXModule { throw new IllegalStateException("Can not take picture under VIDEO capture mode."); } - if (listener == null) { - throw new IllegalArgumentException("OnImageCapturedListener should not be empty"); + if (callback == null) { + throw new IllegalArgumentException("OnImageCapturedCallback should not be empty"); } - mImageCapture.takePicture(executor, listener); - } - - public void takePicture(File saveLocation, Executor executor, ImageCapture.OnImageSavedListener listener) { - if (mImageCapture == null) { - return; - } - - if (getCaptureMode() == CameraXView.CaptureMode.VIDEO) { - throw new IllegalStateException("Can not take picture under VIDEO capture mode."); - } - - if (listener == null) { - throw new IllegalArgumentException("OnImageSavedListener should not be empty"); - } - - ImageCapture.Metadata metadata = new ImageCapture.Metadata(); - metadata.isReversedHorizontal = mCameraLensFacing == CameraX.LensFacing.FRONT; - mImageCapture.takePicture(saveLocation, metadata, executor, listener); + mImageCapture.takePicture(executor, callback); } // Begin Signal Custom Code Block @RequiresApi(26) - public void startRecording(FileDescriptor file, Executor executor, final VideoCapture.OnVideoSavedListener listener) { - // End Signal Custom Code Block + public void startRecording(FileDescriptor file, + // End Signal Custom Code Block + Executor executor, + final VideoCapture.OnVideoSavedCallback callback) { if (mVideoCapture == null) { return; } @@ -356,31 +327,31 @@ final class CameraXModule { throw new IllegalStateException("Can not record video under IMAGE capture mode."); } - if (listener == null) { - throw new IllegalArgumentException("OnVideoSavedListener should not be empty"); + if (callback == null) { + throw new IllegalArgumentException("OnVideoSavedCallback should not be empty"); } mVideoIsRecording.set(true); mVideoCapture.startRecording( file, executor, - new VideoCapture.OnVideoSavedListener() { + new VideoCapture.OnVideoSavedCallback() { @Override - // Begin Signal Custom Code block + // Begin Signal Custom Code Block public void onVideoSaved(@NonNull FileDescriptor savedFile) { // End Signal Custom Code Block mVideoIsRecording.set(false); - listener.onVideoSaved(savedFile); + callback.onVideoSaved(savedFile); } @Override public void onError( - @NonNull VideoCapture.VideoCaptureError videoCaptureError, + @VideoCapture.VideoCaptureError int videoCaptureError, @NonNull String message, @Nullable Throwable cause) { mVideoIsRecording.set(false); Log.e(TAG, message, cause); - listener.onError(videoCaptureError, message, cause); + callback.onError(videoCaptureError, message, cause); } }); } @@ -402,9 +373,9 @@ final class CameraXModule { // TODO(b/124269166): Rethink how we can handle permissions here. @SuppressLint("MissingPermission") - public void setCameraLensFacing(@Nullable CameraX.LensFacing lensFacing) { + public void setCameraLensFacing(@Nullable Integer lensFacing) { // Setting same lens facing is a no-op, so check for that first - if (mCameraLensFacing != lensFacing) { + if (!Objects.equals(mCameraLensFacing, lensFacing)) { // If we're not bound to a lifecycle, just update the camera that will be opened when we // attach to a lifecycle. mCameraLensFacing = lensFacing; @@ -417,7 +388,7 @@ final class CameraXModule { } @RequiresPermission(permission.CAMERA) - public boolean hasCameraWithLensFacing(CameraX.LensFacing lensFacing) { + public boolean hasCameraWithLensFacing(@CameraSelector.LensFacing int lensFacing) { String cameraId; try { cameraId = CameraX.getCameraWithLensFacing(lensFacing); @@ -429,14 +400,14 @@ final class CameraXModule { } @Nullable - public CameraX.LensFacing getLensFacing() { + public Integer getLensFacing() { return mCameraLensFacing; } public void toggleCamera() { // TODO(b/124269166): Rethink how we can handle permissions here. @SuppressLint("MissingPermission") - Set availableCameraLensFacing = getAvailableCameraLensFacing(); + Set availableCameraLensFacing = getAvailableCameraLensFacing(); if (availableCameraLensFacing.isEmpty()) { return; @@ -447,106 +418,65 @@ final class CameraXModule { return; } - if (mCameraLensFacing == CameraX.LensFacing.BACK - && availableCameraLensFacing.contains(CameraX.LensFacing.FRONT)) { - setCameraLensFacing(CameraX.LensFacing.FRONT); + if (mCameraLensFacing == CameraSelector.LENS_FACING_BACK + && availableCameraLensFacing.contains(CameraSelector.LENS_FACING_FRONT)) { + setCameraLensFacing(CameraSelector.LENS_FACING_FRONT); return; } - if (mCameraLensFacing == CameraX.LensFacing.FRONT - && availableCameraLensFacing.contains(CameraX.LensFacing.BACK)) { - setCameraLensFacing(CameraX.LensFacing.BACK); + if (mCameraLensFacing == CameraSelector.LENS_FACING_FRONT + && availableCameraLensFacing.contains(CameraSelector.LENS_FACING_BACK)) { + setCameraLensFacing(CameraSelector.LENS_FACING_BACK); return; } } - public float getZoomLevel() { - return mZoomLevel; + public float getZoomRatio() { + if (mCamera != null) { + return mCamera.getCameraInfo().getZoomState().getValue().getZoomRatio(); + } else { + return UNITY_ZOOM_SCALE; + } } - public void setZoomLevel(float zoomLevel) { - // Set the zoom level in case it is set before binding to a lifecycle - this.mZoomLevel = zoomLevel; + public void setZoomRatio(float zoomRatio) { + if (mCamera != null) { + ListenableFuture future = mCamera.getCameraControl().setZoomRatio( + zoomRatio); + Futures.addCallback(future, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void result) { + } - if (mPreview == null) { - // Nothing to zoom on yet since we don't have a preview. Defer calculating crop - // region. - return; + @Override + public void onFailure(Throwable t) { + // Throw the unexpected error. + throw new RuntimeException(t); + } + }, CameraXExecutors.directExecutor()); + } else { + Log.e(TAG, "Failed to set zoom ratio"); } - - Rect sensorSize; - try { - sensorSize = getSensorSize(getActiveCamera()); - if (sensorSize == null) { - Log.e(TAG, "Failed to get the sensor size."); - return; - } - } catch (Exception e) { - Log.e(TAG, "Failed to get the sensor size.", e); - return; - } - - float minZoom = getMinZoomLevel(); - float maxZoom = getMaxZoomLevel(); - - if (this.mZoomLevel < minZoom) { - Log.e(TAG, "Requested zoom level is less than minimum zoom level."); - } - if (this.mZoomLevel > maxZoom) { - Log.e(TAG, "Requested zoom level is greater than maximum zoom level."); - } - this.mZoomLevel = Math.max(minZoom, Math.min(maxZoom, this.mZoomLevel)); - - float zoomScaleFactor = - (maxZoom == minZoom) ? minZoom : (this.mZoomLevel - minZoom) / (maxZoom - minZoom); - int minWidth = Math.round(sensorSize.width() / maxZoom); - int minHeight = Math.round(sensorSize.height() / maxZoom); - int diffWidth = sensorSize.width() - minWidth; - int diffHeight = sensorSize.height() - minHeight; - float cropWidth = diffWidth * zoomScaleFactor; - float cropHeight = diffHeight * zoomScaleFactor; - - Rect cropRegion = - new Rect( - /*left=*/ (int) Math.ceil(cropWidth / 2 - 0.5f), - /*top=*/ (int) Math.ceil(cropHeight / 2 - 0.5f), - /*right=*/ (int) Math.floor(sensorSize.width() - cropWidth / 2 + 0.5f), - /*bottom=*/ (int) Math.floor(sensorSize.height() - cropHeight / 2 + 0.5f)); - - if (cropRegion.width() < 50 || cropRegion.height() < 50) { - Log.e(TAG, "Crop region is too small to compute 3A stats, so ignoring further zoom."); - return; - } - this.mCropRegion = cropRegion; - - mPreview.zoom(cropRegion); } - public float getMinZoomLevel() { - return UNITY_ZOOM_SCALE; + public float getMinZoomRatio() { + if (mCamera != null) { + return mCamera.getCameraInfo().getZoomState().getValue().getMinZoomRatio(); + } else { + return UNITY_ZOOM_SCALE; + } } - public float getMaxZoomLevel() { - try { - CameraCharacteristics characteristics = - mCameraManager.getCameraCharacteristics(getActiveCamera()); - Float maxZoom = - characteristics.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM); - if (maxZoom == null) { - return ZOOM_NOT_SUPPORTED; - } - if (maxZoom == ZOOM_NOT_SUPPORTED) { - return ZOOM_NOT_SUPPORTED; - } - return maxZoom; - } catch (Exception e) { - Log.e(TAG, "Failed to get SCALER_AVAILABLE_MAX_DIGITAL_ZOOM.", e); + public float getMaxZoomRatio() { + if (mCamera != null) { + return mCamera.getCameraInfo().getZoomState().getValue().getMaxZoomRatio(); + } else { + return ZOOM_NOT_SUPPORTED; } - return ZOOM_NOT_SUPPORTED; } public boolean isZoomSupported() { - return getMaxZoomLevel() != ZOOM_NOT_SUPPORTED; + return getMaxZoomRatio() != ZOOM_NOT_SUPPORTED; } // TODO(b/124269166): Rethink how we can handle permissions here. @@ -559,80 +489,47 @@ final class CameraXModule { int getRelativeCameraOrientation(boolean compensateForMirroring) { int rotationDegrees = 0; - try { - CameraInfo cameraInfo = CameraX.getCameraInfo(getLensFacing()); - rotationDegrees = cameraInfo.getSensorRotationDegrees(getDisplaySurfaceRotation()); + if (mCamera != null) { + rotationDegrees = + mCamera.getCameraInfo().getSensorRotationDegrees(getDisplaySurfaceRotation()); if (compensateForMirroring) { rotationDegrees = (360 - rotationDegrees) % 360; } - } catch (CameraInfoUnavailableException e) { - Log.e(TAG, "Failed to get CameraInfo", e); - } catch (Exception e) { - Log.e(TAG, "Failed to query camera", e); } return rotationDegrees; } public void invalidateView() { - transformPreview(); updateViewInfo(); } void clearCurrentLifecycle() { - if (mCurrentLifecycle != null) { + if (mCurrentLifecycle != null && mCameraProvider != null) { // Remove previous use cases - // Begin Signal Custom Code Block - CameraX.unbind(mImageCapture, mPreview); - if (mVideoCapture != null) { - CameraX.unbind(mVideoCapture); + List toUnbind = new ArrayList<>(); + if (mImageCapture != null && mCameraProvider.isBound(mImageCapture)) { + toUnbind.add(mImageCapture); + } + if (mVideoCapture != null && mCameraProvider.isBound(mVideoCapture)) { + toUnbind.add(mVideoCapture); + } + if (mPreview != null && mCameraProvider.isBound(mPreview)) { + toUnbind.add(mPreview); } - // End Signal Custom Code Block - } + if (!toUnbind.isEmpty()) { + mCameraProvider.unbind(toUnbind.toArray((new UseCase[0]))); + } + } + mCamera = null; mCurrentLifecycle = null; } - private Rect getSensorSize(String cameraId) throws CameraAccessException { - CameraCharacteristics characteristics = mCameraManager.getCameraCharacteristics(cameraId); - return characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE); - } - - String getActiveCamera() throws CameraInfoUnavailableException { - return CameraX.getCameraWithLensFacing(mCameraLensFacing); - } - - @UiThread - private void transformPreview() { - int previewWidth = getPreviewWidth(); - int previewHeight = getPreviewHeight(); - int displayOrientation = getDisplayRotationDegrees(); - - Matrix matrix = new Matrix(); - - // Apply rotation of the display - int rotation = -displayOrientation; - - int px = (int) Math.round(previewWidth / 2d); - int py = (int) Math.round(previewHeight / 2d); - - matrix.postRotate(rotation, px, py); - - if (displayOrientation == 90 || displayOrientation == 270) { - // Swap width and height - float xScale = previewWidth / (float) previewHeight; - float yScale = previewHeight / (float) previewWidth; - - matrix.postScale(xScale, yScale, px, py); - } - - setTransform(matrix); - } - // Update view related information used in use cases private void updateViewInfo() { if (mImageCapture != null) { - mImageCapture.setTargetAspectRatioCustom(new Rational(getWidth(), getHeight())); + mImageCapture.setCropAspectRatio(new Rational(getWidth(), getHeight())); mImageCapture.setTargetRotation(getDisplaySurfaceRotation()); } @@ -642,29 +539,46 @@ final class CameraXModule { } @RequiresPermission(permission.CAMERA) - private Set getAvailableCameraLensFacing() { + private Set getAvailableCameraLensFacing() { // Start with all camera directions - Set available = new LinkedHashSet<>(Arrays.asList(CameraX.LensFacing.values())); + Set available = new LinkedHashSet<>(Arrays.asList(LensFacingConverter.values())); // If we're bound to a lifecycle, remove unavailable cameras if (mCurrentLifecycle != null) { - if (!hasCameraWithLensFacing(CameraX.LensFacing.BACK)) { - available.remove(CameraX.LensFacing.BACK); + if (!hasCameraWithLensFacing(CameraSelector.LENS_FACING_BACK)) { + available.remove(CameraSelector.LENS_FACING_BACK); } - if (!hasCameraWithLensFacing(CameraX.LensFacing.FRONT)) { - available.remove(CameraX.LensFacing.FRONT); + if (!hasCameraWithLensFacing(CameraSelector.LENS_FACING_FRONT)) { + available.remove(CameraSelector.LENS_FACING_FRONT); } } return available; } - public FlashMode getFlash() { + @ImageCapture.FlashMode + public int getFlash() { return mFlash; } - public void setFlash(FlashMode flash) { + // Begin Signal Custom Code Block + public boolean hasFlash() { + if (mImageCapture == null) { + return false; + } + + CameraInternal camera = mImageCapture.getBoundCamera(); + + if (camera == null) { + return false; + } + + return camera.getCameraInfoInternal().hasFlashUnit(); + } + // End Signal Custom Code Block + + public void setFlash(@ImageCapture.FlashMode int flash) { this.mFlash = flash; if (mImageCapture == null) { @@ -676,101 +590,69 @@ final class CameraXModule { } public void enableTorch(boolean torch) { - if (mPreview == null) { + if (mCamera == null) { return; } - mPreview.enableTorch(torch); + ListenableFuture future = mCamera.getCameraControl().enableTorch(torch); + Futures.addCallback(future, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void result) { + } + + @Override + public void onFailure(Throwable t) { + // Throw the unexpected error. + throw new RuntimeException(t); + } + }, CameraXExecutors.directExecutor()); } public boolean isTorchOn() { - if (mPreview == null) { + if (mCamera == null) { return false; } - return mPreview.isTorchOn(); + return mCamera.getCameraInfo().getTorchState().getValue() == TorchState.ON; } public Context getContext() { - return mCameraView.getContext(); + return mCameraXView.getContext(); } public int getWidth() { - return mCameraView.getWidth(); + return mCameraXView.getWidth(); } public int getHeight() { - return mCameraView.getHeight(); + return mCameraXView.getHeight(); } public int getDisplayRotationDegrees() { return CameraOrientationUtil.surfaceRotationToDegrees(getDisplaySurfaceRotation()); } - // Begin Signal Custom Code Block - public boolean hasFlash() { - try { - LiveData isFlashAvailable = CameraX.getCameraInfo(getLensFacing()).isFlashAvailable(); - return isFlashAvailable.getValue() == Boolean.TRUE; - } catch (CameraInfoUnavailableException e) { - return false; - } - } - // End Signal Custom Code Block - protected int getDisplaySurfaceRotation() { - return mCameraView.getDisplaySurfaceRotation(); - } - - public void setSurfaceTexture(SurfaceTexture st) { - mCameraView.setSurfaceTexture(st); - } - - private int getPreviewWidth() { - return mCameraView.getPreviewWidth(); - } - - private int getPreviewHeight() { - return mCameraView.getPreviewHeight(); + return mCameraXView.getDisplaySurfaceRotation(); } private int getMeasuredWidth() { - return mCameraView.getMeasuredWidth(); + return mCameraXView.getMeasuredWidth(); } private int getMeasuredHeight() { - return mCameraView.getMeasuredHeight(); + return mCameraXView.getMeasuredHeight(); } - void setTransform(final Matrix matrix) { - if (Looper.myLooper() != Looper.getMainLooper()) { - mCameraView.post( - new Runnable() { - @Override - public void run() { - setTransform(matrix); - } - }); - } else { - mCameraView.setTransform(matrix); - } - } - - /** - * Notify the view that the source dimensions have changed. - * - *

This will allow the view to layout the preview to display the correct aspect ratio. - * - * @param width width of camera source buffers. - * @param height height of camera source buffers. - */ - void onPreviewSourceDimensUpdated(int width, int height) { - mCameraView.onPreviewSourceDimensUpdated(width, height); + @Nullable + public Camera getCamera() { + return mCamera; } + @NonNull public CameraXView.CaptureMode getCaptureMode() { return mCaptureMode; } - public void setCaptureMode(CameraXView.CaptureMode captureMode) { + public void setCaptureMode(@NonNull CameraXView.CaptureMode captureMode) { this.mCaptureMode = captureMode; rebindToLifecycle(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXUtil.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXUtil.java index f1fae6bcb..f3a03076d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXUtil.java @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.mediasend.camerax; +import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.content.Context; import android.graphics.Bitmap; @@ -19,14 +20,13 @@ import android.util.Size; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; -import androidx.camera.camera2.impl.compat.CameraManagerCompat; -import androidx.camera.core.CameraX; +import androidx.camera.camera2.internal.compat.CameraManagerCompat; +import androidx.camera.core.CameraSelector; import androidx.camera.core.ImageCapture; import androidx.camera.core.ImageProxy; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mediasend.LegacyCameraModels; -import org.thoughtcrime.securesms.migrations.LegacyMigrationJob; import org.thoughtcrime.securesms.util.Stopwatch; import java.io.ByteArrayOutputStream; @@ -57,11 +57,12 @@ public class CameraXUtil { @SuppressWarnings("SuspiciousNameCombination") @RequiresApi(21) - public static ImageResult toJpeg(@NonNull ImageProxy image, int rotation, boolean flip) throws IOException { + public static ImageResult toJpeg(@NonNull ImageProxy image, boolean flip) throws IOException { ImageProxy.PlaneProxy[] planes = image.getPlanes(); ByteBuffer buffer = planes[0].getBuffer(); Rect cropRect = shouldCropImage(image) ? image.getCropRect() : null; byte[] data = new byte[buffer.capacity()]; + int rotation = image.getImageInfo().getRotationDegrees(); buffer.get(data); @@ -86,25 +87,25 @@ public class CameraXUtil { return Build.VERSION.SDK_INT >= 21 && !LegacyCameraModels.isLegacyCameraModel(); } - public static int toCameraDirectionInt(@Nullable CameraX.LensFacing facing) { - if (facing == CameraX.LensFacing.FRONT) { + public static int toCameraDirectionInt(int facing) { + if (facing == CameraSelector.LENS_FACING_FRONT) { return Camera.CameraInfo.CAMERA_FACING_FRONT; } else { return Camera.CameraInfo.CAMERA_FACING_BACK; } } - public static @NonNull CameraX.LensFacing toLensFacing(int cameraDirectionInt) { + public static int toLensFacing(@CameraSelector.LensFacing int cameraDirectionInt) { if (cameraDirectionInt == Camera.CameraInfo.CAMERA_FACING_FRONT) { - return CameraX.LensFacing.FRONT; + return CameraSelector.LENS_FACING_FRONT; } else { - return CameraX.LensFacing.BACK; + return CameraSelector.LENS_FACING_BACK; } } - public static @NonNull ImageCapture.CaptureMode getOptimalCaptureMode() { - return FastCameraModels.contains(Build.MODEL) ? ImageCapture.CaptureMode.MAX_QUALITY - : ImageCapture.CaptureMode.MIN_LATENCY; + public static @NonNull @ImageCapture.CaptureMode int getOptimalCaptureMode() { + return FastCameraModels.contains(Build.MODEL) ? ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY + : ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY; } public static int getIdealResolution(int displayWidth, int displayHeight) { @@ -186,7 +187,7 @@ public class CameraXUtil { @RequiresApi(21) public static int getLowestSupportedHardwareLevel(@NonNull Context context) { - CameraManager cameraManager = CameraManagerCompat.from(context).unwrap(); + @SuppressLint("RestrictedApi") CameraManager cameraManager = CameraManagerCompat.from(context).unwrap(); try { int supported = maxHardwareLevel(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXView.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXView.java index 446a3f0fc..f1c76cc94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXView.java @@ -20,10 +20,6 @@ import android.Manifest.permission; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.TypedArray; -import android.graphics.Matrix; -import android.graphics.Paint; -import android.graphics.Rect; -import android.graphics.SurfaceTexture; import android.hardware.display.DisplayManager; import android.hardware.display.DisplayManager.DisplayListener; import android.os.Bundle; @@ -33,17 +29,14 @@ import android.os.Parcelable; import android.text.TextUtils; import android.util.AttributeSet; import android.util.Log; -import android.util.Size; import android.view.Display; import android.view.MotionEvent; import android.view.ScaleGestureDetector; import android.view.Surface; -import android.view.TextureView; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; -import android.view.animation.BaseInterpolator; -import android.view.animation.DecelerateInterpolator; +import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -51,33 +44,42 @@ import androidx.annotation.RequiresApi; import androidx.annotation.RequiresPermission; import androidx.annotation.RestrictTo; import androidx.annotation.RestrictTo.Scope; -import androidx.annotation.UiThread; -import androidx.camera.core.CameraInfoUnavailableException; -import androidx.camera.core.CameraX; -import androidx.camera.core.FlashMode; +import androidx.camera.core.Camera; +import androidx.camera.core.CameraSelector; +import androidx.camera.core.DisplayOrientedMeteringPointFactory; import androidx.camera.core.FocusMeteringAction; +import androidx.camera.core.FocusMeteringResult; import androidx.camera.core.ImageCapture; +import androidx.camera.core.ImageCapture.OnImageCapturedCallback; +import androidx.camera.core.ImageProxy; import androidx.camera.core.MeteringPoint; +import androidx.camera.core.impl.LensFacingConverter; +import androidx.camera.core.impl.utils.executor.CameraXExecutors; +import androidx.camera.core.impl.utils.futures.FutureCallback; +import androidx.camera.core.impl.utils.futures.Futures; import androidx.lifecycle.LifecycleOwner; +import com.google.common.util.concurrent.ListenableFuture; + import org.thoughtcrime.securesms.R; -import java.io.File; import java.io.FileDescriptor; import java.util.concurrent.Executor; /** * A {@link View} that displays a preview of the camera with methods {@link - * #takePicture(Executor, OnImageCapturedListener)}, - * {@link #takePicture(File, Executor, OnImageSavedListener)}, - * {@link #startRecording(File, Executor, OnVideoSavedListener)} and {@link #stopRecording()}. + * #takePicture(Executor, OnImageCapturedCallback)}, + * {@link #startRecording(FileDescriptor, Executor, VideoCapture.OnVideoSavedCallback)} and {@link #stopRecording()}. * *

Because the Camera is a limited resource and consumes a high amount of power, CameraView must * be opened/closed. CameraView will handle opening/closing automatically through use of a {@link * LifecycleOwner}. Use {@link #bindToLifecycle(LifecycleOwner)} to start the camera. */ +// Begin Signal Custom Code Block @RequiresApi(21) -public final class CameraXView extends ViewGroup { +@SuppressLint("RestrictedApi") +// End Signal Custom Code Block +public final class CameraXView extends FrameLayout { static final String TAG = CameraXView.class.getSimpleName(); static final boolean DEBUG = false; @@ -85,7 +87,7 @@ public final class CameraXView extends ViewGroup { static final int INDEFINITE_VIDEO_SIZE = -1; private static final String EXTRA_SUPER = "super"; - private static final String EXTRA_ZOOM_LEVEL = "zoom_level"; + private static final String EXTRA_ZOOM_RATIO = "zoom_ratio"; private static final String EXTRA_PINCH_TO_ZOOM_ENABLED = "pinch_to_zoom_enabled"; private static final String EXTRA_FLASH = "flash"; private static final String EXTRA_MAX_VIDEO_DURATION = "max_video_duration"; @@ -121,51 +123,31 @@ public final class CameraXView extends ViewGroup { mCameraModule.invalidateView(); } }; - private TextureView mCameraTextureView; - private Size mPreviewSrcSize = new Size(0, 0); + private PreviewView mPreviewView; private ScaleType mScaleType = ScaleType.CENTER_CROP; // For accessibility event private MotionEvent mUpEvent; - private @Nullable Paint mLayerPaint; - public CameraXView(Context context) { + public CameraXView(@NonNull Context context) { this(context, null); } - public CameraXView(Context context, AttributeSet attrs) { + public CameraXView(@NonNull Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); } - public CameraXView(Context context, AttributeSet attrs, int defStyle) { + public CameraXView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context, attrs); } @RequiresApi(21) - public CameraXView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + public CameraXView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, + int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); init(context, attrs); } - /** Debug logging that can be enabled. */ - private static void log(String msg) { - if (DEBUG) { - Log.i(TAG, msg); - } - } - - /** Utility method for converting an displayRotation int into a human readable string. */ - private static String displayRotationToString(int displayRotation) { - if (displayRotation == Surface.ROTATION_0 || displayRotation == Surface.ROTATION_180) { - return "Portrait-" + (displayRotation * 90); - } else if (displayRotation == Surface.ROTATION_90 - || displayRotation == Surface.ROTATION_270) { - return "Landscape-" + (displayRotation * 90); - } else { - return "Unknown"; - } - } - /** * Binds control of the camera used by this view to the given lifecycle. * @@ -184,21 +166,16 @@ public final class CameraXView extends ViewGroup { * @throws IllegalStateException if camera permissions are not granted. */ @RequiresPermission(permission.CAMERA) - public void bindToLifecycle(LifecycleOwner lifecycleOwner) { + public void bindToLifecycle(@NonNull LifecycleOwner lifecycleOwner) { mCameraModule.bindToLifecycle(lifecycleOwner); } private void init(Context context, @Nullable AttributeSet attrs) { - addView(mCameraTextureView = new TextureView(getContext()), 0 /* view position */); - mCameraTextureView.setLayerPaint(mLayerPaint); + addView(mPreviewView = new PreviewView(getContext()), 0 /* view position */); mCameraModule = new CameraXModule(this); - if (isInEditMode()) { - onPreviewSourceDimensUpdated(640, 480); - } - if (attrs != null) { - TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CameraView); + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CameraXView); setScaleType( ScaleType.fromId( a.getInteger(R.styleable.CameraXView_scaleType, @@ -217,10 +194,10 @@ public final class CameraXView extends ViewGroup { setCameraLensFacing(null); break; case LENS_FACING_FRONT: - setCameraLensFacing(CameraX.LensFacing.FRONT); + setCameraLensFacing(CameraSelector.LENS_FACING_FRONT); break; case LENS_FACING_BACK: - setCameraLensFacing(CameraX.LensFacing.BACK); + setCameraLensFacing(CameraSelector.LENS_FACING_BACK); break; default: // Unhandled event. @@ -229,13 +206,13 @@ public final class CameraXView extends ViewGroup { int flashMode = a.getInt(R.styleable.CameraXView_flash, 0); switch (flashMode) { case FLASH_MODE_AUTO: - setFlash(FlashMode.AUTO); + setFlash(ImageCapture.FLASH_MODE_AUTO); break; case FLASH_MODE_ON: - setFlash(FlashMode.ON); + setFlash(ImageCapture.FLASH_MODE_ON); break; case FLASH_MODE_OFF: - setFlash(FlashMode.OFF); + setFlash(ImageCapture.FLASH_MODE_OFF); break; default: // Unhandled event. @@ -252,12 +229,14 @@ public final class CameraXView extends ViewGroup { } @Override + @NonNull protected LayoutParams generateDefaultLayoutParams() { return new LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); } @Override + @NonNull protected Parcelable onSaveInstanceState() { // TODO(b/113884082): Decide what belongs here or what should be invalidated on // configuration @@ -265,20 +244,21 @@ public final class CameraXView extends ViewGroup { Bundle state = new Bundle(); state.putParcelable(EXTRA_SUPER, super.onSaveInstanceState()); state.putInt(EXTRA_SCALE_TYPE, getScaleType().getId()); - state.putFloat(EXTRA_ZOOM_LEVEL, getZoomLevel()); + state.putFloat(EXTRA_ZOOM_RATIO, getZoomRatio()); state.putBoolean(EXTRA_PINCH_TO_ZOOM_ENABLED, isPinchToZoomEnabled()); - state.putString(EXTRA_FLASH, getFlash().name()); + state.putString(EXTRA_FLASH, FlashModeConverter.nameOf(getFlash())); state.putLong(EXTRA_MAX_VIDEO_DURATION, getMaxVideoDuration()); state.putLong(EXTRA_MAX_VIDEO_SIZE, getMaxVideoSize()); if (getCameraLensFacing() != null) { - state.putString(EXTRA_CAMERA_DIRECTION, getCameraLensFacing().name()); + state.putString(EXTRA_CAMERA_DIRECTION, + LensFacingConverter.nameOf(getCameraLensFacing())); } state.putInt(EXTRA_CAPTURE_MODE, getCaptureMode().getId()); return state; } @Override - protected void onRestoreInstanceState(Parcelable savedState) { + protected void onRestoreInstanceState(@Nullable Parcelable savedState) { // TODO(b/113884082): Decide what belongs here or what should be invalidated on // configuration // change @@ -286,39 +266,22 @@ public final class CameraXView extends ViewGroup { Bundle state = (Bundle) savedState; super.onRestoreInstanceState(state.getParcelable(EXTRA_SUPER)); setScaleType(ScaleType.fromId(state.getInt(EXTRA_SCALE_TYPE))); - setZoomLevel(state.getFloat(EXTRA_ZOOM_LEVEL)); + setZoomRatio(state.getFloat(EXTRA_ZOOM_RATIO)); setPinchToZoomEnabled(state.getBoolean(EXTRA_PINCH_TO_ZOOM_ENABLED)); - setFlash(FlashMode.valueOf(state.getString(EXTRA_FLASH))); + setFlash(FlashModeConverter.valueOf(state.getString(EXTRA_FLASH))); setMaxVideoDuration(state.getLong(EXTRA_MAX_VIDEO_DURATION)); setMaxVideoSize(state.getLong(EXTRA_MAX_VIDEO_SIZE)); String lensFacingString = state.getString(EXTRA_CAMERA_DIRECTION); setCameraLensFacing( TextUtils.isEmpty(lensFacingString) ? null - : CameraX.LensFacing.valueOf(lensFacingString)); + : LensFacingConverter.valueOf(lensFacingString)); setCaptureMode(CaptureMode.fromId(state.getInt(EXTRA_CAPTURE_MODE))); } else { super.onRestoreInstanceState(savedState); } } - /** - * Sets the paint on the preview. - * - *

This only affects the preview, and does not affect captured images/video. - * - * @param paint The paint object to apply to the preview. - * @hide This may not work once {@link android.view.SurfaceView} is supported along with {@link - * TextureView}. - */ - @Override - @RestrictTo(Scope.LIBRARY_GROUP) - public void setLayerPaint(@Nullable Paint paint) { - super.setLayerPaint(paint); - mLayerPaint = paint; - mCameraTextureView.setLayerPaint(paint); - } - @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); @@ -335,33 +298,21 @@ public final class CameraXView extends ViewGroup { dpyMgr.unregisterDisplayListener(mDisplayListener); } + PreviewView getPreviewView() { + return mPreviewView; + } + // TODO(b/124269166): Rethink how we can handle permissions here. @SuppressLint("MissingPermission") @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - int viewWidth = MeasureSpec.getSize(widthMeasureSpec); - int viewHeight = MeasureSpec.getSize(heightMeasureSpec); - - int displayRotation = getDisplay().getRotation(); - - if (mPreviewSrcSize.getHeight() == 0 || mPreviewSrcSize.getWidth() == 0) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - mCameraTextureView.measure(viewWidth, viewHeight); - } else { - Size scaled = - calculatePreviewViewDimens( - mPreviewSrcSize, viewWidth, viewHeight, displayRotation, mScaleType); - super.setMeasuredDimension( - Math.min(scaled.getWidth(), viewWidth), - Math.min(scaled.getHeight(), viewHeight)); - mCameraTextureView.measure(scaled.getWidth(), scaled.getHeight()); - } - // Since bindToLifecycle will depend on the measured dimension, only call it when measured // dimension is not 0x0 if (getMeasuredWidth() > 0 && getMeasuredHeight() > 0) { mCameraModule.bindToLifecycleAfterViewMeasured(); } + + super.onMeasure(widthMeasureSpec, heightMeasureSpec); } // TODO(b/124269166): Rethink how we can handle permissions here. @@ -372,114 +323,8 @@ public final class CameraXView extends ViewGroup { // binding to lifecycle mCameraModule.bindToLifecycleAfterViewMeasured(); - // If we don't know the src buffer size yet, set the preview to be the parent size - if (mPreviewSrcSize.getWidth() == 0 || mPreviewSrcSize.getHeight() == 0) { - mCameraTextureView.layout(left, top, right, bottom); - return; - } - - // Compute the preview ui size based on the available width, height, and ui orientation. - int viewWidth = (right - left); - int viewHeight = (bottom - top); - int displayRotation = getDisplay().getRotation(); - Size scaled = - calculatePreviewViewDimens( - mPreviewSrcSize, viewWidth, viewHeight, displayRotation, mScaleType); - - // Compute the center of the view. - int centerX = viewWidth / 2; - int centerY = viewHeight / 2; - - // Compute the left / top / right / bottom values such that preview is centered. - int layoutL = centerX - (scaled.getWidth() / 2); - int layoutT = centerY - (scaled.getHeight() / 2); - int layoutR = layoutL + scaled.getWidth(); - int layoutB = layoutT + scaled.getHeight(); - - // Layout debugging - log("layout: viewWidth: " + viewWidth); - log("layout: viewHeight: " + viewHeight); - log("layout: viewRatio: " + (viewWidth / (float) viewHeight)); - log("layout: sizeWidth: " + mPreviewSrcSize.getWidth()); - log("layout: sizeHeight: " + mPreviewSrcSize.getHeight()); - log( - "layout: sizeRatio: " - + (mPreviewSrcSize.getWidth() / (float) mPreviewSrcSize.getHeight())); - log("layout: scaledWidth: " + scaled.getWidth()); - log("layout: scaledHeight: " + scaled.getHeight()); - log("layout: scaledRatio: " + (scaled.getWidth() / (float) scaled.getHeight())); - log( - "layout: size: " - + scaled - + " (" - + (scaled.getWidth() / (float) scaled.getHeight()) - + " - " - + mScaleType - + "-" - + displayRotationToString(displayRotation) - + ")"); - log("layout: final " + layoutL + ", " + layoutT + ", " + layoutR + ", " + layoutB); - - mCameraTextureView.layout(layoutL, layoutT, layoutR, layoutB); - mCameraModule.invalidateView(); - } - - /** Records the size of the preview's buffers. */ - @UiThread - void onPreviewSourceDimensUpdated(int srcWidth, int srcHeight) { - if (srcWidth != mPreviewSrcSize.getWidth() - || srcHeight != mPreviewSrcSize.getHeight()) { - mPreviewSrcSize = new Size(srcWidth, srcHeight); - requestLayout(); - } - } - - private Size calculatePreviewViewDimens( - Size srcSize, - int parentWidth, - int parentHeight, - int displayRotation, - ScaleType scaleType) { - int inWidth = srcSize.getWidth(); - int inHeight = srcSize.getHeight(); - if (displayRotation == Surface.ROTATION_90 || displayRotation == Surface.ROTATION_270) { - // Need to reverse the width and height since we're in landscape orientation. - inWidth = srcSize.getHeight(); - inHeight = srcSize.getWidth(); - } - - int outWidth = parentWidth; - int outHeight = parentHeight; - if (inWidth != 0 && inHeight != 0) { - float vfRatio = inWidth / (float) inHeight; - float parentRatio = parentWidth / (float) parentHeight; - - switch (scaleType) { - case CENTER_INSIDE: - // Match longest sides together. - if (vfRatio > parentRatio) { - outWidth = parentWidth; - outHeight = Math.round(parentWidth / vfRatio); - } else { - outWidth = Math.round(parentHeight * vfRatio); - outHeight = parentHeight; - } - break; - case CENTER_CROP: - // Match shortest sides together. - if (vfRatio < parentRatio) { - outWidth = parentWidth; - outHeight = Math.round(parentWidth / vfRatio); - } else { - outWidth = Math.round(parentHeight * vfRatio); - outHeight = parentHeight; - } - break; - } - } - - return new Size(outWidth, outHeight); + super.onLayout(changed, left, top, right, bottom); } /** @@ -499,58 +344,12 @@ public final class CameraXView extends ViewGroup { return display.getRotation(); } - @UiThread - SurfaceTexture getSurfaceTexture() { - if (mCameraTextureView != null) { - return mCameraTextureView.getSurfaceTexture(); - } - - return null; - } - - @UiThread - void setSurfaceTexture(SurfaceTexture surfaceTexture) { - if (mCameraTextureView.getSurfaceTexture() != surfaceTexture) { - if (mCameraTextureView.isAvailable()) { - // Remove the old TextureView to properly detach the old SurfaceTexture from the GL - // Context. - removeView(mCameraTextureView); - addView(mCameraTextureView = new TextureView(getContext()), 0); - mCameraTextureView.setLayerPaint(mLayerPaint); - requestLayout(); - } - - mCameraTextureView.setSurfaceTexture(surfaceTexture); - } - } - - @UiThread - Matrix getTransform(Matrix matrix) { - return mCameraTextureView.getTransform(matrix); - } - - @UiThread - int getPreviewWidth() { - return mCameraTextureView.getWidth(); - } - - @UiThread - int getPreviewHeight() { - return mCameraTextureView.getHeight(); - } - - @UiThread - void setTransform(final Matrix matrix) { - if (mCameraTextureView != null) { - mCameraTextureView.setTransform(matrix); - } - } - /** * Returns the scale type used to scale the preview. * * @return The current {@link ScaleType}. */ + @NonNull public ScaleType getScaleType() { return mScaleType; } @@ -562,7 +361,7 @@ public final class CameraXView extends ViewGroup { * * @param scaleType The desired {@link ScaleType}. */ - public void setScaleType(ScaleType scaleType) { + public void setScaleType(@NonNull ScaleType scaleType) { if (scaleType != mScaleType) { mScaleType = scaleType; requestLayout(); @@ -574,6 +373,7 @@ public final class CameraXView extends ViewGroup { * * @return The current {@link CaptureMode}. */ + @NonNull public CaptureMode getCaptureMode() { return mCameraModule.getCaptureMode(); } @@ -585,7 +385,7 @@ public final class CameraXView extends ViewGroup { * * @param captureMode The desired {@link CaptureMode}. */ - public void setCaptureMode(CaptureMode captureMode) { + public void setCaptureMode(@NonNull CaptureMode captureMode) { mCameraModule.setCaptureMode(captureMode); } @@ -601,7 +401,7 @@ public final class CameraXView extends ViewGroup { } /** - * Sets the maximum video duration before {@link OnVideoSavedListener#onVideoSaved(File)} is + * Sets the maximum video duration before {@link VideoCapture.OnVideoSavedCallback#onVideoSaved(FileDescriptor)} is * called automatically. Use {@link #INDEFINITE_VIDEO_DURATION} to disable the timeout. */ private void setMaxVideoDuration(long duration) { @@ -617,7 +417,7 @@ public final class CameraXView extends ViewGroup { } /** - * Sets the maximum video size in bytes before {@link OnVideoSavedListener#onVideoSaved(File)} + * Sets the maximum video size in bytes before {@link VideoCapture.OnVideoSavedCallback#onVideoSaved(FileDescriptor)} * is called automatically. Use {@link #INDEFINITE_VIDEO_SIZE} to disable the size restriction. */ private void setMaxVideoSize(long size) { @@ -625,44 +425,32 @@ public final class CameraXView extends ViewGroup { } /** - * Takes a picture, and calls {@link OnImageCapturedListener#onCaptureSuccess(ImageProxy, int)} + * Takes a picture, and calls {@link OnImageCapturedCallback#onCaptureSuccess(ImageProxy)} * once when done. * - * @param executor The executor in which the listener callback methods will be run. - * @param listener Listener which will receive success or failure callbacks. + * @param executor The executor in which the callback methods will be run. + * @param callback Callback which will receive success or failure callbacks. */ - @SuppressLint("LambdaLast") // Maybe remove after https://issuetracker.google.com/135275901 - public void takePicture(@NonNull Executor executor, @NonNull ImageCapture.OnImageCapturedListener listener) { - mCameraModule.takePicture(executor, listener); + public void takePicture(@NonNull Executor executor, @NonNull OnImageCapturedCallback callback) { + mCameraModule.takePicture(executor, callback); } /** - * Takes a picture and calls {@link OnImageSavedListener#onImageSaved(File)} when done. + * Takes a video and calls the OnVideoSavedCallback when done. * * @param file The destination. - * @param executor The executor in which the listener callback methods will be run. - * @param listener Listener which will receive success or failure callbacks. - */ - @SuppressLint("LambdaLast") // Maybe remove after https://issuetracker.google.com/135275901 - public void takePicture(@NonNull File file, @NonNull Executor executor, - @NonNull ImageCapture.OnImageSavedListener listener) { - mCameraModule.takePicture(file, executor, listener); - } - - /** - * Takes a video and calls the OnVideoSavedListener when done. - * - * @param file The destination. - * @param executor The executor in which the listener callback methods will be run. - * @param listener Listener which will receive success or failure callbacks. + * @param executor The executor in which the callback methods will be run. + * @param callback Callback which will receive success or failure. */ // Begin Signal Custom Code Block @RequiresApi(26) - @SuppressLint("LambdaLast") // Maybe remove after https://issuetracker.google.com/135275901 - public void startRecording(@NonNull FileDescriptor file, @NonNull Executor executor, // End Signal Custom Code Block - @NonNull VideoCapture.OnVideoSavedListener listener) { - mCameraModule.startRecording(file, executor, listener); + public void startRecording(// Begin Signal Custom Code Block + @NonNull FileDescriptor file, + // End Signal Custom Code Block + @NonNull Executor executor, + @NonNull VideoCapture.OnVideoSavedCallback callback) { + mCameraModule.startRecording(file, executor, callback); } /** Stops an in progress video. */ @@ -685,7 +473,7 @@ public final class CameraXView extends ViewGroup { * @throws IllegalStateException if the CAMERA permission is not currently granted. */ @RequiresPermission(permission.CAMERA) - public boolean hasCameraWithLensFacing(CameraX.LensFacing lensFacing) { + public boolean hasCameraWithLensFacing(@CameraSelector.LensFacing int lensFacing) { return mCameraModule.hasCameraWithLensFacing(lensFacing); } @@ -706,7 +494,7 @@ public final class CameraXView extends ViewGroup { * *

If called before {@link #bindToLifecycle(LifecycleOwner)}, this will set the camera to be * used when first bound to the lifecycle. If the specified lensFacing is not supported by the - * device, as determined by {@link #hasCameraWithLensFacing(LensFacing)}, the first supported + * device, as determined by {@link #hasCameraWithLensFacing(int)}, the first supported * lensFacing will be chosen when {@link #bindToLifecycle(LifecycleOwner)} is called. * *

If called with {@code null} AFTER binding to the lifecycle, the behavior would be @@ -714,36 +502,33 @@ public final class CameraXView extends ViewGroup { * * @param lensFacing The desired camera lensFacing. */ - public void setCameraLensFacing(@Nullable CameraX.LensFacing lensFacing) { + public void setCameraLensFacing(@Nullable Integer lensFacing) { mCameraModule.setCameraLensFacing(lensFacing); } - /** Returns the currently selected {@link LensFacing}. */ + /** Returns the currently selected lensFacing. */ @Nullable - public CameraX.LensFacing getCameraLensFacing() { + public Integer getCameraLensFacing() { return mCameraModule.getLensFacing(); } + /** Gets the active flash strategy. */ + @ImageCapture.FlashMode + public int getFlash() { + return mCameraModule.getFlash(); + } + // Begin Signal Custom Code Block public boolean hasFlash() { return mCameraModule.hasFlash(); } // End Signal Custom Code Block - /** Gets the active flash strategy. */ - public FlashMode getFlash() { - return mCameraModule.getFlash(); - } - /** Sets the active flash strategy. */ - public void setFlash(@NonNull FlashMode flashMode) { + public void setFlash(@ImageCapture.FlashMode int flashMode) { mCameraModule.setFlash(flashMode); } - private int getRelativeCameraOrientation(boolean compensateForMirroring) { - return mCameraModule.getRelativeCameraOrientation(compensateForMirroring); - } - private long delta() { return System.currentTimeMillis() - mDownEventTimestamp; } @@ -793,42 +578,47 @@ public final class CameraXView extends ViewGroup { final float y = (mUpEvent != null) ? mUpEvent.getY() : getY() + getHeight() / 2f; mUpEvent = null; - TextureViewMeteringPointFactory pointFactory = new TextureViewMeteringPointFactory( - mCameraTextureView); + CameraSelector cameraSelector = + new CameraSelector.Builder().requireLensFacing( + mCameraModule.getLensFacing()).build(); + + DisplayOrientedMeteringPointFactory pointFactory = new DisplayOrientedMeteringPointFactory( + getDisplay(), cameraSelector, mPreviewView.getWidth(), mPreviewView.getHeight()); float afPointWidth = 1.0f / 6.0f; // 1/6 total area float aePointWidth = afPointWidth * 1.5f; - MeteringPoint afPoint = pointFactory.createPoint(x, y, afPointWidth, 1.0f); - MeteringPoint aePoint = pointFactory.createPoint(x, y, aePointWidth, 1.0f); + MeteringPoint afPoint = pointFactory.createPoint(x, y, afPointWidth); + MeteringPoint aePoint = pointFactory.createPoint(x, y, aePointWidth); - try { - CameraX.getCameraControl(getCameraLensFacing()).startFocusAndMetering( - FocusMeteringAction.Builder.from(afPoint, FocusMeteringAction.MeteringMode.AF_ONLY) - .addPoint(aePoint, FocusMeteringAction.MeteringMode.AE_ONLY) - .build()); - } catch (CameraInfoUnavailableException e) { - Log.d(TAG, "cannot access camera", e); + Camera camera = mCameraModule.getCamera(); + if (camera != null) { + ListenableFuture future = + camera.getCameraControl().startFocusAndMetering( + new FocusMeteringAction.Builder(afPoint, + FocusMeteringAction.FLAG_AF).addPoint(aePoint, + FocusMeteringAction.FLAG_AE).build()); + Futures.addCallback(future, new FutureCallback() { + @Override + public void onSuccess(@Nullable FocusMeteringResult result) { + } + + @Override + public void onFailure(Throwable t) { + // Throw the unexpected error. + throw new RuntimeException(t); + } + }, CameraXExecutors.directExecutor()); + + } else { + Log.d(TAG, "cannot access camera"); } return true; } - /** Returns the width * height of the given rect */ - private int area(Rect rect) { - return rect.width() * rect.height(); - } - - private int rangeLimit(int val, int max, int min) { - return Math.min(Math.max(val, min), max); - } - float rangeLimit(float val, float max, float min) { return Math.min(Math.max(val, min), max); } - private int distance(int a, int b) { - return Math.abs(a - b); - } - /** * Returns whether the view allows pinch-to-zoom. * @@ -851,47 +641,47 @@ public final class CameraXView extends ViewGroup { } /** - * Returns the current zoom level. + * Returns the current zoom ratio. * - * @return The current zoom level. + * @return The current zoom ratio. */ - public float getZoomLevel() { - return mCameraModule.getZoomLevel(); + public float getZoomRatio() { + return mCameraModule.getZoomRatio(); } /** - * Sets the current zoom level. + * Sets the current zoom ratio. * - *

Valid zoom values range from 1 to {@link #getMaxZoomLevel()}. + *

Valid zoom values range from {@link #getMinZoomRatio()} to {@link #getMaxZoomRatio()}. * - * @param zoomLevel The requested zoom level. + * @param zoomRatio The requested zoom ratio. */ - public void setZoomLevel(float zoomLevel) { - mCameraModule.setZoomLevel(zoomLevel); + public void setZoomRatio(float zoomRatio) { + mCameraModule.setZoomRatio(zoomRatio); } /** - * Returns the minimum zoom level. + * Returns the minimum zoom ratio. * - *

For most cameras this should return a zoom level of 1. A zoom level of 1 corresponds to a + *

For most cameras this should return a zoom ratio of 1. A zoom ratio of 1 corresponds to a * non-zoomed image. * - * @return The minimum zoom level. + * @return The minimum zoom ratio. */ - public float getMinZoomLevel() { - return mCameraModule.getMinZoomLevel(); + public float getMinZoomRatio() { + return mCameraModule.getMinZoomRatio(); } /** - * Returns the maximum zoom level. + * Returns the maximum zoom ratio. * - *

The zoom level corresponds to the ratio between both the widths and heights of a + *

The zoom ratio corresponds to the ratio between both the widths and heights of a * non-zoomed image and a maximally zoomed image for the selected camera. * - * @return The maximum zoom level. + * @return The maximum zoom ratio. */ - public float getMaxZoomLevel() { - return mCameraModule.getMaxZoomLevel(); + public float getMaxZoomRatio() { + return mCameraModule.getMaxZoomRatio(); } /** @@ -935,7 +725,7 @@ public final class CameraXView extends ViewGroup { */ CENTER_INSIDE(1); - private int mId; + private final int mId; int getId() { return mId; @@ -959,7 +749,7 @@ public final class CameraXView extends ViewGroup { * The capture mode used by CameraView. * *

This enum can be used to determine which capture mode will be enabled for {@link - * CameraView}. + * CameraXView}. */ public enum CaptureMode { /** A mode where image capture is enabled. */ @@ -972,7 +762,7 @@ public final class CameraXView extends ViewGroup { */ MIXED(2); - private int mId; + private final int mId; int getId() { return mId; @@ -1007,10 +797,6 @@ public final class CameraXView extends ViewGroup { private class PinchToZoomGestureDetector extends ScaleGestureDetector implements ScaleGestureDetector.OnScaleGestureListener { - private static final float SCALE_MULTIPIER = 0.75f; - private final BaseInterpolator mInterpolator = new DecelerateInterpolator(2f); - private float mNormalizedScaleFactor = 0; - PinchToZoomGestureDetector(Context context) { this(context, new S()); } @@ -1022,34 +808,23 @@ public final class CameraXView extends ViewGroup { @Override public boolean onScale(ScaleGestureDetector detector) { - mNormalizedScaleFactor += (detector.getScaleFactor() - 1f) * SCALE_MULTIPIER; - // Since the scale factor is normalized, it should always be in the range [0, 1] - mNormalizedScaleFactor = rangeLimit(mNormalizedScaleFactor, 1f, 0); + float scale = detector.getScaleFactor(); - // Apply decelerate interpolation. This will cause the differences to seem less - // pronounced - // at higher zoom levels. - float transformedScale = mInterpolator.getInterpolation(mNormalizedScaleFactor); + // Speeding up the zoom by 2X. + if (scale > 1f) { + scale = 1.0f + (scale - 1.0f) * 2; + } else { + scale = 1.0f - (1.0f - scale) * 2; + } - // Transform back from normalized coordinates to the zoom scale - float zoomLevel = - (getMaxZoomLevel() == getMinZoomLevel()) - ? getMinZoomLevel() - : getMinZoomLevel() - + transformedScale * (getMaxZoomLevel() - getMinZoomLevel()); - - setZoomLevel(rangeLimit(zoomLevel, getMaxZoomLevel(), getMinZoomLevel())); + float newRatio = getZoomRatio() * scale; + newRatio = rangeLimit(newRatio, getMaxZoomRatio(), getMinZoomRatio()); + setZoomRatio(newRatio); return true; } @Override public boolean onScaleBegin(ScaleGestureDetector detector) { - float initialZoomLevel = getZoomLevel(); - mNormalizedScaleFactor = - (getMaxZoomLevel() == getMinZoomLevel()) - ? 0 - : (initialZoomLevel - getMinZoomLevel()) - / (getMaxZoomLevel() - getMinZoomLevel()); return true; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/FlashModeConverter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/FlashModeConverter.java new file mode 100644 index 000000000..39d9f7929 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/FlashModeConverter.java @@ -0,0 +1,78 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.mediasend.camerax; + +import static androidx.camera.core.ImageCapture.FLASH_MODE_AUTO; +import static androidx.camera.core.ImageCapture.FLASH_MODE_OFF; +import static androidx.camera.core.ImageCapture.FLASH_MODE_ON; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.camera.core.ImageCapture.FlashMode; + +/** + * Helper class that defines certain enum-like methods for {@link FlashMode} + */ +final class FlashModeConverter { + + private FlashModeConverter() { + } + + /** + * Returns the {@link FlashMode} constant for the specified name + * + * @param name The name of the {@link FlashMode} to return + * @return The {@link FlashMode} constant for the specified name + */ + @FlashMode + public static int valueOf(@Nullable final String name) { + if (name == null) { + throw new NullPointerException("name cannot be null"); + } + + switch (name) { + case "AUTO": + return FLASH_MODE_AUTO; + case "ON": + return FLASH_MODE_ON; + case "OFF": + return FLASH_MODE_OFF; + default: + throw new IllegalArgumentException("Unknown flash mode name " + name); + } + } + + /** + * Returns the name of the {@link FlashMode} constant, exactly as it is declared. + * + * @param flashMode A {@link FlashMode} constant + * @return The name of the {@link FlashMode} constant. + */ + @NonNull + public static String nameOf(@FlashMode final int flashMode) { + switch (flashMode) { + case FLASH_MODE_AUTO: + return "AUTO"; + case FLASH_MODE_ON: + return "ON"; + case FLASH_MODE_OFF: + return "OFF"; + default: + throw new IllegalArgumentException("Unknown flash mode " + flashMode); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/PreviewView.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/PreviewView.java new file mode 100644 index 000000000..520c75e0d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/PreviewView.java @@ -0,0 +1,273 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.mediasend.camerax; + +import android.content.Context; +import android.content.res.TypedArray; +import android.hardware.display.DisplayManager; +import android.os.Build; +import android.util.AttributeSet; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.camera.core.Preview; + +import org.thoughtcrime.securesms.R; + +import java.util.concurrent.Executor; + +/** + * Custom View that displays camera feed for CameraX's Preview use case. + * + *

This class manages the Surface lifecycle, as well as the preview aspect ratio and + * orientation. Internally, it uses either a {@link android.view.TextureView} or + * {@link android.view.SurfaceView} to display the camera feed. + */ +// Begin Signal Custom Code Block +@RequiresApi(21) +// End Signal Custom Code Block +public class PreviewView extends FrameLayout { + + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ + Implementation mImplementation; + + private ImplementationMode mImplementationMode; + + private final DisplayManager.DisplayListener mDisplayListener = + new DisplayManager.DisplayListener() { + @Override + public void onDisplayAdded(int displayId) { + } + + @Override + public void onDisplayRemoved(int displayId) { + } + @Override + public void onDisplayChanged(int displayId) { + mImplementation.onDisplayChanged(); + } + }; + + public PreviewView(@NonNull Context context) { + this(context, null); + } + + public PreviewView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public PreviewView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public PreviewView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, + int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + final TypedArray attributes = context.getTheme().obtainStyledAttributes(attrs, + R.styleable.PreviewView, defStyleAttr, defStyleRes); + + try { + final int implementationModeId = attributes.getInteger( + R.styleable.PreviewView_implementationMode, + ImplementationMode.TEXTURE_VIEW.getId()); + mImplementationMode = ImplementationMode.fromId(implementationModeId); + } finally { + attributes.recycle(); + } + setUp(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + final DisplayManager displayManager = + (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE); + if (displayManager != null) { + displayManager.registerDisplayListener(mDisplayListener, getHandler()); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + final DisplayManager displayManager = + (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE); + if (displayManager != null) { + displayManager.unregisterDisplayListener(mDisplayListener); + } + } + + private void setUp() { + removeAllViews(); + switch (mImplementationMode) { + case SURFACE_VIEW: + mImplementation = new SurfaceViewImplementation(); + break; + case TEXTURE_VIEW: + mImplementation = new TextureViewImplementation(); + break; + default: + throw new IllegalStateException( + "Unsupported implementation mode " + mImplementationMode); + } + mImplementation.init(this); + } + + /** + * Specifies the {@link ImplementationMode} to use for the preview. + * + * @param implementationMode SURFACE_VIEW if a {@link android.view.SurfaceView} + * should be used to display the camera feed, or + * TEXTURE_VIEW to use a {@link android.view.TextureView} + */ + public void setImplementationMode(@NonNull final ImplementationMode implementationMode) { + mImplementationMode = implementationMode; + setUp(); + } + + /** + * Returns the implementation mode of the {@link PreviewView}. + * + * @return SURFACE_VIEW if the {@link PreviewView} is internally using a + * {@link android.view.SurfaceView} to display the camera feed, or TEXTURE_VIEW + * if a {@link android.view.TextureView} is being used. + */ + @NonNull + public ImplementationMode getImplementationMode() { + return mImplementationMode; + } + + /** + * Gets the {@link Preview.SurfaceProvider} to be used with + * {@link Preview#setSurfaceProvider(Executor, Preview.SurfaceProvider)}. + */ + @NonNull + public Preview.SurfaceProvider getPreviewSurfaceProvider() { + return mImplementation.getSurfaceProvider(); + } + + /** + * Implements this interface to create PreviewView implementation. + */ + interface Implementation { + + /** + * Initializes the parent view with sub views. + * + * @param parent the containing parent {@link FrameLayout}. + */ + void init(@NonNull FrameLayout parent); + + /** + * Gets the {@link Preview.SurfaceProvider} to be used with {@link Preview}. + */ + @NonNull + Preview.SurfaceProvider getSurfaceProvider(); + + /** + * Notifies that the display properties have changed. + * + *

Implementation might need to adjust transform by latest display properties such as + * display orientation in order to show the preview correctly. + */ + void onDisplayChanged(); + } + + /** + * The implementation mode of a {@link PreviewView} + * + *

Specifies how the Preview surface will be implemented internally: Using a + * {@link android.view.SurfaceView} or a {@link android.view.TextureView} (which is the default) + *

+ */ + public enum ImplementationMode { + /** Use a {@link android.view.SurfaceView} for the preview */ + SURFACE_VIEW(0), + + /** Use a {@link android.view.TextureView} for the preview */ + TEXTURE_VIEW(1); + + private final int mId; + + ImplementationMode(final int id) { + mId = id; + } + + public int getId() { + return mId; + } + + static ImplementationMode fromId(final int id) { + for (final ImplementationMode mode : values()) { + if (mode.mId == id) { + return mode; + } + } + throw new IllegalArgumentException("Unsupported implementation mode " + id); + } + } + + /** Options for scaling the preview vis-à-vis its container {@link PreviewView}. */ + public enum ScaleType { + /** + * Scale the preview, maintaining the source aspect ratio, so it fills the entire + * {@link PreviewView}, and align it to the top left corner of the view. + * This may cause the preview to be cropped if the camera preview aspect ratio does not + * match that of its container {@link PreviewView}. + */ + FILL_START, + /** + * Scale the preview, maintaining the source aspect ratio, so it fills the entire + * {@link PreviewView}, and center it inside the view. + * This may cause the preview to be cropped if the camera preview aspect ratio does not + * match that of its container {@link PreviewView}. + */ + FILL_CENTER, + /** + * Scale the preview, maintaining the source aspect ratio, so it fills the entire + * {@link PreviewView}, and align it to the bottom right corner of the view. + * This may cause the preview to be cropped if the camera preview aspect ratio does not + * match that of its container {@link PreviewView}. + */ + FILL_END, + /** + * Scale the preview, maintaining the source aspect ratio, so it is entirely contained + * within the {@link PreviewView}, and align it to the top left corner of the view. + * Both dimensions of the preview will be equal or less than the corresponding dimensions + * of its container {@link PreviewView}. + */ + FIT_START, + /** + * Scale the preview, maintaining the source aspect ratio, so it is entirely contained + * within the {@link PreviewView}, and center it inside the view. + * Both dimensions of the preview will be equal or less than the corresponding dimensions + * of its container {@link PreviewView}. + */ + FIT_CENTER, + /** + * Scale the preview, maintaining the source aspect ratio, so it is entirely contained + * within the {@link PreviewView}, and align it to the bottom right corner of the view. + * Both dimensions of the preview will be equal or less than the corresponding dimensions + * of its container {@link PreviewView}. + */ + FIT_END + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/ScaleTypeTransform.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/ScaleTypeTransform.java new file mode 100644 index 000000000..28af25040 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/ScaleTypeTransform.java @@ -0,0 +1,162 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.mediasend.camerax; + +import android.content.Context; +import android.graphics.Point; +import android.util.Pair; +import android.util.Size; +import android.view.Display; +import android.view.View; +import android.view.WindowManager; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +// Begin Signal Custom Code Block +@RequiresApi(21) +// End Signal Custom Code Block +final class ScaleTypeTransform { + + /** + * Computes the scale by which a view has to scale in x and y in order to fill its parent + * while maintaining the buffer's aspect ratio. + * + * @param container A parent {@link android.view.View} that wraps {@code view}. + * @param view A child {@link android.view.View} of {@code container}. + * @param bufferSize A {@link android.util.Size} whose aspect ratio must be maintained when + * scaling {@code view} inside its parent {@code container}. + * @return The scale by which {@code view} has to scale in x and y in order to fill its + * parent while maintaining {@code bufferSize}'s aspect ratio. + */ + @SuppressWarnings("SuspiciousNameCombination") + static Pair getFillScaleWithBufferAspectRatio(@NonNull final View container, + @NonNull final View view, @NonNull final Size bufferSize) { + // Scaling only makes sense when none of the dimensions are equal to zero. In the + // opposite case, a default scale of 1 is returned, + if (container.getWidth() == 0 || container.getHeight() == 0 || view.getWidth() == 0 + || view.getHeight() == 0 || bufferSize.getWidth() == 0 + || bufferSize.getHeight() == 0) { + return new Pair<>(1F, 1F); + } + + final int viewRotationDegrees = getRotationDegrees(view); + final boolean isNaturalPortrait = isNaturalPortrait(view.getContext(), viewRotationDegrees); + + final int bufferWidth; + final int bufferHeight; + if (isNaturalPortrait) { + bufferWidth = bufferSize.getHeight(); + bufferHeight = bufferSize.getWidth(); + } else { + bufferWidth = bufferSize.getWidth(); + bufferHeight = bufferSize.getHeight(); + } + + // Scale the buffers back to the original output size. + float scaleX = bufferWidth / (float) view.getWidth(); + float scaleY = bufferHeight / (float) view.getHeight(); + + int bufferRotatedWidth; + int bufferRotatedHeight; + if (viewRotationDegrees == 0 || viewRotationDegrees == 180) { + bufferRotatedWidth = bufferWidth; + bufferRotatedHeight = bufferHeight; + } else { + bufferRotatedWidth = bufferHeight; + bufferRotatedHeight = bufferWidth; + } + + // Scale the buffer so that it completely fills the container. + final float scale = Math.max(container.getWidth() / (float) bufferRotatedWidth, + container.getHeight() / (float) bufferRotatedHeight); + scaleX *= scale; + scaleY *= scale; + + return new Pair<>(scaleX, scaleY); + } + + /** + * Computes the top left coordinates for the view to be centered inside its parent. + * + * @param container A parent {@link android.view.View} that wraps {@code view}. + * @param view A child {@link android.view.View} of {@code container}. + * @return A {@link android.graphics.Point} whose coordinates represent the top left of + * {@code view} when centered inside its parent. + */ + static Point getOriginOfCenteredView(@NonNull final View container, + @NonNull final View view) { + final int offsetX = (view.getWidth() - container.getWidth()) / 2; + final int offsetY = (view.getHeight() - container.getHeight()) / 2; + return new Point(-offsetX, -offsetY); + } + + /** + * Computes the rotation of a {@link android.view.View} in degrees from its natural + * orientation. + */ + static int getRotationDegrees(@NonNull final View view) { + final WindowManager windowManager = (WindowManager) view.getContext().getSystemService( + Context.WINDOW_SERVICE); + if (windowManager == null) { + return 0; + } + final int rotation = windowManager.getDefaultDisplay().getRotation(); + return SurfaceRotation.rotationDegreesFromSurfaceRotation(rotation); + } + + /** + * Determines whether the current device is a natural portrait-oriented device + * + *

+ * Using the current app's window to determine whether the device is a natural + * portrait-oriented device doesn't work in all scenarios, one example of this is multi-window + * mode. + * Taking a natural portrait-oriented device in multi-window mode, rotating it 90 degrees (so + * that it's in landscape), with the app open, and its window's width being smaller than its + * height. Using the app's width and height would determine that the device isn't + * naturally portrait-oriented, where in fact it is, which is why it is important to use the + * size of the device instead. + *

+ * + * @param context Current context. Can be an {@link android.app.Application} context + * or an {@link android.app.Activity} context. + * @param rotationDegrees The device's rotation in degrees from its natural orientation. + * @return Whether the device is naturally portrait-oriented. + */ + private static boolean isNaturalPortrait(@NonNull final Context context, + final int rotationDegrees) { + final WindowManager windowManager = (WindowManager) context.getSystemService( + Context.WINDOW_SERVICE); + if (windowManager == null) { + return true; + } + + final Display display = windowManager.getDefaultDisplay(); + final Point deviceSize = new Point(); + display.getRealSize(deviceSize); + + final int width = deviceSize.x; + final int height = deviceSize.y; + return ((rotationDegrees == 0 || rotationDegrees == 180) && width < height) || ( + (rotationDegrees == 90 || rotationDegrees == 270) && width >= height); + } + + // Prevent creating an instance + private ScaleTypeTransform() { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SurfaceRotation.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SurfaceRotation.java new file mode 100644 index 000000000..b4e08f22c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SurfaceRotation.java @@ -0,0 +1,46 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.mediasend.camerax; + +import android.view.Surface; + +final class SurfaceRotation { + /** + * Get the int value degree of a rotation from the {@link Surface} constants. + * + *

Valid values for the relative rotation are {@link Surface#ROTATION_0}, {@link + * * Surface#ROTATION_90}, {@link Surface#ROTATION_180}, {@link Surface#ROTATION_270}. + */ + static int rotationDegreesFromSurfaceRotation(int rotationConstant) { + switch (rotationConstant) { + case Surface.ROTATION_0: + return 0; + case Surface.ROTATION_90: + return 90; + case Surface.ROTATION_180: + return 180; + case Surface.ROTATION_270: + return 270; + default: + throw new UnsupportedOperationException( + "Unsupported surface rotation constant: " + rotationConstant); + } + } + + /** Prevents construction */ + private SurfaceRotation() {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SurfaceViewImplementation.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SurfaceViewImplementation.java new file mode 100644 index 000000000..7c6963a5c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SurfaceViewImplementation.java @@ -0,0 +1,180 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.mediasend.camerax; + +import android.util.Log; +import android.util.Size; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.UiThread; +import androidx.camera.core.Preview; +import androidx.camera.core.SurfaceRequest; +import androidx.core.content.ContextCompat; + +/** + * The SurfaceView implementation for {@link PreviewView}. + */ +@RequiresApi(21) +final class SurfaceViewImplementation implements PreviewView.Implementation { + + private static final String TAG = "SurfaceViewPreviewView"; + + // Synthetic Accessor + @SuppressWarnings("WeakerAccess") + TransformableSurfaceView mSurfaceView; + + // Synthetic Accessor + @SuppressWarnings("WeakerAccess") + final SurfaceRequestCallback mSurfaceRequestCallback = + new SurfaceRequestCallback(); + + private Preview.SurfaceProvider mSurfaceProvider = + new Preview.SurfaceProvider() { + @Override + public void onSurfaceRequested(@NonNull SurfaceRequest surfaceRequest) { + mSurfaceView.post( + () -> mSurfaceRequestCallback.setSurfaceRequest(surfaceRequest)); + } + }; + + /** + * {@inheritDoc} + */ + @Override + public void init(@NonNull FrameLayout parent) { + mSurfaceView = new TransformableSurfaceView(parent.getContext()); + mSurfaceView.setLayoutParams( + new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT)); + parent.addView(mSurfaceView); + mSurfaceView.getHolder().addCallback(mSurfaceRequestCallback); + } + + /** + * {@inheritDoc} + */ + @NonNull + @Override + public Preview.SurfaceProvider getSurfaceProvider() { + return mSurfaceProvider; + } + + @Override + public void onDisplayChanged() { + + } + + /** + * The {@link SurfaceHolder.Callback} on mSurfaceView. + * + *

SurfaceView creates Surface on its own before we can do anything. This class makes + * sure only the Surface with correct size will be returned to Preview. + */ + class SurfaceRequestCallback implements SurfaceHolder.Callback { + + // Target Surface size. Only complete the SurfaceRequest when the size of the Surface + // matches this value. + // Guarded by UI thread. + @Nullable + private Size mTargetSize; + + // SurfaceRequest to set when the target size is met. + // Guarded by UI thread. + @Nullable + private SurfaceRequest mSurfaceRequest; + + // The cached size of the current Surface. + // Guarded by UI thread. + @Nullable + private Size mCurrentSurfaceSize; + + /** + * Sets the completer and the size. The completer will only be set if the current size of + * the Surface matches the target size. + */ + @UiThread + void setSurfaceRequest(@NonNull SurfaceRequest surfaceRequest) { + cancelPreviousRequest(); + mSurfaceRequest = surfaceRequest; + Size targetSize = surfaceRequest.getResolution(); + mTargetSize = targetSize; + if (!tryToComplete()) { + // The current size is incorrect. Wait for it to change. + Log.d(TAG, "Wait for new Surface creation."); + mSurfaceView.getHolder().setFixedSize(targetSize.getWidth(), + targetSize.getHeight()); + } + } + + /** + * Sets the completer if size matches. + * + * @return true if the completer is set. + */ + @UiThread + private boolean tryToComplete() { + Surface surface = mSurfaceView.getHolder().getSurface(); + if (mSurfaceRequest != null && mTargetSize != null && mTargetSize.equals( + mCurrentSurfaceSize)) { + Log.d(TAG, "Surface set on Preview."); + mSurfaceRequest.provideSurface(surface, + ContextCompat.getMainExecutor(mSurfaceView.getContext()), + (result) -> Log.d(TAG, "Safe to release surface.")); + mSurfaceRequest = null; + mTargetSize = null; + return true; + } + return false; + } + + @UiThread + private void cancelPreviousRequest() { + if (mSurfaceRequest != null) { + Log.d(TAG, "Request canceled: " + mSurfaceRequest); + mSurfaceRequest.willNotProvideSurface(); + mSurfaceRequest = null; + } + mTargetSize = null; + } + + @Override + public void surfaceCreated(SurfaceHolder surfaceHolder) { + Log.d(TAG, "Surface created."); + // No-op. Handling surfaceChanged() is enough because it's always called afterwards. + } + + @Override + public void surfaceChanged(SurfaceHolder surfaceHolder, int format, int width, int height) { + Log.d(TAG, "Surface changed. Size: " + width + "x" + height); + mCurrentSurfaceSize = new Size(width, height); + tryToComplete(); + } + + @Override + public void surfaceDestroyed(SurfaceHolder surfaceHolder) { + Log.d(TAG, "Surface destroyed."); + mCurrentSurfaceSize = null; + cancelPreviousRequest(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/TextureViewImplementation.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/TextureViewImplementation.java new file mode 100644 index 000000000..7e9213d5f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/TextureViewImplementation.java @@ -0,0 +1,238 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.mediasend.camerax; + +import static androidx.camera.core.SurfaceRequest.Result; + +import android.annotation.SuppressLint; +import android.graphics.Point; +import android.graphics.SurfaceTexture; +import android.util.Log; +import android.util.Pair; +import android.util.Size; +import android.view.Surface; +import android.view.TextureView; +import android.view.View; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.camera.core.Preview; +import androidx.camera.core.SurfaceRequest; +import androidx.camera.core.impl.utils.executor.CameraXExecutors; +import androidx.camera.core.impl.utils.futures.FutureCallback; +import androidx.camera.core.impl.utils.futures.Futures; +import androidx.concurrent.futures.CallbackToFutureAdapter; +import androidx.core.content.ContextCompat; +import androidx.core.util.Preconditions; + +import com.google.common.util.concurrent.ListenableFuture; + +/** + * The {@link TextureView} implementation for {@link PreviewView} + */ +// Begin Signal Custom Code Block +@RequiresApi(21) +@SuppressLint("RestrictedApi") +// End Signal Custom Code Block +public class TextureViewImplementation implements PreviewView.Implementation { + + private static final String TAG = "TextureViewImpl"; + + private FrameLayout mParent; + TextureView mTextureView; + SurfaceTexture mSurfaceTexture; + private Size mResolution; + ListenableFuture mSurfaceReleaseFuture; + SurfaceRequest mSurfaceRequest; + + @Override + public void init(@NonNull FrameLayout parent) { + mParent = parent; + } + + @NonNull + @Override + public Preview.SurfaceProvider getSurfaceProvider() { + return (surfaceRequest) -> { + mResolution = surfaceRequest.getResolution(); + initInternal(); + if (mSurfaceRequest != null) { + mSurfaceRequest.willNotProvideSurface(); + } + + mSurfaceRequest = surfaceRequest; + surfaceRequest.addRequestCancellationListener( + ContextCompat.getMainExecutor(mTextureView.getContext()), () -> { + if (mSurfaceRequest != null && mSurfaceRequest == surfaceRequest) { + mSurfaceRequest = null; + mSurfaceReleaseFuture = null; + } + }); + + tryToProvidePreviewSurface(); + }; + } + + @Override + public void onDisplayChanged() { + if (mParent == null || mTextureView == null || mResolution == null) { + return; + } + + correctPreviewForCenterCrop(mParent, mTextureView, mResolution); + } + + private void initInternal() { + mTextureView = new TextureView(mParent.getContext()); + mTextureView.setLayoutParams( + new FrameLayout.LayoutParams(mResolution.getWidth(), mResolution.getHeight())); + mTextureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() { + @Override + public void onSurfaceTextureAvailable(final SurfaceTexture surfaceTexture, + final int width, final int height) { + mSurfaceTexture = surfaceTexture; + tryToProvidePreviewSurface(); + } + + @Override + public void onSurfaceTextureSizeChanged(final SurfaceTexture surfaceTexture, + final int width, final int height) { + Log.d(TAG, "onSurfaceTextureSizeChanged(width:" + width + ", height: " + height + + " )"); + } + + /** + * If a surface has been provided to the camera (meaning + * {@link TextureViewImplementation#mSurfaceRequest} is null), but the camera + * is still using it (meaning {@link TextureViewImplementation#mSurfaceReleaseFuture} is + * not null), a listener must be added to + * {@link TextureViewImplementation#mSurfaceReleaseFuture} to ensure the surface + * is properly released after the camera is done using it. + * + * @param surfaceTexture The {@link SurfaceTexture} about to be destroyed. + * @return false if the camera is not done with the surface, true otherwise. + */ + @Override + public boolean onSurfaceTextureDestroyed(final SurfaceTexture surfaceTexture) { + mSurfaceTexture = null; + if (mSurfaceRequest == null && mSurfaceReleaseFuture != null) { + Futures.addCallback(mSurfaceReleaseFuture, + new FutureCallback() { + @Override + public void onSuccess(Result result) { + Preconditions.checkState(result.getResultCode() + != Result.RESULT_SURFACE_ALREADY_PROVIDED, + "Unexpected result from SurfaceRequest. Surface was " + + "provided twice."); + surfaceTexture.release(); + } + + @Override + public void onFailure(Throwable t) { + throw new IllegalStateException("SurfaceReleaseFuture did not " + + "complete nicely.", t); + } + }, ContextCompat.getMainExecutor(mTextureView.getContext())); + return false; + } else { + return true; + } + } + + @Override + public void onSurfaceTextureUpdated(final SurfaceTexture surfaceTexture) { + } + }); + + // Even though PreviewView calls `removeAllViews()` before calling init(), it should be + // called again here in case `getPreviewSurfaceProvider()` is called more than once on + // the same TextureViewImplementation instance. + mParent.removeAllViews(); + mParent.addView(mTextureView); + } + + @SuppressWarnings("WeakerAccess") + void tryToProvidePreviewSurface() { + /* + Should only continue if: + - The preview size has been specified. + - The textureView's surfaceTexture is available (after TextureView + .SurfaceTextureListener#onSurfaceTextureAvailable is invoked) + - The surfaceCompleter has been set (after CallbackToFutureAdapter + .Resolver#attachCompleter is invoked). + */ + if (mResolution == null || mSurfaceTexture == null || mSurfaceRequest == null) { + return; + } + + mSurfaceTexture.setDefaultBufferSize(mResolution.getWidth(), mResolution.getHeight()); + + final Surface surface = new Surface(mSurfaceTexture); + final ListenableFuture surfaceReleaseFuture = + CallbackToFutureAdapter.getFuture(completer -> { + mSurfaceRequest.provideSurface(surface, + CameraXExecutors.directExecutor(), completer::set); + return "provideSurface[request=" + mSurfaceRequest + " surface=" + surface + + "]"; + }); + mSurfaceReleaseFuture = surfaceReleaseFuture; + mSurfaceReleaseFuture.addListener(() -> { + surface.release(); + if (mSurfaceReleaseFuture == surfaceReleaseFuture) { + mSurfaceReleaseFuture = null; + } + }, ContextCompat.getMainExecutor(mTextureView.getContext())); + + mSurfaceRequest = null; + + correctPreviewForCenterCrop(mParent, mTextureView, mResolution); + } + + /** + * Corrects the preview to match the UI orientation and completely fill the PreviewView. + * + *

+ * The camera produces a preview that depends on its sensor orientation and that has a + * specific resolution. In order to display it correctly, this preview must be rotated to + * match the UI orientation, and must be scaled up/down to fit inside the view that's + * displaying it. This method takes care of doing so while keeping the preview centered. + *

+ * + * @param container The {@link PreviewView}'s root layout, which wraps the preview. + * @param textureView The {@link android.view.TextureView} that displays the preview, its size + * must match the camera sensor output size. + * @param bufferSize The camera sensor output size. + */ + private void correctPreviewForCenterCrop(@NonNull final View container, + @NonNull final TextureView textureView, @NonNull final Size bufferSize) { + // Scale TextureView to fill PreviewView while respecting sensor output size aspect ratio + final Pair scale = ScaleTypeTransform.getFillScaleWithBufferAspectRatio(container, textureView, + bufferSize); + textureView.setScaleX(scale.first); + textureView.setScaleY(scale.second); + + // Center TextureView inside PreviewView + final Point newOrigin = ScaleTypeTransform.getOriginOfCenteredView(container, textureView); + textureView.setX(newOrigin.x); + textureView.setY(newOrigin.y); + + // Rotate TextureView to correct preview orientation + final int rotation = ScaleTypeTransform.getRotationDegrees(textureView); + textureView.setRotation(-rotation); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/TextureViewMeteringPointFactory.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/TextureViewMeteringPointFactory.java index de3d3c546..b878708f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/TextureViewMeteringPointFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/TextureViewMeteringPointFactory.java @@ -22,6 +22,7 @@ import android.graphics.SurfaceTexture; import android.view.TextureView; import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; import androidx.camera.core.MeteringPoint; import androidx.camera.core.MeteringPointFactory; @@ -37,57 +38,60 @@ import androidx.camera.core.MeteringPointFactory; * to the lens face of current camera ouput. */ public class TextureViewMeteringPointFactory extends MeteringPointFactory { - private final TextureView mTextureView; + private final TextureView mTextureView; - public TextureViewMeteringPointFactory(@NonNull TextureView textureView) { - mTextureView = textureView; - } + public TextureViewMeteringPointFactory(@NonNull TextureView textureView) { + mTextureView = textureView; + } - /** - * Translates a (x,y) from TextureView. - */ - @NonNull - @Override - protected PointF translatePoint(float x, float y) { - Matrix transform = new Matrix(); - mTextureView.getTransform(transform); + /** + * Translates a (x,y) from TextureView. + * + * @hide + */ + @NonNull + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + @Override + protected PointF convertPoint(float x, float y) { + Matrix transform = new Matrix(); + mTextureView.getTransform(transform); - // applying reverse of TextureView#getTransform - Matrix inverse = new Matrix(); - transform.invert(inverse); - float[] pt = new float[]{x, y}; - inverse.mapPoints(pt); + // applying reverse of TextureView#getTransform + Matrix inverse = new Matrix(); + transform.invert(inverse); + float[] pt = new float[]{x, y}; + inverse.mapPoints(pt); - // get SurfaceTexture#getTransformMatrix - float[] surfaceTextureMat = new float[16]; - mTextureView.getSurfaceTexture().getTransformMatrix(surfaceTextureMat); + // get SurfaceTexture#getTransformMatrix + float[] surfaceTextureMat = new float[16]; + mTextureView.getSurfaceTexture().getTransformMatrix(surfaceTextureMat); - // convert SurfaceTexture#getTransformMatrix(4x4 column major 3D matrix) to - // android.graphics.Matrix(3x3 row major 2D matrix) - Matrix surfaceTextureTransform = glMatrixToGraphicsMatrix(surfaceTextureMat); + // convert SurfaceTexture#getTransformMatrix(4x4 column major 3D matrix) to + // android.graphics.Matrix(3x3 row major 2D matrix) + Matrix surfaceTextureTransform = glMatrixToGraphicsMatrix(surfaceTextureMat); - float[] pt2 = new float[2]; - // convert to texture coordinates first. - pt2[0] = pt[0] / mTextureView.getWidth(); - pt2[1] = (mTextureView.getHeight() - pt[1]) / mTextureView.getHeight(); - surfaceTextureTransform.mapPoints(pt2); + float[] pt2 = new float[2]; + // convert to texture coordinates first. + pt2[0] = pt[0] / mTextureView.getWidth(); + pt2[1] = (mTextureView.getHeight() - pt[1]) / mTextureView.getHeight(); + surfaceTextureTransform.mapPoints(pt2); - return new PointF(pt2[0], pt2[1]); - } + return new PointF(pt2[0], pt2[1]); + } - private Matrix glMatrixToGraphicsMatrix(float[] glMatrix) { - float[] convert = new float[9]; - convert[0] = glMatrix[0]; - convert[1] = glMatrix[4]; - convert[2] = glMatrix[12]; - convert[3] = glMatrix[1]; - convert[4] = glMatrix[5]; - convert[5] = glMatrix[13]; - convert[6] = glMatrix[3]; - convert[7] = glMatrix[7]; - convert[8] = glMatrix[15]; - Matrix graphicsMatrix = new Matrix(); - graphicsMatrix.setValues(convert); - return graphicsMatrix; - } + private Matrix glMatrixToGraphicsMatrix(float[] glMatrix) { + float[] convert = new float[9]; + convert[0] = glMatrix[0]; + convert[1] = glMatrix[4]; + convert[2] = glMatrix[12]; + convert[3] = glMatrix[1]; + convert[4] = glMatrix[5]; + convert[5] = glMatrix[13]; + convert[6] = glMatrix[3]; + convert[7] = glMatrix[7]; + convert[8] = glMatrix[15]; + Matrix graphicsMatrix = new Matrix(); + graphicsMatrix.setValues(convert); + return graphicsMatrix; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/TransformableSurfaceView.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/TransformableSurfaceView.java new file mode 100644 index 000000000..7e06677a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/TransformableSurfaceView.java @@ -0,0 +1,130 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.mediasend.camerax; + +import android.content.Context; +import android.graphics.Matrix; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.SurfaceView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +/** + * A subclass of {@link SurfaceView} that supports translation and scaling transformations. + */ +// Begin Signal Custom Code Block +@RequiresApi(21) +// End Signal Custom Code Block +final class TransformableSurfaceView extends SurfaceView { + + private RectF mOverriddenLayoutRect; + + TransformableSurfaceView(@NonNull Context context) { + super(context); + } + + TransformableSurfaceView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + TransformableSurfaceView(@NonNull Context context, @Nullable AttributeSet attrs, + int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + TransformableSurfaceView(@NonNull Context context, @Nullable AttributeSet attrs, + int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + if (mOverriddenLayoutRect == null) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } else { + setMeasuredDimension((int) mOverriddenLayoutRect.width(), + (int) mOverriddenLayoutRect.height()); + } + } + + /** + * Sets the transform to associate with this surface view. Only translation and scaling are + * supported. If a rotated transformation is passed in, an exception is thrown. + * + * @param transform The transform to apply to the content of this view. + */ + void setTransform(final Matrix transform) { + if (hasRotation(transform)) { + throw new IllegalArgumentException("TransformableSurfaceView does not support " + + "rotation transformations."); + } + + final RectF rect = new RectF(getLeft(), getTop(), getRight(), getBottom()); + transform.mapRect(rect); + overrideLayout(rect); + } + + private boolean hasRotation(final Matrix matrix) { + final float[] values = new float[9]; + matrix.getValues(values); + + /* + A translation matrix can be represented as: + (1 0 transX) + (0 1 transX) + (0 0 1) + + A rotation Matrix of ψ degrees can be represented as: + (cosψ -sinψ 0) + (sinψ cosψ 0) + (0 0 1) + + A scale matrix can be represented as: + (scaleX 0 0) + (0 scaleY 0) + (0 0 0) + + Meaning a transformed matrix can be represented as: + (scaleX * cosψ -scaleX * sinψ transX) + (scaleY * sinψ scaleY * cosψ transY) + (0 0 1) + + Using the following 2 equalities: + scaleX * cosψ = matrix[0][0] + -scaleX * sinψ = matrix[0][1] + + The following is deduced: + -tanψ = matrix[0][1] / matrix[0][0] + + Or: + ψ = -arctan(matrix[0][1] / matrix[0][0]) + */ + final double angle = -Math.atan2(values[Matrix.MSKEW_X], values[Matrix.MSCALE_X]); + + return Math.round(angle * (180 / Math.PI)) != 0; + } + + private void overrideLayout(final RectF overriddenLayoutRect) { + mOverriddenLayoutRect = overriddenLayoutRect; + setX(overriddenLayoutRect.left); + setY(overriddenLayoutRect.top); + requestLayout(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/VideoCapture.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/VideoCapture.java index 4c0ef726e..d6baaea70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/VideoCapture.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/VideoCapture.java @@ -16,7 +16,22 @@ package org.thoughtcrime.securesms.mediasend.camerax; -import android.annotation.SuppressLint; +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import android.location.Location; import android.media.AudioFormat; import android.media.AudioRecord; @@ -30,39 +45,42 @@ import android.media.MediaMuxer; import android.media.MediaRecorder.AudioSource; import android.os.Handler; import android.os.HandlerThread; -import android.os.Looper; +import android.util.Log; import android.util.Size; import android.view.Display; import android.view.Surface; import androidx.annotation.GuardedBy; +import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.RestrictTo; import androidx.annotation.RestrictTo.Scope; -import androidx.camera.core.CameraInfoInternal; -import androidx.camera.core.CameraInfoUnavailableException; +import androidx.camera.core.CameraInfo; import androidx.camera.core.CameraX; -import androidx.camera.core.CameraX.LensFacing; import androidx.camera.core.CameraXThreads; -import androidx.camera.core.ConfigProvider; -import androidx.camera.core.DeferrableSurface; -import androidx.camera.core.ImageOutputConfig; -import androidx.camera.core.ImageOutputConfig.RotationValue; -import androidx.camera.core.ImmediateSurface; -import androidx.camera.core.SessionConfig; import androidx.camera.core.UseCase; -import androidx.camera.core.UseCaseConfig; -import androidx.camera.core.VideoCaptureConfig; +import androidx.camera.core.impl.CameraInfoInternal; +import androidx.camera.core.impl.CameraInternal; +import androidx.camera.core.impl.ConfigProvider; +import androidx.camera.core.impl.DeferrableSurface; +import androidx.camera.core.impl.ImageOutputConfig; +import androidx.camera.core.impl.ImageOutputConfig.RotationValue; +import androidx.camera.core.impl.ImmediateSurface; +import androidx.camera.core.impl.SessionConfig; +import androidx.camera.core.impl.UseCaseConfig; +import androidx.camera.core.impl.VideoCaptureConfig; import androidx.camera.core.impl.utils.executor.CameraXExecutors; +import androidx.camera.core.internal.utils.UseCaseConfigUtil; -import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.video.VideoUtil; import java.io.File; import java.io.FileDescriptor; import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.nio.ByteBuffer; import java.util.Map; import java.util.concurrent.Executor; @@ -77,10 +95,31 @@ import java.util.concurrent.atomic.AtomicBoolean; * * @hide In the earlier stage, the VideoCapture is deprioritized. */ +// Begin Signal Custom Code Block @RequiresApi(26) +// End Signal Custom Code Block @RestrictTo(Scope.LIBRARY_GROUP) +@SuppressWarnings("ClassCanBeStatic") // TODO(b/141958189): Suppressed during upgrade to AGP 3.6. public class VideoCapture extends UseCase { + /** + * An unknown error occurred. + * + *

See message parameter in onError callback or log for more details. + */ + public static final int ERROR_UNKNOWN = 0; + /** + * An error occurred with encoder state, either when trying to change state or when an + * unexpected state change occurred. + */ + public static final int ERROR_ENCODER = 1; + /** An error with muxer state such as during creation or when stopping. */ + public static final int ERROR_MUXER = 2; + /** + * An error indicating start recording was called when video recording is still in progress. + */ + public static final int ERROR_RECORDING_IN_PROGRESS = 3; + /** * Provides a static configuration with implementation-agnostic options. * @@ -131,7 +170,6 @@ public class VideoCapture extends UseCase { /** For record the first sample written time. */ private final AtomicBoolean mIsFirstVideoSampleWrite = new AtomicBoolean(false); private final AtomicBoolean mIsFirstAudioSampleWrite = new AtomicBoolean(false); - private final VideoCaptureConfig.Builder mUseCaseConfigBuilder; @NonNull MediaCodec mVideoEncoder; @@ -147,7 +185,9 @@ public class VideoCapture extends UseCase { private int mAudioTrackIndex; /** Surface the camera writes to, which the videoEncoder uses as input. */ Surface mCameraSurface; + /** audio raw data */ + @NonNull private AudioRecord mAudioRecorder; private int mAudioBufferSize; private boolean mIsRecording = false; @@ -163,7 +203,6 @@ public class VideoCapture extends UseCase { */ public VideoCapture(VideoCaptureConfig config) { super(config); - mUseCaseConfigBuilder = VideoCaptureConfig.Builder.fromConfig(config); // video thread start mVideoHandlerThread.start(); @@ -182,9 +221,6 @@ public class VideoCapture extends UseCase { format.setInteger(MediaFormat.KEY_COLOR_FORMAT, CodecCapabilities.COLOR_FormatSurface); format.setInteger(MediaFormat.KEY_BIT_RATE, config.getBitRate()); format.setInteger(MediaFormat.KEY_FRAME_RATE, config.getVideoFrameRate()); - // Begin Signal Custom Code Block - format.setInteger(MediaFormat.KEY_CAPTURE_RATE, config.getVideoFrameRate()); - // End Signal Custom Code Block format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, config.getIFrameInterval()); return format; @@ -199,9 +235,9 @@ public class VideoCapture extends UseCase { @Override @Nullable @RestrictTo(Scope.LIBRARY_GROUP) - protected UseCaseConfig.Builder getDefaultBuilder(LensFacing lensFacing) { - VideoCaptureConfig defaults = CameraX.getDefaultUseCaseConfig( - VideoCaptureConfig.class, lensFacing); + protected UseCaseConfig.Builder getDefaultBuilder(@Nullable CameraInfo cameraInfo) { + VideoCaptureConfig defaults = CameraX.getDefaultUseCaseConfig(VideoCaptureConfig.class, + cameraInfo); if (defaults != null) { return VideoCaptureConfig.Builder.fromConfig(defaults); } @@ -216,9 +252,9 @@ public class VideoCapture extends UseCase { */ @Override @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull protected Map onSuggestedResolutionUpdated( - Map suggestedResolutionMap) { - VideoCaptureConfig config = (VideoCaptureConfig) getUseCaseConfig(); + @NonNull Map suggestedResolutionMap) { if (mCameraSurface != null) { mVideoEncoder.stop(); mVideoEncoder.release(); @@ -234,14 +270,14 @@ public class VideoCapture extends UseCase { throw new IllegalStateException("Unable to create MediaCodec due to: " + e.getCause()); } - String cameraId = getCameraIdUnchecked(config); + String cameraId = getBoundCameraId(); Size resolution = suggestedResolutionMap.get(cameraId); if (resolution == null) { throw new IllegalArgumentException( "Suggested resolution map missing resolution for camera " + cameraId); } - setupEncoder(resolution); + setupEncoder(cameraId, resolution); return suggestedResolutionMap; } @@ -250,20 +286,19 @@ public class VideoCapture extends UseCase { * called. * *

StartRecording() is asynchronous. User needs to check if any error occurs by setting the - * {@link OnVideoSavedListener#onError(VideoCaptureError, String, Throwable)}. + * {@link OnVideoSavedCallback#onError(int, String, Throwable)}. * * @param saveLocation Location to save the video capture - * @param executor The executor in which the listener callback methods will be run. - * @param listener Listener to call for the recorded video + * @param executor The executor in which the callback methods will be run. + * @param callback Callback for when the recorded video saving completion or failure. */ - @SuppressLint("LambdaLast") // Maybe remove after https://issuetracker.google.com/135275901 // Begin Signal Custom Code Block public void startRecording(@NonNull FileDescriptor saveLocation, - @NonNull Executor executor, @NonNull OnVideoSavedListener listener) { // End Signal Custom Code Block + @NonNull Executor executor, @NonNull OnVideoSavedCallback callback) { mIsFirstVideoSampleWrite.set(false); mIsFirstAudioSampleWrite.set(false); - startRecording(saveLocation, EMPTY_METADATA, executor, listener); + startRecording(saveLocation, EMPTY_METADATA, executor, callback); } /** @@ -271,26 +306,26 @@ public class VideoCapture extends UseCase { * called. * *

StartRecording() is asynchronous. User needs to check if any error occurs by setting the - * {@link OnVideoSavedListener#onError(VideoCaptureError, String, Throwable)}. + * {@link OnVideoSavedCallback#onError(int, String, Throwable)}. * * @param saveLocation Location to save the video capture * @param metadata Metadata to save with the recorded video - * @param executor The executor in which the listener callback methods will be run. - * @param listener Listener to call for the recorded video + * @param executor The executor in which the callback methods will be run. + * @param callback Callback for when the recorded video saving completion or failure. */ - @SuppressLint("LambdaLast") // Maybe remove after https://issuetracker.google.com/135275901 - // Begin Signal Custom Code Block public void startRecording( - @NonNull FileDescriptor saveLocation, @NonNull Metadata metadata, + // Begin Signal Custom Code Block + @NonNull FileDescriptor saveLocation, + // End Signal Custom Code Block + @NonNull Metadata metadata, @NonNull Executor executor, - @NonNull OnVideoSavedListener listener) { - // End Signal Custom Code Block + @NonNull OnVideoSavedCallback callback) { Log.i(TAG, "startRecording"); - OnVideoSavedListener postListener = new VideoSavedListenerWrapper(executor, listener); + OnVideoSavedCallback postListener = new VideoSavedListenerWrapper(executor, callback); if (!mEndOfAudioVideoSignal.get()) { postListener.onError( - VideoCaptureError.RECORDING_IN_PROGRESS, "It is still in video recording!", + ERROR_RECORDING_IN_PROGRESS, "It is still in video recording!", null); return; } @@ -305,12 +340,13 @@ public class VideoCapture extends UseCase { } // End Signal Custom Code Block } catch (IllegalStateException e) { - postListener.onError(VideoCaptureError.ENCODER_ERROR, "AudioRecorder start fail", e); + postListener.onError(ERROR_ENCODER, "AudioRecorder start fail", e); return; } - VideoCaptureConfig config = (VideoCaptureConfig) getUseCaseConfig(); - String cameraId = getCameraIdUnchecked(config); + CameraInternal boundCamera = getBoundCamera(); + String cameraId = getBoundCameraId(); + Size resolution = getAttachedSurfaceResolution(cameraId); try { // video encoder start Log.i(TAG, "videoEncoder start"); @@ -320,23 +356,15 @@ public class VideoCapture extends UseCase { mAudioEncoder.start(); } catch (IllegalStateException e) { - setupEncoder(getAttachedSurfaceResolution(cameraId)); - postListener.onError(VideoCaptureError.ENCODER_ERROR, "Audio/Video encoder start fail", + setupEncoder(cameraId, resolution); + postListener.onError(ERROR_ENCODER, "Audio/Video encoder start fail", e); return; } - // Get the relative rotation or default to 0 if the camera info is unavailable - int relativeRotation = 0; - try { - CameraInfoInternal cameraInfoInternal = CameraX.getCameraInfo(cameraId); - relativeRotation = - cameraInfoInternal.getSensorRotationDegrees( - ((ImageOutputConfig) getUseCaseConfig()) - .getTargetRotation(Surface.ROTATION_0)); - } catch (CameraInfoUnavailableException e) { - Log.e(TAG, "Unable to retrieve camera sensor orientation.", e); - } + CameraInfoInternal cameraInfoInternal = boundCamera.getCameraInfoInternal(); + int relativeRotation = cameraInfoInternal.getSensorRotationDegrees( + ((ImageOutputConfig) getUseCaseConfig()).getTargetRotation(Surface.ROTATION_0)); try { synchronized (mMuxerLock) { @@ -355,8 +383,8 @@ public class VideoCapture extends UseCase { } } } catch (IOException e) { - setupEncoder(getAttachedSurfaceResolution(cameraId)); - postListener.onError(VideoCaptureError.MUXER_ERROR, "MediaMuxer creation failed!", e); + setupEncoder(cameraId, resolution); + postListener.onError(ERROR_MUXER, "MediaMuxer creation failed!", e); return; } @@ -378,7 +406,8 @@ public class VideoCapture extends UseCase { new Runnable() { @Override public void run() { - boolean errorOccurred = VideoCapture.this.videoEncode(postListener); + boolean errorOccurred = VideoCapture.this.videoEncode(postListener, + cameraId, resolution); if (!errorOccurred) { postListener.onVideoSaved(saveLocation); } @@ -388,11 +417,11 @@ public class VideoCapture extends UseCase { /** * Stops recording video, this must be called after {@link - * VideoCapture#startRecording(File, Metadata, Executor, OnVideoSavedListener)} is called. + * VideoCapture#startRecording(File, Metadata, Executor, OnVideoSavedCallback)} is called. * *

stopRecording() is asynchronous API. User need to check if {@link - * OnVideoSavedListener#onVideoSaved(File)} or - * {@link OnVideoSavedListener#onError(VideoCaptureError, String, Throwable)} be called + * OnVideoSavedCallback#onVideoSaved(File)} or + * {@link OnVideoSavedCallback#onError(int, String, Throwable)} be called * before startRecording. */ public void stopRecording() { @@ -438,23 +467,17 @@ public class VideoCapture extends UseCase { return; } - final Surface surface = mCameraSurface; final MediaCodec videoEncoder = mVideoEncoder; - mDeferrableSurface.setOnSurfaceDetachedListener( - CameraXExecutors.mainThreadExecutor(), - new DeferrableSurface.OnSurfaceDetachedListener() { - @Override - public void onSurfaceDetached() { - if (releaseVideoEncoder && videoEncoder != null) { - videoEncoder.release(); - } - - if (surface != null) { - surface.release(); - } + // Calling close should allow termination future to complete and close the surface with + // the listener that was added after constructing the DeferrableSurface. + mDeferrableSurface.close(); + mDeferrableSurface.getTerminationFuture().addListener( + () -> { + if (releaseVideoEncoder && videoEncoder != null) { + videoEncoder.release(); } - }); + }, CameraXExecutors.mainThreadExecutor()); if (releaseVideoEncoder) { mVideoEncoder = null; @@ -473,11 +496,12 @@ public class VideoCapture extends UseCase { * @param rotation Desired rotation of the output video. */ public void setTargetRotation(@RotationValue int rotation) { - ImageOutputConfig oldConfig = (ImageOutputConfig) getUseCaseConfig(); + VideoCaptureConfig oldConfig = (VideoCaptureConfig) getUseCaseConfig(); + VideoCaptureConfig.Builder builder = VideoCaptureConfig.Builder.fromConfig(oldConfig); int oldRotation = oldConfig.getTargetRotation(ImageOutputConfig.INVALID_ROTATION); if (oldRotation == ImageOutputConfig.INVALID_ROTATION || oldRotation != rotation) { - mUseCaseConfigBuilder.setTargetRotation(rotation); - updateUseCaseConfig(mUseCaseConfigBuilder.build()); + UseCaseConfigUtil.updateTargetRotationAndRelatedConfigs(builder, rotation); + updateUseCaseConfig(builder.getUseCaseConfig()); // TODO(b/122846516): Update session configuration and possibly reconfigure session. } @@ -488,7 +512,7 @@ public class VideoCapture extends UseCase { * audio from selected audio source. */ @SuppressWarnings("WeakerAccess") /* synthetic accessor */ - void setupEncoder(Size resolution) { + void setupEncoder(@NonNull String cameraId, @NonNull Size resolution) { VideoCaptureConfig config = (VideoCaptureConfig) getUseCaseConfig(); // video encoder setup @@ -501,21 +525,32 @@ public class VideoCapture extends UseCase { if (mCameraSurface != null) { releaseCameraSurface(false); } - mCameraSurface = mVideoEncoder.createInputSurface(); + Surface cameraSurface = mVideoEncoder.createInputSurface(); + mCameraSurface = cameraSurface; SessionConfig.Builder sessionConfigBuilder = SessionConfig.Builder.createFrom(config); + if (mDeferrableSurface != null) { + mDeferrableSurface.close(); + } mDeferrableSurface = new ImmediateSurface(mCameraSurface); + mDeferrableSurface.getTerminationFuture().addListener( + cameraSurface::release, CameraXExecutors.mainThreadExecutor() + ); sessionConfigBuilder.addSurface(mDeferrableSurface); - String cameraId = getCameraIdUnchecked(config); - sessionConfigBuilder.addErrorListener(new SessionConfig.ErrorListener() { @Override public void onError(@NonNull SessionConfig sessionConfig, @NonNull SessionConfig.SessionError error) { - setupEncoder(resolution); + // Ensure the bound camera has not changed before calling setupEncoder. + // TODO(b/143915543): Ensure this never gets called by a camera that is not bound + // to this use case so we don't need to do this check. + if (isCurrentlyBoundCamera(cameraId)) { + // Only reset the pipeline when the bound camera is the same. + setupEncoder(cameraId, resolution); + } } }); @@ -620,8 +655,8 @@ public class VideoCapture extends UseCase { * * @return returns {@code true} if an error condition occurred, otherwise returns {@code false} */ - boolean videoEncode(OnVideoSavedListener videoSavedListener) { - VideoCaptureConfig config = (VideoCaptureConfig) getUseCaseConfig(); + boolean videoEncode(@NonNull OnVideoSavedCallback videoSavedCallback, @NonNull String cameraId, + @NonNull Size resolution) { // Main encoding loop. Exits on end of stream. boolean errorOccurred = false; boolean videoEos = false; @@ -638,8 +673,8 @@ public class VideoCapture extends UseCase { switch (outputBufferId) { case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED: if (mMuxerStarted) { - videoSavedListener.onError( - VideoCaptureError.ENCODER_ERROR, + videoSavedCallback.onError( + ERROR_ENCODER, "Unexpected change in video encoding format.", null); errorOccurred = true; @@ -656,10 +691,6 @@ public class VideoCapture extends UseCase { break; case MediaCodec.INFO_TRY_AGAIN_LATER: // Timed out. Just wait until next attempt to deque. - case MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED: - // Ignore output buffers changed since we dequeue a single buffer instead of - // multiple - break; default: videoEos = writeVideoEncodedBuffer(outputBufferId); } @@ -669,7 +700,7 @@ public class VideoCapture extends UseCase { Log.i(TAG, "videoEncoder stop"); mVideoEncoder.stop(); } catch (IllegalStateException e) { - videoSavedListener.onError(VideoCaptureError.ENCODER_ERROR, + videoSavedCallback.onError(ERROR_ENCODER, "Video encoder stop failed!", e); errorOccurred = true; } @@ -686,16 +717,15 @@ public class VideoCapture extends UseCase { } } } catch (IllegalStateException e) { - videoSavedListener.onError(VideoCaptureError.MUXER_ERROR, "Muxer stop failed!", e); + videoSavedCallback.onError(ERROR_MUXER, "Muxer stop failed!", e); errorOccurred = true; } mMuxerStarted = false; // Do the setup of the videoEncoder at the end of video recording instead of at the start of // recording because it requires attaching a new Surface. This causes a glitch so we don't - // want - // that to incur latency at the start of capture. - setupEncoder(getAttachedSurfaceResolution(getCameraIdUnchecked(config))); + // want that to incur latency at the start of capture. + setupEncoder(cameraId, resolution); notifyReset(); // notify the UI thread that the video recording has finished @@ -705,7 +735,7 @@ public class VideoCapture extends UseCase { return errorOccurred; } - boolean audioEncode(OnVideoSavedListener videoSavedListener) { + boolean audioEncode(OnVideoSavedCallback videoSavedCallback) { // Audio encoding loop. Exits on end of stream. boolean audioEos = false; int outIndex; @@ -766,14 +796,14 @@ public class VideoCapture extends UseCase { } // End Signal Custom Code Block } catch (IllegalStateException e) { - videoSavedListener.onError( - VideoCaptureError.ENCODER_ERROR, "Audio recorder stop failed!", e); + videoSavedCallback.onError( + ERROR_ENCODER, "Audio recorder stop failed!", e); } try { mAudioEncoder.stop(); } catch (IllegalStateException e) { - videoSavedListener.onError(VideoCaptureError.ENCODER_ERROR, + videoSavedCallback.onError(ERROR_ENCODER, "Audio encoder stop failed!", e); } @@ -889,39 +919,29 @@ public class VideoCapture extends UseCase { * Describes the error that occurred during video capture operations. * *

This is a parameter sent to the error callback functions set in listeners such as {@link - * VideoCapture.OnVideoSavedListener#onError(VideoCaptureError, String, Throwable)}. + * VideoCapture.OnVideoSavedCallback#onError(int, String, Throwable)}. * *

See message parameter in onError callback or log for more details. + * + * @hide */ - public enum VideoCaptureError { - /** - * An unknown error occurred. - * - *

See message parameter in onError callback or log for more details. - */ - UNKNOWN_ERROR, - /** - * An error occurred with encoder state, either when trying to change state or when an - * unexpected state change occurred. - */ - ENCODER_ERROR, - /** An error with muxer state such as during creation or when stopping. */ - MUXER_ERROR, - /** - * An error indicating start recording was called when video recording is still in progress. - */ - RECORDING_IN_PROGRESS + @IntDef({ERROR_UNKNOWN, ERROR_ENCODER, ERROR_MUXER, ERROR_RECORDING_IN_PROGRESS}) + @Retention(RetentionPolicy.SOURCE) + @RestrictTo(Scope.LIBRARY_GROUP) + public @interface VideoCaptureError { } /** Listener containing callbacks for video file I/O events. */ - public interface OnVideoSavedListener { + public interface OnVideoSavedCallback { /** Called when the video has been successfully saved. */ + // TODO: Should remove file argument to match ImageCapture.OnImageSavedCallback + // #onImageSaved() // Begin Signal Custom Code Block void onVideoSaved(@NonNull FileDescriptor file); // End Signal Custom Code Block /** Called when an error occurs while attempting to save the video. */ - void onError(@NonNull VideoCaptureError videoCaptureError, @NonNull String message, + void onError(@VideoCaptureError int videoCaptureError, @NonNull String message, @Nullable Throwable cause); } @@ -936,7 +956,6 @@ public class VideoCapture extends UseCase { @RestrictTo(Scope.LIBRARY_GROUP) public static final class Defaults implements ConfigProvider { - private static final Handler DEFAULT_HANDLER = new Handler(Looper.getMainLooper()); private static final int DEFAULT_VIDEO_FRAME_RATE = 30; /** 8Mb/s the recommend rate for 30fps 1080p */ private static final int DEFAULT_BIT_RATE = 8 * 1024 * 1024; @@ -973,11 +992,12 @@ public class VideoCapture extends UseCase { .setMaxResolution(DEFAULT_MAX_RESOLUTION) .setSurfaceOccupancyPriority(DEFAULT_SURFACE_OCCUPANCY_PRIORITY); - DEFAULT_CONFIG = builder.build(); + DEFAULT_CONFIG = builder.getUseCaseConfig(); } + @NonNull @Override - public VideoCaptureConfig getConfig(LensFacing lensFacing) { + public VideoCaptureConfig getConfig(@Nullable CameraInfo cameraInfo) { return DEFAULT_CONFIG; } } @@ -989,15 +1009,17 @@ public class VideoCapture extends UseCase { public Location location; } - private final class VideoSavedListenerWrapper implements OnVideoSavedListener { + private final class VideoSavedListenerWrapper implements OnVideoSavedCallback { - @NonNull Executor mExecutor; - @NonNull OnVideoSavedListener mOnVideoSavedListener; + @NonNull + Executor mExecutor; + @NonNull + OnVideoSavedCallback mOnVideoSavedCallback; VideoSavedListenerWrapper(@NonNull Executor executor, - @NonNull OnVideoSavedListener onVideoSavedListener) { + @NonNull OnVideoSavedCallback onVideoSavedCallback) { mExecutor = executor; - mOnVideoSavedListener = onVideoSavedListener; + mOnVideoSavedCallback = onVideoSavedCallback; } @Override @@ -1005,18 +1027,18 @@ public class VideoCapture extends UseCase { public void onVideoSaved(@NonNull FileDescriptor file) { // End Signal Custom Code Block try { - mExecutor.execute(() -> mOnVideoSavedListener.onVideoSaved(file)); + mExecutor.execute(() -> mOnVideoSavedCallback.onVideoSaved(file)); } catch (RejectedExecutionException e) { Log.e(TAG, "Unable to post to the supplied executor."); } } @Override - public void onError(@NonNull VideoCaptureError videoCaptureError, @NonNull String message, + public void onError(@VideoCaptureError int videoCaptureError, @NonNull String message, @Nullable Throwable cause) { try { mExecutor.execute( - () -> mOnVideoSavedListener.onError(videoCaptureError, message, cause)); + () -> mOnVideoSavedCallback.onError(videoCaptureError, message, cause)); } catch (RejectedExecutionException e) { Log.e(TAG, "Unable to post to the supplied executor."); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java index 266afc10d..ba13b7098 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java @@ -56,9 +56,9 @@ public class BasicMegaphoneView extends FrameLayout { this.megaphone = megaphone; this.megaphoneListener = megaphoneListener; - if (megaphone.getImage() != 0) { + if (megaphone.getImageRequest() != null) { image.setVisibility(VISIBLE); - image.setImageResource(megaphone.getImage()); + megaphone.getImageRequest().into(image); } else { image.setVisibility(GONE); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java index 86927f382..73dc47b81 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java @@ -1,28 +1,34 @@ package org.thoughtcrime.securesms.megaphone; +import android.content.Context; +import android.graphics.drawable.Drawable; + import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.megaphone.Megaphones.Event; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequest; /** * For guidance on creating megaphones, see {@link Megaphones}. */ public class Megaphone { - private final Event event; - private final Style style; - private final boolean mandatory; - private final boolean canSnooze; - private final int titleRes; - private final int bodyRes; - private final int imageRes; - private final int buttonTextRes; - private final EventListener buttonListener; - private final EventListener snoozeListener; - private final EventListener onVisibleListener; + private final Event event; + private final Style style; + private final boolean mandatory; + private final boolean canSnooze; + private final int titleRes; + private final int bodyRes; + private final GlideRequest imageRequest; + private final int buttonTextRes; + private final EventListener buttonListener; + private final EventListener snoozeListener; + private final EventListener onVisibleListener; private Megaphone(@NonNull Builder builder) { this.event = builder.event; @@ -31,7 +37,7 @@ public class Megaphone { this.canSnooze = builder.canSnooze; this.titleRes = builder.titleRes; this.bodyRes = builder.bodyRes; - this.imageRes = builder.imageRes; + this.imageRequest = builder.imageRequest; this.buttonTextRes = builder.buttonTextRes; this.buttonListener = builder.buttonListener; this.snoozeListener = builder.snoozeListener; @@ -62,8 +68,8 @@ public class Megaphone { return bodyRes; } - public @DrawableRes int getImage() { - return imageRes; + public @Nullable GlideRequest getImageRequest() { + return imageRequest; } public @StringRes int getButtonText() { @@ -91,15 +97,15 @@ public class Megaphone { private final Event event; private final Style style; - private boolean mandatory; - private boolean canSnooze; - private int titleRes; - private int bodyRes; - private int imageRes; - private int buttonTextRes; - private EventListener buttonListener; - private EventListener snoozeListener; - private EventListener onVisibleListener; + private boolean mandatory; + private boolean canSnooze; + private int titleRes; + private int bodyRes; + private GlideRequest imageRequest; + private int buttonTextRes; + private EventListener buttonListener; + private EventListener snoozeListener; + private EventListener onVisibleListener; public Builder(@NonNull Event event, @NonNull Style style) { @@ -135,7 +141,12 @@ public class Megaphone { } public @NonNull Builder setImage(@DrawableRes int imageRes) { - this.imageRes = imageRes; + setImageRequest(GlideApp.with(ApplicationDependencies.getApplication()).load(imageRes)); + return this; + } + + public @NonNull Builder setImageRequest(@Nullable GlideRequest imageRequest) { + this.imageRequest = imageRequest; return this; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java index 8b2e5c5eb..08d1f0c76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java @@ -23,6 +23,8 @@ 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.recipients.Recipient; +import org.thoughtcrime.securesms.util.AvatarUtil; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -201,15 +203,15 @@ public final class Megaphones { } private static @NonNull Megaphone buildProfileNamesMegaphone(@NonNull Context context) { - short requestCode = TextSecurePreferences.getProfileName(context) != ProfileName.EMPTY + short requestCode = Recipient.self().getProfileName() != 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); + .setImageRequest(AvatarUtil.getSelfAvatarOrFallbackIcon(context, R.drawable.ic_profilename_64)); - if (TextSecurePreferences.getProfileName(ApplicationDependencies.getApplication()) == ProfileName.EMPTY) { + if (Recipient.self().getProfileName() == ProfileName.EMPTY) { return builder.setTitle(R.string.ProfileNamesMegaphone__add_a_profile_name) .setBody(R.string.ProfileNamesMegaphone__this_will_be_displayed_when_you_start) .setActionButton(R.string.ProfileNamesMegaphone__add_profile_name, (megaphone, listener) -> { @@ -240,7 +242,7 @@ public final class Megaphones { } private static boolean shouldShowMessageRequestsMegaphone() { - boolean userHasAProfileName = TextSecurePreferences.getProfileName(ApplicationDependencies.getApplication()) != ProfileName.EMPTY; + boolean userHasAProfileName = Recipient.self().getProfileName() != ProfileName.EMPTY; return FeatureFlags.messageRequests() && !userHasAProfileName; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java index 9ad9febfb..46cd2a6da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java @@ -55,9 +55,9 @@ public class PopupMegaphoneView extends FrameLayout { this.megaphone = megaphone; this.megaphoneListener = megaphoneListener; - if (megaphone.getImage() != 0) { + if (megaphone.getImageRequest() != null) { image.setVisibility(VISIBLE); - image.setImageResource(megaphone.getImage()); + megaphone.getImageRequest().into(image); } else { image.setVisibility(GONE); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupMemberCount.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupMemberCount.java new file mode 100644 index 000000000..028638ec1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupMemberCount.java @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.messagerequests; + +final class GroupMemberCount { + static final GroupMemberCount ZERO = new GroupMemberCount(0, 0); + + private final int fullMemberCount; + private final int pendingMemberCount; + + GroupMemberCount(int fullMemberCount, int pendingMemberCount) { + this.fullMemberCount = fullMemberCount; + this.pendingMemberCount = pendingMemberCount; + } + + int getFullMemberCount() { + return fullMemberCount; + } + + int getPendingMemberCount() { + return pendingMemberCount; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestMegaphoneActivity.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestMegaphoneActivity.java index aeed83f0b..eb146ae88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestMegaphoneActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestMegaphoneActivity.java @@ -14,6 +14,7 @@ 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.recipients.Recipient; import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -53,7 +54,7 @@ public class MessageRequestMegaphoneActivity extends PassphraseRequiredActionBar if (requestCode == EDIT_PROFILE_REQUEST_CODE && resultCode == RESULT_OK && - TextSecurePreferences.getProfileName(this) != ProfileName.EMPTY) { + Recipient.self().getProfileName() != ProfileName.EMPTY) { ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.MESSAGE_REQUESTS); setResult(RESULT_OK); finish(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java index 558b8f4bb..6a73c691f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java @@ -25,7 +25,7 @@ import org.whispersystems.libsignal.util.guava.Optional; import java.util.List; import java.util.concurrent.Executor; -public class MessageRequestRepository { +final class MessageRequestRepository { private final Context context; private final Executor executor; @@ -42,11 +42,13 @@ public class MessageRequestRepository { }); } - void getMemberCount(@NonNull RecipientId recipientId, @NonNull Consumer onMemberCountLoaded) { + void getMemberCount(@NonNull RecipientId recipientId, @NonNull Consumer onMemberCountLoaded) { executor.execute(() -> { GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); Optional groupRecord = groupDatabase.getGroup(recipientId); - onMemberCountLoaded.accept(groupRecord.transform(record -> record.getMembers().size()).or(0)); + onMemberCountLoaded.accept(groupRecord.transform(record -> { + return new GroupMemberCount(record.getMembers().size(), 0); + }).or(GroupMemberCount.ZERO)); }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java index 3d501bc71..d416da07c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java @@ -25,13 +25,13 @@ import java.util.List; public class MessageRequestViewModel extends ViewModel { - private final SingleLiveEvent status = new SingleLiveEvent<>(); - private final MutableLiveData recipient = new MutableLiveData<>(); - private final MutableLiveData> groups = new MutableLiveData<>(Collections.emptyList()); - private final MutableLiveData memberCount = new MutableLiveData<>(0); - private final MutableLiveData displayState = new MutableLiveData<>(); - private final LiveData recipientInfo = Transformations.map(new LiveDataTriple<>(recipient, memberCount, groups), - triple -> new RecipientInfo(triple.first(), triple.second(), triple.third())); + private final SingleLiveEvent status = new SingleLiveEvent<>(); + private final MutableLiveData recipient = new MutableLiveData<>(); + private final MutableLiveData> groups = new MutableLiveData<>(Collections.emptyList()); + private final MutableLiveData memberCount = new MutableLiveData<>(GroupMemberCount.ZERO); + private final MutableLiveData displayState = new MutableLiveData<>(); + private final LiveData recipientInfo = Transformations.map(new LiveDataTriple<>(recipient, memberCount, groups), + triple -> new RecipientInfo(triple.first(), triple.second(), triple.third())); private final MessageRequestRepository repository; @@ -133,9 +133,7 @@ public class MessageRequestViewModel extends ViewModel { } private void loadMemberCount() { - repository.getMemberCount(liveRecipient.getId(), memberCount -> { - this.memberCount.postValue(memberCount == null ? 0 : memberCount); - }); + repository.getMemberCount(liveRecipient.getId(), memberCount::postValue); } @SuppressWarnings("ConstantConditions") @@ -161,13 +159,13 @@ public class MessageRequestViewModel extends ViewModel { } public static class RecipientInfo { - private final @Nullable Recipient recipient; - private final int groupMemberCount; - private final @NonNull List sharedGroups; + @Nullable private final Recipient recipient; + @NonNull private final GroupMemberCount groupMemberCount; + @NonNull private final List sharedGroups; - private RecipientInfo(@Nullable Recipient recipient, @Nullable Integer groupMemberCount, @Nullable List sharedGroups) { + private RecipientInfo(@Nullable Recipient recipient, @Nullable GroupMemberCount groupMemberCount, @Nullable List sharedGroups) { this.recipient = recipient; - this.groupMemberCount = groupMemberCount == null ? 0 : groupMemberCount; + this.groupMemberCount = groupMemberCount == null ? GroupMemberCount.ZERO : groupMemberCount; this.sharedGroups = sharedGroups == null ? Collections.emptyList() : sharedGroups; } @@ -177,7 +175,11 @@ public class MessageRequestViewModel extends ViewModel { } public int getGroupMemberCount() { - return groupMemberCount; + return groupMemberCount.getFullMemberCount(); + } + + public int getGroupPendingMemberCount() { + return groupMemberCount.getPendingMemberCount(); } @NonNull diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index b7bae93f0..81c66c92a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -39,7 +39,7 @@ public class ApplicationMigrations { private static final int LEGACY_CANONICAL_VERSION = 455; - public static final int CURRENT_VERSION = 12; + public static final int CURRENT_VERSION = 13; private static final class Version { static final int LEGACY = 1; @@ -54,6 +54,7 @@ public class ApplicationMigrations { static final int SWOON_STICKERS = 10; static final int STORAGE_SERVICE = 11; static final int STORAGE_KEY_ROTATE = 12; + static final int REMOVE_AVATAR_ID = 13; } /** @@ -215,6 +216,10 @@ public class ApplicationMigrations { jobs.put(Version.STORAGE_KEY_ROTATE, new StorageKeyRotationMigrationJob()); } + if (lastSeenVersion < Version.REMOVE_AVATAR_ID) { + jobs.put(Version.REMOVE_AVATAR_ID, new AvatarIdRemovalMigrationJob()); + } + return jobs; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/AvatarIdRemovalMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/AvatarIdRemovalMigrationJob.java new file mode 100644 index 000000000..d822153b2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/AvatarIdRemovalMigrationJob.java @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob; +import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob; +import org.thoughtcrime.securesms.jobs.StorageSyncJob; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +/** + * We just want to make sure that the user has a profile avatar set in the RecipientDatabase, so + * we're refreshing their own profile. + */ +public class AvatarIdRemovalMigrationJob extends MigrationJob { + + private static final String TAG = Log.tag(AvatarIdRemovalMigrationJob.class); + + public static final String KEY = "AvatarIdRemovalMigrationJob"; + + AvatarIdRemovalMigrationJob() { + this(new Parameters.Builder().build()); + } + + private AvatarIdRemovalMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + ApplicationDependencies.getJobManager().add(new RefreshOwnProfileJob()); + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull AvatarIdRemovalMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new AvatarIdRemovalMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/AvatarMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/AvatarMigrationJob.java index 20088c8eb..e1966f70a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/AvatarMigrationJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/AvatarMigrationJob.java @@ -2,14 +2,13 @@ package org.thoughtcrime.securesms.migrations; import androidx.annotation.NonNull; -import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.phonenumbers.NumberUtil; import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.Util; import java.io.File; @@ -83,7 +82,7 @@ public class AvatarMigrationJob extends MigrationJob { } private static boolean isValidFileName(@NonNull String name) { - return NUMBER_PATTERN.matcher(name).matches() || GroupUtil.isEncodedGroup(name) || NumberUtil.isValidEmail(name); + return NUMBER_PATTERN.matcher(name).matches() || GroupId.isEncodedGroup(name) || NumberUtil.isValidEmail(name); } public static class Factory implements Job.Factory { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java index 307d436ec..c7905f062 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java @@ -5,9 +5,9 @@ import androidx.annotation.NonNull; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.PointerAttachment; import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.util.GroupUtil; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceGroup; @@ -19,7 +19,7 @@ import java.util.List; public class IncomingMediaMessage { private final RecipientId from; - private final String groupId; + private final GroupId groupId; private final String body; private final boolean push; private final long sentTimeMillis; @@ -35,7 +35,7 @@ public class IncomingMediaMessage { private final List linkPreviews = new LinkedList<>(); public IncomingMediaMessage(@NonNull RecipientId from, - Optional groupId, + Optional groupId, String body, long sentTimeMillis, List attachments, @@ -86,7 +86,7 @@ public class IncomingMediaMessage { this.quote = quote.orNull(); this.unidentified = unidentified; - if (group.isPresent()) this.groupId = GroupUtil.getEncodedId(group.get().getGroupId(), false); + if (group.isPresent()) this.groupId = GroupId.v1(group.get().getGroupId()); else this.groupId = null; this.attachments.addAll(PointerAttachment.forPointers(attachments)); @@ -114,7 +114,7 @@ public class IncomingMediaMessage { return from; } - public String getGroupId() { + public GroupId getGroupId() { return groupId; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/CallRequestController.java b/app/src/main/java/org/thoughtcrime/securesms/net/CallRequestController.java index 034fc3189..4d4c69c09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/net/CallRequestController.java +++ b/app/src/main/java/org/thoughtcrime/securesms/net/CallRequestController.java @@ -4,6 +4,7 @@ import android.os.AsyncTask; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; +import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; @@ -27,24 +28,15 @@ public class CallRequestController implements RequestController { AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { synchronized (CallRequestController.this) { if (canceled) return; - + call.cancel(); - - if (stream != null) { - Util.close(stream); - } - canceled = true; } }); } public synchronized void setStream(@NonNull InputStream stream) { - if (canceled) { - Util.close(stream); - } else { - this.stream = stream; - } + this.stream = stream; notifyAll(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/FailedNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/FailedNotificationBuilder.java index af0508ffe..084785932 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/FailedNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/FailedNotificationBuilder.java @@ -14,7 +14,7 @@ public class FailedNotificationBuilder extends AbstractNotificationBuilder { public FailedNotificationBuilder(Context context, NotificationPrivacyPreference privacy, Intent intent) { super(context, privacy); - setSmallIcon(R.drawable.icon_notification); + setSmallIcon(R.drawable.ic_notification); setLargeIcon(BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_action_warning_red)); setContentTitle(context.getString(R.string.MessageNotifier_message_delivery_failed)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java index 6d6199a2b..e455b028d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java @@ -25,8 +25,8 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu public MultipleRecipientNotificationBuilder(Context context, NotificationPrivacyPreference privacy) { super(context, privacy); - setColor(context.getResources().getColor(R.color.textsecure_primary)); - setSmallIcon(R.drawable.icon_notification); + setColor(context.getResources().getColor(R.color.core_ultramarine)); + setSmallIcon(R.drawable.ic_notification); setContentTitle(context.getString(R.string.app_name)); // TODO [greyson] Navigation setContentIntent(PendingIntent.getActivity(context, 0, new Intent(context, MainActivity.class), 0)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java index 40249835f..1c91a34ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java @@ -5,7 +5,6 @@ public final class NotificationIds { public static final int FCM_FAILURE = 12; public static final int PENDING_MESSAGES = 1111; public static final int MESSAGE_SUMMARY = 1338; - public static final int EXPERIENCE_UPGRADE = 1339; public static final int APPLICATION_MIGRATION = 4242; public static final int SMS_IMPORT_COMPLETE = 31337; public static final int THREAD = 50000; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java index dcdc9a2b6..9b58e25c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java @@ -20,8 +20,8 @@ public class PendingMessageNotificationBuilder extends AbstractNotificationBuild // TODO [greyson] Navigation Intent intent = new Intent(context, MainActivity.class); - setSmallIcon(R.drawable.icon_notification); - setColor(context.getResources().getColor(R.color.textsecure_primary)); + setSmallIcon(R.drawable.ic_notification); + setColor(context.getResources().getColor(R.color.core_ultramarine)); setCategory(NotificationCompat.CATEGORY_MESSAGE); setContentTitle(context.getString(R.string.MessageNotifier_you_may_have_new_messages)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index 228934167..42f1bc16e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -62,8 +62,8 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil { super(new ContextThemeWrapper(context, R.style.TextSecure_LightTheme), privacy); - setSmallIcon(R.drawable.icon_notification); - setColor(context.getResources().getColor(R.color.textsecure_primary)); + setSmallIcon(R.drawable.ic_notification); + setColor(context.getResources().getColor(R.color.core_ultramarine)); setCategory(NotificationCompat.CATEGORY_MESSAGE); if (!NotificationChannels.supported()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberFormatter.java b/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberFormatter.java index 37e8ceb10..d3929c339 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberFormatter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberFormatter.java @@ -11,8 +11,8 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.Phonenumber; import com.google.i18n.phonenumbers.ShortNumberInfo; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.logging.Log; -import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.Pair; @@ -83,7 +83,7 @@ public class PhoneNumberFormatter { public String format(@Nullable String number) { if (number == null) return "Unknown"; - if (GroupUtil.isEncodedGroup(number)) return number; + if (GroupId.isEncodedGroup(number)) return number; if (ALPHA_PATTERN.matcher(number).find()) return number.trim(); String bareNumber = number.replaceAll("[^0-9+]", ""); diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java index a7cd846d2..fc88ec574 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java @@ -20,16 +20,23 @@ import org.thoughtcrime.securesms.PassphraseChangeActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; import org.thoughtcrime.securesms.crypto.MasterSecretUtil; +import org.thoughtcrime.securesms.database.Database; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob; import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; +import org.thoughtcrime.securesms.jobs.StorageSyncJob; import org.thoughtcrime.securesms.lock.RegistrationLockDialog; import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity; import org.thoughtcrime.securesms.lock.v2.PinUtil; +import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import java.util.Locale; import java.util.concurrent.TimeUnit; @@ -221,12 +228,16 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment private class ReadReceiptToggleListener implements Preference.OnPreferenceChangeListener { @Override public boolean onPreferenceChange(Preference preference, Object newValue) { - boolean enabled = (boolean)newValue; - ApplicationDependencies.getJobManager().add(new MultiDeviceConfigurationUpdateJob(enabled, - TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()), - TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()), - TextSecurePreferences.isLinkPreviewsEnabled(getContext()))); + SignalExecutors.BOUNDED.execute(() -> { + boolean enabled = (boolean)newValue; + DatabaseFactory.getRecipientDatabase(getContext()).markNeedsSync(Recipient.self().getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + ApplicationDependencies.getJobManager().add(new MultiDeviceConfigurationUpdateJob(enabled, + TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()), + TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()), + TextSecurePreferences.isLinkPreviewsEnabled(getContext()))); + }); return true; } } @@ -234,16 +245,19 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment private class TypingIndicatorsToggleListener implements Preference.OnPreferenceChangeListener { @Override public boolean onPreferenceChange(Preference preference, Object newValue) { - boolean enabled = (boolean)newValue; - ApplicationDependencies.getJobManager().add(new MultiDeviceConfigurationUpdateJob(TextSecurePreferences.isReadReceiptsEnabled(requireContext()), - enabled, - TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()), - TextSecurePreferences.isLinkPreviewsEnabled(getContext()))); - - if (!enabled) { - ApplicationContext.getInstance(requireContext()).getTypingStatusRepository().clear(); - } + SignalExecutors.BOUNDED.execute(() -> { + boolean enabled = (boolean)newValue; + DatabaseFactory.getRecipientDatabase(getContext()).markNeedsSync(Recipient.self().getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + ApplicationDependencies.getJobManager().add(new MultiDeviceConfigurationUpdateJob(TextSecurePreferences.isReadReceiptsEnabled(requireContext()), + enabled, + TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()), + TextSecurePreferences.isLinkPreviewsEnabled(getContext()))); + if (!enabled) { + ApplicationContext.getInstance(requireContext()).getTypingStatusRepository().clear(); + } + }); return true; } } @@ -251,12 +265,15 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment private class LinkPreviewToggleListener implements Preference.OnPreferenceChangeListener { @Override public boolean onPreferenceChange(Preference preference, Object newValue) { - boolean enabled = (boolean)newValue; - ApplicationDependencies.getJobManager().add(new MultiDeviceConfigurationUpdateJob(TextSecurePreferences.isReadReceiptsEnabled(requireContext()), - TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()), - TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(requireContext()), - enabled)); - + SignalExecutors.BOUNDED.execute(() -> { + boolean enabled = (boolean)newValue; + DatabaseFactory.getRecipientDatabase(getContext()).markNeedsSync(Recipient.self().getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + ApplicationDependencies.getJobManager().add(new MultiDeviceConfigurationUpdateJob(TextSecurePreferences.isReadReceiptsEnabled(requireContext()), + TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()), + TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(requireContext()), + enabled)); + }); return true; } } @@ -355,10 +372,14 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment @Override public boolean onPreferenceChange(Preference preference, Object newValue) { boolean enabled = (boolean) newValue; - ApplicationDependencies.getJobManager().add(new MultiDeviceConfigurationUpdateJob(TextSecurePreferences.isReadReceiptsEnabled(getContext()), - TextSecurePreferences.isTypingIndicatorsEnabled(getContext()), - enabled, - TextSecurePreferences.isLinkPreviewsEnabled(getContext()))); + SignalExecutors.BOUNDED.execute(() -> { + DatabaseFactory.getRecipientDatabase(getContext()).markNeedsSync(Recipient.self().getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + ApplicationDependencies.getJobManager().add(new MultiDeviceConfigurationUpdateJob(TextSecurePreferences.isReadReceiptsEnabled(getContext()), + TextSecurePreferences.isTypingIndicatorsEnabled(getContext()), + enabled, + TextSecurePreferences.isLinkPreviewsEnabled(getContext()))); + }); return true; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ContactPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ContactPreference.java index 727e4ae5b..121ea1de9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ContactPreference.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ContactPreference.java @@ -68,7 +68,7 @@ public class ContactPreference extends Preference { int color; if (secure) { - color = getContext().getResources().getColor(R.color.textsecure_primary); + color = getContext().getResources().getColor(R.color.core_ultramarine); } else { color = getContext().getResources().getColor(R.color.grey_600); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProfilePreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProfilePreference.java index 83e876523..c4bf1c474 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProfilePreference.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProfilePreference.java @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; public class ProfilePreference extends Preference { @@ -65,10 +66,10 @@ public class ProfilePreference extends Preference { if (profileSubtextView == null) return; final Recipient self = Recipient.self(); - final String profileName = TextSecurePreferences.getProfileName(getContext()).toString(); + final String profileName = Recipient.self().getProfileName().toString(); GlideApp.with(getContext().getApplicationContext()) - .load(new ProfileContactPhoto(self.getId(), String.valueOf(TextSecurePreferences.getProfileAvatarId(getContext())))) + .load(new ProfileContactPhoto(self, self.getProfileAvatar())) .error(new ResourceContactPhoto(R.drawable.ic_camera_solid_white_24).asDrawable(getContext(), getContext().getResources().getColor(R.color.grey_400))) .circleCrop() .diskCacheStrategy(DiskCacheStrategy.ALL) diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileRepository.java index 1d12d2110..dbfcce1aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileRepository.java @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.profiles.SystemProfileUtil; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.ProfileUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -47,7 +48,7 @@ class EditProfileRepository { } void getCurrentProfileName(@NonNull Consumer profileNameConsumer) { - ProfileName storedProfileName = TextSecurePreferences.getProfileName(context); + ProfileName storedProfileName = Recipient.self().getProfileName(); if (!storedProfileName.isEmpty()) { profileNameConsumer.accept(storedProfileName); } else if (!excludeSystem) { @@ -102,12 +103,10 @@ class EditProfileRepository { void uploadProfile(@NonNull ProfileName profileName, @Nullable byte[] avatar, @NonNull Consumer uploadResultConsumer) { SimpleTask.run(() -> { - TextSecurePreferences.setProfileName(context, profileName); DatabaseFactory.getRecipientDatabase(context).setProfileName(Recipient.self().getId(), profileName); try { AvatarHelper.setAvatar(context, Recipient.self().getId(), avatar); - TextSecurePreferences.setProfileAvatarId(context, new SecureRandom().nextInt()); } catch (IOException e) { return UploadResult.ERROR_FILE_IO; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java index d4d9b319b..8b584f81b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java @@ -19,7 +19,6 @@ import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; import org.thoughtcrime.securesms.logging.Log; -import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; @@ -198,7 +197,7 @@ public final class LiveRecipient { List members = Stream.of(groupRecord.get().getMembers()).filterNot(RecipientId::isUnknown).map(this::fetchRecipientFromDisk).toList(); Optional avatarId = Optional.absent(); - if (settings.getGroupId() != null && !GroupUtil.isMmsGroup(settings.getGroupId()) && title == null) { + if (settings.getGroupId() != null && !settings.getGroupId().isMmsGroup() && title == null) { title = unnamedGroupName; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index 0e652bd52..6d92dfdcc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode; import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.notifications.NotificationChannels; @@ -35,7 +36,6 @@ import org.thoughtcrime.securesms.phonenumbers.NumberUtil; import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.util.FeatureFlags; -import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.libsignal.util.guava.Preconditions; @@ -64,7 +64,7 @@ public class Recipient { private final String username; private final String e164; private final String email; - private final String groupId; + private final GroupId groupId; private final List participants; private final Optional groupAvatarId; private final boolean localNumber; @@ -93,7 +93,7 @@ public class Recipient { private final Capability uuidCapability; private final Capability groupsV2Capability; private final InsightsBannerTier insightsBannerTier; - private final byte[] storageKey; + private final byte[] storageId; private final byte[] identityKey; private final VerifiedStatus identityStatus; @@ -236,11 +236,7 @@ public class Recipient { * identifier is a groupId. */ @WorkerThread - public static @NonNull Recipient externalGroup(@NonNull Context context, @NonNull String groupId) { - if (!GroupUtil.isEncodedGroup(groupId)) { - throw new IllegalArgumentException("Invalid groupId!"); - } - + public static @NonNull Recipient externalGroup(@NonNull Context context, @NonNull GroupId groupId) { return Recipient.resolved(DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId)); } @@ -274,8 +270,8 @@ public class Recipient { throw new UuidRecipientError(); } } - } else if (GroupUtil.isEncodedGroup(identifier)) { - id = db.getOrInsertFromGroupId(identifier); + } else if (GroupId.isEncodedGroup(identifier)) { + id = db.getOrInsertFromGroupId(GroupId.parse(identifier)); } else if (NumberUtil.isValidEmail(identifier)) { id = db.getOrInsertFromEmail(identifier); } else { @@ -326,7 +322,7 @@ public class Recipient { this.forceSmsSelection = false; this.uuidCapability = Capability.UNKNOWN; this.groupsV2Capability = Capability.UNKNOWN; - this.storageKey = null; + this.storageId = null; this.identityKey = null; this.identityStatus = VerifiedStatus.DEFAULT; } @@ -367,7 +363,7 @@ public class Recipient { this.forceSmsSelection = details.forceSmsSelection; this.uuidCapability = details.uuidCapability; this.groupsV2Capability = details.groupsV2Capability; - this.storageKey = details.storageKey; + this.storageId = details.storageId; this.identityKey = details.identityKey; this.identityStatus = details.identityStatus; } @@ -385,7 +381,7 @@ public class Recipient { } public @Nullable String getName(@NonNull Context context) { - if (this.name == null && groupId != null && GroupUtil.isMmsGroup(groupId)) { + if (this.name == null && groupId != null && groupId.isMmsGroup()) { List names = new LinkedList<>(); for (Recipient recipient : participants) { @@ -443,7 +439,7 @@ public class Recipient { return Optional.fromNullable(email); } - public @NonNull Optional getGroupId() { + public @NonNull Optional getGroupId() { return Optional.fromNullable(groupId); } @@ -495,8 +491,8 @@ public class Recipient { return getUuid().isPresent(); } - public @NonNull String requireGroupId() { - String resolved = resolving ? resolve().groupId : groupId; + public @NonNull GroupId requireGroupId() { + GroupId resolved = resolving ? resolve().groupId : groupId; if (resolved == null) { throw new MissingAddressError(); @@ -532,7 +528,7 @@ public class Recipient { Recipient resolved = resolving ? resolve() : this; if (resolved.isGroup()) { - return resolved.requireGroupId(); + return resolved.requireGroupId().toString(); } else if (resolved.getUuid().isPresent()) { return resolved.getUuid().get().toString(); } @@ -570,13 +566,13 @@ public class Recipient { } public boolean isMmsGroup() { - String groupId = resolve().groupId; - return groupId != null && GroupUtil.isMmsGroup(groupId); + GroupId groupId = resolve().groupId; + return groupId != null && groupId.isMmsGroup(); } public boolean isPushGroup() { - String groupId = resolve().groupId; - return groupId != null && !GroupUtil.isMmsGroup(groupId); + GroupId groupId = resolve().groupId; + return groupId != null && !groupId.isMmsGroup(); } public @NonNull List getParticipants() { @@ -612,7 +608,7 @@ public class Recipient { if (localNumber) return null; else if (isGroupInternal() && groupAvatarId.isPresent()) return new GroupRecordContactPhoto(groupId, groupAvatarId.get()); else if (systemContactPhoto != null) return new SystemContactPhoto(id, systemContactPhoto, 0); - else if (profileAvatar != null) return new ProfileContactPhoto(id, profileAvatar); + else if (profileAvatar != null) return new ProfileContactPhoto(this, profileAvatar); else return null; } @@ -706,8 +702,8 @@ public class Recipient { return profileKeyCredential != null; } - public @Nullable byte[] getStorageServiceKey() { - return storageKey; + public @Nullable byte[] getStorageServiceId() { + return storageId; } public @NonNull VerifiedStatus getIdentityVerifiedStatus() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java index b4394dde2..c7938be0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode; import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -28,7 +29,7 @@ public class RecipientDetails { final String username; final String e164; final String email; - final String groupId; + final GroupId groupId; final String name; final String customLabel; final Uri systemContactPhoto; @@ -58,7 +59,7 @@ public class RecipientDetails { final Recipient.Capability uuidCapability; final Recipient.Capability groupsV2Capability; final InsightsBannerTier insightsBannerTier; - final byte[] storageKey; + final byte[] storageId; final byte[] identityKey; final VerifiedStatus identityStatus; @@ -88,7 +89,7 @@ public class RecipientDetails { this.blocked = settings.isBlocked(); this.expireMessages = settings.getExpireMessages(); this.participants = participants == null ? new LinkedList<>() : participants; - this.profileName = isLocalNumber ? TextSecurePreferences.getProfileName(context) : settings.getProfileName(); + this.profileName = settings.getProfileName(); this.defaultSubscriptionId = settings.getDefaultSubscriptionId(); this.registered = settings.getRegistered(); this.profileKey = settings.getProfileKey(); @@ -103,7 +104,7 @@ public class RecipientDetails { this.uuidCapability = settings.getUuidCapability(); this.groupsV2Capability = settings.getGroupsV2Capability(); this.insightsBannerTier = settings.getInsightsBannerTier(); - this.storageKey = settings.getStorageKey(); + this.storageId = settings.getStorageId(); this.identityKey = settings.getIdentityKey(); this.identityStatus = settings.getIdentityStatus(); @@ -149,7 +150,7 @@ public class RecipientDetails { this.name = null; this.uuidCapability = Recipient.Capability.UNKNOWN; this.groupsV2Capability = Recipient.Capability.UNKNOWN; - this.storageKey = null; + this.storageId = null; this.identityKey = null; this.identityStatus = VerifiedStatus.DEFAULT; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java index 18833b73d..256523a7b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java @@ -7,23 +7,26 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; +import com.annimon.stream.Stream; + import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; import org.thoughtcrime.securesms.jobs.LeaveGroupJob; import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob; import org.thoughtcrime.securesms.jobs.MultiDeviceMessageRequestResponseJob; import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.jobs.StorageSyncJob; import org.thoughtcrime.securesms.logging.Log; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.GroupUtil; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.push.SignalServiceAddress; @@ -81,13 +84,13 @@ public class RecipientUtil { leaveGroup(context, recipient); } - if (resolved.isSystemContact() || resolved.isProfileSharing()) { + if (resolved.isSystemContact() || resolved.isProfileSharing() || isProfileSharedViaGroup(context,resolved)) { ApplicationDependencies.getJobManager().add(new RotateProfileKeyJob()); DatabaseFactory.getRecipientDatabase(context).setProfileSharing(resolved.getId(), false); } ApplicationDependencies.getJobManager().add(new MultiDeviceBlockedUpdateJob()); - ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + StorageSyncHelper.scheduleSyncForDataChange(); } @WorkerThread @@ -98,7 +101,7 @@ public class RecipientUtil { DatabaseFactory.getRecipientDatabase(context).setBlocked(recipient.getId(), false); ApplicationDependencies.getJobManager().add(new MultiDeviceBlockedUpdateJob()); - ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + StorageSyncHelper.scheduleSyncForDataChange(); if (FeatureFlags.messageRequests()) { ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forAccept(recipient.getId())); @@ -121,7 +124,7 @@ public class RecipientUtil { ApplicationDependencies.getJobManager().add(LeaveGroupJob.create(recipient)); GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); - String groupId = resolved.requireGroupId(); + GroupId groupId = resolved.requireGroupId(); groupDatabase.setActive(groupId, false); groupDatabase.remove(groupId, Recipient.self().getId()); } else { @@ -229,4 +232,10 @@ public class RecipientUtil { private static boolean noSecureMessagesInThread(@NonNull Context context, long threadId) { return DatabaseFactory.getMmsSmsDatabase(context).getSecureConversationCount(threadId) == 0; } + + @WorkerThread + private static boolean isProfileSharedViaGroup(@NonNull Context context, @NonNull Recipient recipient) { + return Stream.of(DatabaseFactory.getGroupDatabase(context).getGroupsContainingMember(recipient.getId())) + .anyMatch(group -> Recipient.resolved(group.getRecipientId()).isProfileSharing()); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java index 7089f5e00..90c04f68d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java @@ -21,7 +21,7 @@ import com.dd.CircularProgressButton; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.jobs.StorageSyncJob; +import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.lock.v2.PinKeyboardType; import org.thoughtcrime.securesms.logging.Log; @@ -308,14 +308,14 @@ public final class RegistrationLockFragment extends BaseRegistrationFragment { if (FeatureFlags.storageServiceRestore()) { long startTime = System.currentTimeMillis(); SimpleTask.run(() -> { - return ApplicationDependencies.getJobManager().runSynchronously(new StorageSyncJob(), TimeUnit.SECONDS.toMillis(10)); + return ApplicationDependencies.getJobManager().runSynchronously(new StorageAccountRestoreJob(), StorageAccountRestoreJob.LIFESPAN); }, result -> { long elapsedTime = System.currentTimeMillis() - startTime; if (result.isPresent()) { - Log.i(TAG, "Storage Service restore completed: " + result.get().name() + ". (Took " + elapsedTime + " ms)"); + Log.i(TAG, "Storage Service account restore completed: " + result.get().name() + ". (Took " + elapsedTime + " ms)"); } else { - Log.i(TAG, "Storage Service restore failed to complete in the allotted time. (" + elapsedTime + " ms elapsed)"); + Log.i(TAG, "Storage Service account restore failed to complete in the allotted time. (" + elapsedTime + " ms elapsed)"); } cancelSpinning(pinButton); Navigation.findNavController(requireView()).navigate(RegistrationLockFragmentDirections.actionSuccessfulRegistration()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index 92cc62f44..26ae8debd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -50,8 +50,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu private static final String KEY_IMAGE_URI = "image_uri"; private static final String KEY_IS_AVATAR_MODE = "avatar_mode"; - private static final int SELECT_OLD_STICKER_REQUEST_CODE = 123; - private static final int SELECT_NEW_STICKER_REQUEST_CODE = 124; + private static final int SELECT_STICKER_REQUEST_CODE = 124; private EditorModel restoredModel; @@ -126,8 +125,6 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu imageMaxWidth = mediaConstraints.getImageMaxWidth(requireContext()); imageMaxHeight = mediaConstraints.getImageMaxHeight(requireContext()); - - StickerSearchRepository repository = new StickerSearchRepository(requireContext()); } @Nullable @@ -241,7 +238,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu String initialText = ""; int color = imageEditorHud.getActiveColor(); MultiLineTextRenderer renderer = new MultiLineTextRenderer(initialText, color); - EditorElement element = new EditorElement(renderer); + EditorElement element = new EditorElement(renderer, EditorModel.Z_TEXT); imageEditorView.getModel().addElementCentered(element, 1); imageEditorView.invalidate(); @@ -254,20 +251,11 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); - if (resultCode == RESULT_OK && requestCode == SELECT_NEW_STICKER_REQUEST_CODE && data != null) { + if (resultCode == RESULT_OK && requestCode == SELECT_STICKER_REQUEST_CODE && data != null) { final Uri uri = data.getData(); if (uri != null) { UriGlideRenderer renderer = new UriGlideRenderer(uri, true, imageMaxWidth, imageMaxHeight); - EditorElement element = new EditorElement(renderer); - imageEditorView.getModel().addElementCentered(element, 0.2f); - currentSelection = element; - imageEditorHud.setMode(ImageEditorHud.Mode.MOVE_DELETE); - } - } else if (resultCode == RESULT_OK && requestCode == SELECT_OLD_STICKER_REQUEST_CODE && data != null) { - final Uri uri = data.getData(); - if (uri != null) { - UriGlideRenderer renderer = new UriGlideRenderer(uri, false, imageMaxWidth, imageMaxHeight); - EditorElement element = new EditorElement(renderer); + EditorElement element = new EditorElement(renderer, EditorModel.Z_STICKERS); imageEditorView.getModel().addElementCentered(element, 0.2f); currentSelection = element; imageEditorHud.setMode(ImageEditorHud.Mode.MOVE_DELETE); @@ -307,7 +295,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu case INSERT_STICKER: { Intent intent = new Intent(getContext(), ImageEditorStickerSelectActivity.class); - startActivityForResult(intent, SELECT_NEW_STICKER_REQUEST_CODE); + startActivityForResult(intent, SELECT_STICKER_REQUEST_CODE); break; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ApplicationMigrationService.java b/app/src/main/java/org/thoughtcrime/securesms/service/ApplicationMigrationService.java index 2ac6442db..44b7b3665 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ApplicationMigrationService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ApplicationMigrationService.java @@ -128,8 +128,8 @@ public class ApplicationMigrationService extends Service private NotificationCompat.Builder initializeBackgroundNotification() { NotificationCompat.Builder builder = new NotificationCompat.Builder(this, NotificationChannels.OTHER); - builder.setSmallIcon(R.drawable.icon_notification); - builder.setLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.icon_notification)); + builder.setSmallIcon(R.drawable.ic_notification); + builder.setLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.ic_notification)); builder.setContentTitle(getString(R.string.ApplicationMigrationService_importing_text_messages)); builder.setContentText(getString(R.string.ApplicationMigrationService_import_in_progress)); builder.setOngoing(true); @@ -183,7 +183,7 @@ public class ApplicationMigrationService extends Service @Override public void onReceive(Context context, Intent intent) { NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NotificationChannels.OTHER); - builder.setSmallIcon(R.drawable.icon_notification); + builder.setSmallIcon(R.drawable.ic_notification); builder.setContentTitle(context.getString(R.string.ApplicationMigrationService_import_complete)); builder.setContentText(context.getString(R.string.ApplicationMigrationService_system_database_import_is_complete)); // TODO [greyson] Navigation diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java index e1d1f7280..5406c9c5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java @@ -67,8 +67,8 @@ public class UpdateApkReadyListener extends BroadcastReceiver { .setOngoing(true) .setContentTitle(context.getString(R.string.UpdateApkReadyListener_Signal_update)) .setContentText(context.getString(R.string.UpdateApkReadyListener_a_new_version_of_signal_is_available_tap_to_update)) - .setSmallIcon(R.drawable.icon_notification) - .setColor(context.getResources().getColor(R.color.textsecure_primary)) + .setSmallIcon(R.drawable.ic_notification) + .setColor(context.getResources().getColor(R.color.core_ultramarine)) .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_REMINDER) .setContentIntent(pendingIntent) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java index 1ece2adf5..f8cfc0dc6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java @@ -924,6 +924,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, callManager.setVideoEnable(enable); } catch (CallException e) { callFailure("setVideoEnable() failed: ", e); + return; } localCameraState = camera.getCameraState(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingTextMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingTextMessage.java index 89e7f3db8..ebf3d6c3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingTextMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingTextMessage.java @@ -7,6 +7,7 @@ import android.telephony.SmsMessage; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.push.SignalServiceAddress; @@ -28,19 +29,19 @@ public class IncomingTextMessage implements Parcelable { }; private static final String TAG = IncomingTextMessage.class.getSimpleName(); - private final String message; - private RecipientId sender; - private final int senderDeviceId; - private final int protocol; - private final String serviceCenterAddress; - private final boolean replyPathPresent; - private final String pseudoSubject; - private final long sentTimestampMillis; - private final String groupId; - private final boolean push; - private final int subscriptionId; - private final long expiresInMillis; - private final boolean unidentified; + private final String message; + private final RecipientId sender; + private final int senderDeviceId; + private final int protocol; + private final String serviceCenterAddress; + private final boolean replyPathPresent; + private final String pseudoSubject; + private final long sentTimestampMillis; + @Nullable private final GroupId groupId; + private final boolean push; + private final int subscriptionId; + private final long expiresInMillis; + private final boolean unidentified; public IncomingTextMessage(@NonNull RecipientId sender, @NonNull SmsMessage message, int subscriptionId) { this.message = message.getDisplayMessageBody(); @@ -59,7 +60,7 @@ public class IncomingTextMessage implements Parcelable { } public IncomingTextMessage(@NonNull RecipientId sender, int senderDeviceId, long sentTimestampMillis, - String encodedBody, Optional groupId, + String encodedBody, Optional groupId, long expiresInMillis, boolean unidentified) { this.message = encodedBody; @@ -86,7 +87,7 @@ public class IncomingTextMessage implements Parcelable { this.replyPathPresent = (in.readInt() == 1); this.pseudoSubject = in.readString(); this.sentTimestampMillis = in.readLong(); - this.groupId = in.readString(); + this.groupId = GroupId.parseNullable(in.readString()); this.push = (in.readInt() == 1); this.subscriptionId = in.readInt(); this.expiresInMillis = in.readLong(); @@ -131,7 +132,7 @@ public class IncomingTextMessage implements Parcelable { this.unidentified = fragments.get(0).isUnidentified(); } - protected IncomingTextMessage(@NonNull RecipientId sender, @Nullable String groupId) + protected IncomingTextMessage(@NonNull RecipientId sender, @Nullable GroupId groupId) { this.message = ""; this.sender = sender; @@ -216,7 +217,7 @@ public class IncomingTextMessage implements Parcelable { return push; } - public @Nullable String getGroupId() { + public @Nullable GroupId getGroupId() { return groupId; } @@ -259,7 +260,7 @@ public class IncomingTextMessage implements Parcelable { out.writeInt(replyPathPresent ? 1 : 0); out.writeString(pseudoSubject); out.writeLong(sentTimestampMillis); - out.writeString(groupId); + out.writeString(groupId == null ? null : groupId.toString()); out.writeInt(push ? 1 : 0); out.writeInt(subscriptionId); out.writeLong(expiresInMillis); diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementAdapter.java index 2886689ef..b602eb09f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementAdapter.java @@ -302,7 +302,7 @@ final class StickerManagementAdapter extends SectionedRecyclerViewAdapter> callback) { + public void searchByEmoji(@NonNull String emoji, @NonNull Callback> callback) { SignalExecutors.BOUNDED.execute(() -> { - Cursor cursor = stickerDatabase.getStickersByEmoji(emoji); + String searchEmoji = EmojiUtil.getCanonicalRepresentation(emoji); + List out = new ArrayList<>(); + Set possible = EmojiUtil.getAllRepresentations(searchEmoji); - if (cursor != null) { - callback.onResult(new CursorList<>(cursor, new StickerModelBuilder())); - } else { - callback.onResult(CursorList.emptyList()); + for (String candidate : possible) { + try (StickerRecordReader reader = new StickerRecordReader(stickerDatabase.getStickersByEmoji(candidate))) { + StickerRecord record = null; + while ((record = reader.getNext()) != null) { + out.add(record); + } + } } + + callback.onResult(out); }); } @@ -49,14 +62,7 @@ public final class StickerSearchRepository { private static class StickerModelBuilder implements CursorList.ModelBuilder { @Override public StickerRecord build(@NonNull Cursor cursor) { - return new StickerDatabase.StickerRecordReader(cursor).getCurrent(); - } - } - - private static class StickerPackModelBuilder implements CursorList.ModelBuilder { - @Override - public StickerPackRecord build(@NonNull Cursor cursor) { - return new StickerDatabase.StickerPackRecordReader(cursor).getCurrent(); + return new StickerRecordReader(cursor).getCurrent(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountConflictMerger.java b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountConflictMerger.java new file mode 100644 index 000000000..62dbf3613 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountConflictMerger.java @@ -0,0 +1,116 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.logging.Log; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.storage.SignalAccountRecord; +import org.whispersystems.signalservice.api.storage.SignalContactRecord; +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +class AccountConflictMerger implements StorageSyncHelper.ConflictMerger { + + private static final String TAG = Log.tag(AccountConflictMerger.class); + + private final Optional local; + + AccountConflictMerger(Optional local) { + this.local = local; + } + + @Override + public @NonNull Optional getMatching(@NonNull SignalAccountRecord record) { + return local; + } + + @Override + public @NonNull Collection getInvalidEntries(@NonNull Collection remoteRecords) { + Set invalid = new HashSet<>(remoteRecords); + if (remoteRecords.size() > 0) { + invalid.remove(remoteRecords.iterator().next()); + } + + if (invalid.size() > 0) { + Log.w(TAG, "Found invalid account entries! Count: " + invalid.size()); + } + + return invalid; + } + + @Override + public @NonNull SignalAccountRecord merge(@NonNull SignalAccountRecord remote, @NonNull SignalAccountRecord local, @NonNull StorageSyncHelper.KeyGenerator keyGenerator) { + String givenName; + String familyName; + + if (remote.getGivenName().isPresent() || remote.getFamilyName().isPresent()) { + givenName = remote.getGivenName().or(""); + familyName = remote.getFamilyName().or(""); + } else { + givenName = local.getGivenName().or(""); + familyName = local.getFamilyName().or(""); + } + + String avatarUrlPath = remote.getAvatarUrlPath().or(local.getAvatarUrlPath()).or(""); + byte[] profileKey = remote.getProfileKey().or(local.getProfileKey()).orNull(); + boolean noteToSelfArchived = remote.isNoteToSelfArchived(); + boolean readReceipts = remote.isReadReceiptsEnabled(); + boolean typingIndicators = remote.isTypingIndicatorsEnabled(); + boolean sealedSenderIndicators = remote.isSealedSenderIndicatorsEnabled(); + boolean linkPreviews = remote.isLinkPreviewsEnabled(); + boolean matchesRemote = doParamsMatch(remote, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews); + boolean matchesLocal = doParamsMatch(local, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews); + + if (matchesRemote) { + return remote; + } else if (matchesLocal) { + return local; + } else { + return new SignalAccountRecord.Builder(keyGenerator.generate()) + .setGivenName(givenName) + .setFamilyName(familyName) + .setAvatarUrlPath(avatarUrlPath) + .setProfileKey(profileKey) + .setNoteToSelfArchived(noteToSelfArchived) + .setReadReceiptsEnabled(readReceipts) + .setTypingIndicatorsEnabled(typingIndicators) + .setSealedSenderIndicatorsEnabled(sealedSenderIndicators) + .setLinkPreviewsEnabled(linkPreviews) + .build(); + } + } + + private static boolean doParamsMatch(@NonNull SignalAccountRecord contact, + @NonNull String givenName, + @NonNull String familyName, + @NonNull String avatarUrlPath, + @Nullable byte[] profileKey, + boolean noteToSelfArchived, + boolean readReceipts, + boolean typingIndicators, + boolean sealedSenderIndicators, + boolean linkPreviewsEnabled) + { + return Objects.equals(contact.getGivenName().or(""), givenName) && + Objects.equals(contact.getFamilyName().or(""), familyName) && + Objects.equals(contact.getAvatarUrlPath().or(""), avatarUrlPath) && + Arrays.equals(contact.getProfileKey().orNull(), profileKey) && + contact.isNoteToSelfArchived() == noteToSelfArchived && + contact.isReadReceiptsEnabled() == readReceipts && + contact.isTypingIndicatorsEnabled() == typingIndicators && + contact.isSealedSenderIndicatorsEnabled() == sealedSenderIndicators && + contact.isLinkPreviewsEnabled() == linkPreviewsEnabled; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/ContactConflictMerger.java b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactConflictMerger.java new file mode 100644 index 000000000..b121449b4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactConflictMerger.java @@ -0,0 +1,132 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; +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.internal.storage.protos.ContactRecord.IdentityState; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +class ContactConflictMerger implements StorageSyncHelper.ConflictMerger { + + private static final String TAG = Log.tag(ContactConflictMerger.class); + + private final Map localByUuid = new HashMap<>(); + private final Map localByE164 = new HashMap<>(); + + private final Recipient self; + + ContactConflictMerger(@NonNull Collection localOnly, @NonNull Recipient self) { + for (SignalContactRecord contact : localOnly) { + if (contact.getAddress().getUuid().isPresent()) { + localByUuid.put(contact.getAddress().getUuid().get(), contact); + } + if (contact.getAddress().getNumber().isPresent()) { + localByE164.put(contact.getAddress().getNumber().get(), contact); + } + } + + this.self = self.resolve(); + } + + @Override + public @NonNull Optional getMatching(@NonNull SignalContactRecord record) { + SignalContactRecord localUuid = record.getAddress().getUuid().isPresent() ? localByUuid.get(record.getAddress().getUuid().get()) : null; + SignalContactRecord localE164 = record.getAddress().getNumber().isPresent() ? localByE164.get(record.getAddress().getNumber().get()) : null; + + return Optional.fromNullable(localUuid).or(Optional.fromNullable(localE164)); + } + + @Override + public @NonNull Collection getInvalidEntries(@NonNull Collection remoteRecords) { + List invalid = Stream.of(remoteRecords) + .filter(r -> r.getAddress().getUuid().equals(self.getUuid()) || r.getAddress().getNumber().equals(self.getE164())) + .toList(); + if (invalid.size() > 0) { + Log.w(TAG, "Found invalid contact entries! Count: " + invalid.size()); + } + + return invalid; + } + + @Override + public @NonNull SignalContactRecord merge(@NonNull SignalContactRecord remote, @NonNull SignalContactRecord local, @NonNull StorageSyncHelper.KeyGenerator keyGenerator) { + String givenName; + String familyName; + + if (remote.getGivenName().isPresent() || remote.getFamilyName().isPresent()) { + givenName = remote.getGivenName().or(""); + familyName = remote.getFamilyName().or(""); + } else { + givenName = local.getGivenName().or(""); + familyName = local.getFamilyName().or(""); + } + + 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); + byte[] profileKey = remote.getProfileKey().or(local.getProfileKey()).orNull(); + String username = remote.getUsername().or(local.getUsername()).or(""); + IdentityState identityState = remote.getIdentityState(); + byte[] identityKey = remote.getIdentityKey().or(local.getIdentityKey()).orNull(); + boolean blocked = remote.isBlocked(); + boolean profileSharing = remote.isProfileSharingEnabled() || local.isProfileSharingEnabled(); + boolean archived = remote.isArchived(); + boolean matchesRemote = doParamsMatch(remote, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived); + boolean matchesLocal = doParamsMatch(local, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived); + + if (matchesRemote) { + return remote; + } else if (matchesLocal) { + return local; + } else { + return new SignalContactRecord.Builder(keyGenerator.generate(), address) + .setGivenName(givenName) + .setFamilyName(familyName) + .setProfileKey(profileKey) + .setUsername(username) + .setIdentityState(identityState) + .setIdentityKey(identityKey) + .setBlocked(blocked) + .setProfileSharingEnabled(profileSharing) + .build(); + } + } + + private static boolean doParamsMatch(@NonNull SignalContactRecord contact, + @NonNull SignalServiceAddress address, + @NonNull String givenName, + @NonNull String familyName, + @Nullable byte[] profileKey, + @NonNull String username, + @Nullable IdentityState identityState, + @Nullable byte[] identityKey, + boolean blocked, + boolean profileSharing, + boolean archived) + { + 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 && + contact.isArchived() == archived; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMerger.java b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMerger.java new file mode 100644 index 000000000..6a8e6e662 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMerger.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.groups.GroupId; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +class GroupV1ConflictMerger implements StorageSyncHelper.ConflictMerger { + + private final Map localByGroupId; + + GroupV1ConflictMerger(@NonNull Collection localOnly) { + localByGroupId = Stream.of(localOnly).collect(Collectors.toMap(g -> GroupId.v1(g.getGroupId()), g -> g)); + } + + @Override + public @NonNull Optional getMatching(@NonNull SignalGroupV1Record record) { + return Optional.fromNullable(localByGroupId.get(GroupId.v1(record.getGroupId()))); + } + + @Override + public @NonNull Collection getInvalidEntries(@NonNull Collection remoteRecords) { + return Collections.emptySet(); + } + + @Override + public @NonNull SignalGroupV1Record merge(@NonNull SignalGroupV1Record remote, @NonNull SignalGroupV1Record local, @NonNull StorageSyncHelper.KeyGenerator keyGenerator) { + boolean blocked = remote.isBlocked(); + boolean profileSharing = remote.isProfileSharingEnabled() || local.isProfileSharingEnabled(); + boolean archived = remote.isArchived(); + + boolean matchesRemote = blocked == remote.isBlocked() && profileSharing == remote.isProfileSharingEnabled() && archived == remote.isArchived(); + boolean matchesLocal = blocked == local.isBlocked() && profileSharing == local.isProfileSharingEnabled() && archived == local.isArchived(); + + if (matchesRemote) { + return remote; + } else if (matchesLocal) { + return local; + } else { + return new SignalGroupV1Record.Builder(keyGenerator.generate(), remote.getGroupId()) + .setBlocked(blocked) + .setProfileSharingEnabled(blocked) + .build(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2ConflictMerger.java b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2ConflictMerger.java new file mode 100644 index 000000000..a8f37dae1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2ConflictMerger.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.signal.zkgroup.groups.GroupMasterKey; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +class GroupV2ConflictMerger implements StorageSyncHelper.ConflictMerger { + + private final Map localByGroupId; + + GroupV2ConflictMerger(@NonNull Collection localOnly) { + localByGroupId = Stream.of(localOnly).collect(Collectors.toMap(SignalGroupV2Record::getMasterKey, g -> g)); + } + + @Override + public @NonNull Optional getMatching(@NonNull SignalGroupV2Record record) { + return Optional.fromNullable(localByGroupId.get(record.getMasterKey())); + } + + @Override + public @NonNull Collection getInvalidEntries(@NonNull Collection remoteRecords) { + return Collections.emptySet(); + } + + @Override + public @NonNull SignalGroupV2Record merge(@NonNull SignalGroupV2Record remote, @NonNull SignalGroupV2Record local, @NonNull StorageSyncHelper.KeyGenerator keyGenerator) { + boolean blocked = remote.isBlocked(); + boolean profileSharing = remote.isProfileSharingEnabled() || local.isProfileSharingEnabled(); + boolean archived = remote.isArchived(); + + boolean matchesRemote = blocked == remote.isBlocked() && profileSharing == remote.isProfileSharingEnabled() && archived == remote.isArchived(); + boolean matchesLocal = blocked == local.isBlocked() && profileSharing == local.isProfileSharingEnabled() && archived == local.isArchived(); + + if (matchesRemote) { + return remote; + } else if (matchesLocal) { + return local; + } else { + return new SignalGroupV2Record.Builder(keyGenerator.generate(), remote.getMasterKey()) + .setBlocked(blocked) + .setProfileSharingEnabled(blocked) + .build(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java new file mode 100644 index 000000000..12cc8b6ee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java @@ -0,0 +1,628 @@ +package org.thoughtcrime.securesms.storage; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob; +import org.thoughtcrime.securesms.jobs.StorageSyncJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +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.SetUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.storage.SignalAccountRecord; +import org.whispersystems.signalservice.api.storage.SignalContactRecord; +import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; +import org.whispersystems.signalservice.api.storage.SignalRecord; +import org.whispersystems.signalservice.api.storage.SignalStorageManifest; +import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.api.util.OptionalUtil; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collection; +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.concurrent.TimeUnit; + +import javax.crypto.KeyGenerator; + +public final class StorageSyncHelper { + + private static final String TAG = Log.tag(StorageSyncHelper.class); + + private static final KeyGenerator KEY_GENERATOR = () -> Util.getSecretBytes(16); + + private static KeyGenerator keyGenerator = KEY_GENERATOR; + + private static final long REFRESH_INTERVAL = TimeUnit.HOURS.toMillis(2); + + /** + * 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). + * + * @param currentManifestVersion What you think the version is locally. + * @param currentLocalKeys All local keys you have. This assumes that 'inserts' were given keys + * already, and that deletes still have keys. + * @param updates Contacts that have been altered. + * @param inserts Contacts that have been inserted (or newly marked as registered). + * @param deletes Contacts that are no longer registered. + * + * @return If changes need to be written, then it will return those changes. If no changes need + * to be written, this will return {@link Optional#absent()}. + */ + // TODO [greyson] [storage] Test this + public static @NonNull Optional buildStorageUpdatesForLocal(long currentManifestVersion, + @NonNull List currentLocalKeys, + @NonNull List updates, + @NonNull List inserts, + @NonNull List deletes, + @NonNull Optional accountUpdate, + @NonNull Optional accountInsert, + @NonNull Set archivedRecipients) + { + Set completeKeys = new LinkedHashSet<>(currentLocalKeys); + Set storageInserts = new LinkedHashSet<>(); + Set storageDeletes = new LinkedHashSet<>(); + Map storageKeyUpdates = new HashMap<>(); + + for (RecipientSettings insert : inserts) { + storageInserts.add(StorageSyncModels.localToRemoteRecord(insert, archivedRecipients)); + } + + if (accountInsert.isPresent()) { + storageInserts.add(SignalStorageRecord.forAccount(accountInsert.get())); + } + + for (RecipientSettings delete : deletes) { + byte[] key = Objects.requireNonNull(delete.getStorageId()); + storageDeletes.add(ByteBuffer.wrap(key)); + completeKeys.remove(StorageId.forContact(key)); + } + + for (RecipientSettings update : updates) { + byte[] oldKey = update.getStorageId(); + byte[] newKey = generateKey(); + + storageInserts.add(StorageSyncModels.localToRemoteRecord(update, newKey, archivedRecipients)); + storageDeletes.add(ByteBuffer.wrap(oldKey)); + completeKeys.remove(StorageId.forContact(oldKey)); + completeKeys.add(StorageId.forContact(newKey)); + storageKeyUpdates.put(update.getId(), newKey); + } + + if (accountUpdate.isPresent()) { + byte[] oldKey = accountUpdate.get().getId().getRaw(); + byte[] newKey = generateKey(); + + storageInserts.add(SignalStorageRecord.forAccount(StorageId.forAccount(newKey), accountUpdate.get())); + storageDeletes.add(ByteBuffer.wrap(oldKey)); + completeKeys.remove(StorageId.forAccount(oldKey)); + completeKeys.add(StorageId.forAccount(newKey)); + storageKeyUpdates.put(Recipient.self().getId(), newKey); + } + + if (storageInserts.isEmpty() && storageDeletes.isEmpty()) { + return Optional.absent(); + } else { + List contactDeleteBytes = Stream.of(storageDeletes).map(ByteBuffer::array).toList(); + List completeKeysBytes = new ArrayList<>(completeKeys); + SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, completeKeysBytes); + WriteOperationResult writeOperationResult = new WriteOperationResult(manifest, new ArrayList<>(storageInserts), contactDeleteBytes); + + return Optional.of(new LocalWriteResult(writeOperationResult, storageKeyUpdates)); + } + } + + /** + * Given a list of all the local and remote keys you know about, this will return a result telling + * you which keys are exclusively remote and which are exclusively local. + * + * @param remoteKeys All remote keys available. + * @param localKeys All local keys available. + * + * @return An object describing which keys are exclusive to the remote data set and which keys are + * exclusive to the local data set. + */ + public static @NonNull KeyDifferenceResult findKeyDifference(@NonNull Collection remoteKeys, + @NonNull Collection localKeys) + { + Set remoteOnlyKeys = SetUtil.difference(remoteKeys, localKeys); + Set localOnlyKeys = SetUtil.difference(localKeys, remoteKeys); + + return new KeyDifferenceResult(new ArrayList<>(remoteOnlyKeys), new ArrayList<>(localOnlyKeys)); + } + + /** + * Given two sets of storage records, this will resolve the data into a set of actions that need + * to be applied to resolve the differences. This will handle discovering which records between + * the two collections refer to the same contacts and are actually updates, which are brand new, + * etc. + * + * @param remoteOnlyRecords Records that are only present remotely. + * @param localOnlyRecords Records that are only present locally. + * + * @return A set of actions that should be applied to resolve the conflict. + */ + public static @NonNull MergeResult resolveConflict(@NonNull Collection remoteOnlyRecords, + @NonNull Collection localOnlyRecords) + { + List remoteOnlyContacts = Stream.of(remoteOnlyRecords).filter(r -> r.getContact().isPresent()).map(r -> r.getContact().get()).toList(); + List localOnlyContacts = Stream.of(localOnlyRecords).filter(r -> r.getContact().isPresent()).map(r -> r.getContact().get()).toList(); + + List remoteOnlyGroupV1 = Stream.of(remoteOnlyRecords).filter(r -> r.getGroupV1().isPresent()).map(r -> r.getGroupV1().get()).toList(); + List localOnlyGroupV1 = Stream.of(localOnlyRecords).filter(r -> r.getGroupV1().isPresent()).map(r -> r.getGroupV1().get()).toList(); + + // TODO [storage] Handle groupV2 when appropriate + List remoteOnlyUnknowns = Stream.of(remoteOnlyRecords).filter(r -> r.isUnknown() || r.getGroupV2().isPresent()).toList(); + List localOnlyUnknowns = Stream.of(localOnlyRecords).filter(r -> r.isUnknown() || r.getGroupV2().isPresent()).toList(); + + List remoteOnlyAccount = Stream.of(remoteOnlyRecords).filter(r -> r.getAccount().isPresent()).map(r -> r.getAccount().get()).toList(); + List localOnlyAccount = Stream.of(localOnlyRecords).filter(r -> r.getAccount().isPresent()).map(r -> r.getAccount().get()).toList(); + if (remoteOnlyAccount.size() > 0 && localOnlyAccount.isEmpty()) { + throw new AssertionError("Found a remote-only account, but no local-only account!"); + } + if (localOnlyAccount.size() > 1) { + throw new AssertionError("Multiple local accounts?"); + } + + RecordMergeResult contactMergeResult = resolveRecordConflict(remoteOnlyContacts, localOnlyContacts, new ContactConflictMerger(localOnlyContacts, Recipient.self())); + RecordMergeResult groupV1MergeResult = resolveRecordConflict(remoteOnlyGroupV1, localOnlyGroupV1, new GroupV1ConflictMerger(localOnlyGroupV1)); + RecordMergeResult accountMergeResult = resolveRecordConflict(remoteOnlyAccount, localOnlyAccount, new AccountConflictMerger(localOnlyAccount.isEmpty() ? Optional.absent() : Optional.of(localOnlyAccount.get(0)))); + + Set remoteInserts = new HashSet<>(); + remoteInserts.addAll(Stream.of(contactMergeResult.remoteInserts).map(SignalStorageRecord::forContact).toList()); + remoteInserts.addAll(Stream.of(groupV1MergeResult.remoteInserts).map(SignalStorageRecord::forGroupV1).toList()); + remoteInserts.addAll(Stream.of(accountMergeResult.remoteInserts).map(SignalStorageRecord::forAccount).toList()); + + Set> 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()); + remoteUpdates.addAll(Stream.of(accountMergeResult.remoteUpdates) + .map(c -> new RecordUpdate<>(SignalStorageRecord.forAccount(c.getOld()), SignalStorageRecord.forAccount(c.getNew()))) + .toList()); + + Set remoteDeletes = new HashSet<>(); + remoteDeletes.addAll(contactMergeResult.remoteDeletes); + remoteDeletes.addAll(groupV1MergeResult.remoteDeletes); + remoteDeletes.addAll(accountMergeResult.remoteDeletes); + + return new MergeResult(contactMergeResult.localInserts, + contactMergeResult.localUpdates, + groupV1MergeResult.localInserts, + groupV1MergeResult.localUpdates, + new LinkedHashSet<>(remoteOnlyUnknowns), + new LinkedHashSet<>(localOnlyUnknowns), + accountMergeResult.localUpdates.isEmpty() ? Optional.absent() : Optional.of(accountMergeResult.localUpdates.iterator().next()), + remoteInserts, + remoteUpdates, + remoteDeletes); + } + + /** + * Assumes that the merge result has *not* yet been applied to the local data. That means that + * this method will handle generating the correct final key set based on the merge result. + */ + public static @NonNull WriteOperationResult createWriteOperation(long currentManifestVersion, + @NonNull List currentLocalStorageKeys, + @NonNull MergeResult mergeResult) + { + Set completeKeys = new HashSet<>(currentLocalStorageKeys); + + completeKeys.addAll(Stream.of(mergeResult.getAllNewRecords()).map(SignalRecord::getId).toList()); + completeKeys.removeAll(Stream.of(mergeResult.getAllRemovedRecords()).map(SignalRecord::getId).toList()); + + SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, new ArrayList<>(completeKeys)); + + List inserts = new ArrayList<>(); + inserts.addAll(mergeResult.getRemoteInserts()); + inserts.addAll(Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getNew).toList()); + + List deletes = Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getOld).map(SignalStorageRecord::getId).map(StorageId::getRaw).toList(); + deletes.addAll(Stream.of(mergeResult.getRemoteDeletes()).map(SignalRecord::getId).map(StorageId::getRaw).toList()); + + return new WriteOperationResult(manifest, inserts, deletes); + } + + public static @NonNull byte[] generateKey() { + return keyGenerator.generate(); + } + + @VisibleForTesting + static void setTestKeyGenerator(@Nullable KeyGenerator testKeyGenerator) { + keyGenerator = testKeyGenerator; + } + + private static @NonNull RecordMergeResult resolveRecordConflict(@NonNull Collection remoteOnlyRecords, + @NonNull Collection localOnlyRecords, + @NonNull ConflictMerger merger) + { + Set localInserts = new HashSet<>(remoteOnlyRecords); + Set remoteInserts = new HashSet<>(localOnlyRecords); + Set> localUpdates = new HashSet<>(); + Set> remoteUpdates = new HashSet<>(); + Set remoteDeletes = new HashSet<>(merger.getInvalidEntries(remoteOnlyRecords)); + + remoteOnlyRecords.removeAll(remoteDeletes); + localInserts.removeAll(remoteDeletes); + + for (E remote : remoteOnlyRecords) { + Optional local = merger.getMatching(remote); + + if (local.isPresent()) { + E merged = merger.merge(remote, local.get(), keyGenerator); + + if (!merged.equals(remote)) { + remoteUpdates.add(new RecordUpdate<>(remote, merged)); + } + + if (!merged.equals(local.get())) { + localUpdates.add(new RecordUpdate<>(local.get(), merged)); + } + + localInserts.remove(remote); + remoteInserts.remove(local.get()); + } + } + + return new RecordMergeResult<>(localInserts, localUpdates, remoteInserts, remoteUpdates, remoteDeletes); + } + + public static boolean profileKeyChanged(RecordUpdate update) { + return !OptionalUtil.byteArrayEquals(update.getOld().getProfileKey(), update.getNew().getProfileKey()); + } + + public static Optional getPendingAccountSyncUpdate(@NonNull Context context) { + if (DatabaseFactory.getRecipientDatabase(context).getDirtyState(Recipient.self().getId()) != RecipientDatabase.DirtyState.UPDATE) { + return Optional.absent(); + } + return Optional.of(buildAccountRecord(context, null).getAccount().get()); + } + + public static Optional getPendingAccountSyncInsert(@NonNull Context context) { + if (DatabaseFactory.getRecipientDatabase(context).getDirtyState(Recipient.self().getId()) != RecipientDatabase.DirtyState.INSERT) { + return Optional.absent(); + } + return Optional.of(buildAccountRecord(context, null).getAccount().get()); + } + + public static SignalStorageRecord buildAccountRecord(@NonNull Context context, @Nullable StorageId id) { + Recipient self = Recipient.self().fresh(); + SignalAccountRecord account = new SignalAccountRecord.Builder(id != null ? id.getRaw() : self.getStorageServiceId()) + .setProfileKey(self.getProfileKey()) + .setGivenName(self.getProfileName().getGivenName()) + .setFamilyName(self.getProfileName().getFamilyName()) + .setAvatarUrlPath(self.getProfileAvatar()) + .setNoteToSelfArchived(DatabaseFactory.getThreadDatabase(context).isArchived(self.getId())) + .setTypingIndicatorsEnabled(TextSecurePreferences.isTypingIndicatorsEnabled(context)) + .setReadReceiptsEnabled(TextSecurePreferences.isReadReceiptsEnabled(context)) + .setSealedSenderIndicatorsEnabled(TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context)) + .setLinkPreviewsEnabled(TextSecurePreferences.isLinkPreviewsEnabled(context)) + .build(); + + return SignalStorageRecord.forAccount(account); + } + + public static void applyAccountStorageSyncUpdates(@NonNull Context context, Optional> update) { + if (!update.isPresent()) { + return; + } + applyAccountStorageSyncUpdates(context, update.get().getOld().getId(), update.get().getNew()); + } + + public static void applyAccountStorageSyncUpdates(@NonNull Context context, @NonNull StorageId storageId, @NonNull SignalAccountRecord update) { + DatabaseFactory.getRecipientDatabase(context).applyStorageSyncUpdates(storageId, update); + DatabaseFactory.getThreadDatabase(context).setArchived(Recipient.self().getId(), update.isNoteToSelfArchived()); + + TextSecurePreferences.setReadReceiptsEnabled(context, update.isReadReceiptsEnabled()); + TextSecurePreferences.setTypingIndicatorsEnabled(context, update.isTypingIndicatorsEnabled()); + TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, update.isSealedSenderIndicatorsEnabled()); + TextSecurePreferences.setLinkPreviewsEnabled(context, update.isLinkPreviewsEnabled()); + if (update.getAvatarUrlPath().isPresent()) { + ApplicationDependencies.getJobManager().add(new RetrieveProfileAvatarJob(Recipient.self(), update.getAvatarUrlPath().get())); + } + } + + public static void scheduleSyncForDataChange() { + if (!SignalStore.registrationValues().isRegistrationComplete()) { + Log.d(TAG, "Registration still ongoing. Ignore sync request."); + return; + } + ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + } + + public static void scheduleRoutineSync() { + long timeSinceLastSync = System.currentTimeMillis() - SignalStore.storageServiceValues().getLastSyncTime(); + + if (timeSinceLastSync > REFRESH_INTERVAL) { + Log.d(TAG, "Scheduling a sync. Last sync was " + timeSinceLastSync + " ms ago."); + scheduleSyncForDataChange(); + } else { + Log.d(TAG, "No need for sync. Last sync was " + timeSinceLastSync + " ms ago."); + } + } + + public static final class KeyDifferenceResult { + private final List remoteOnlyKeys; + private final List localOnlyKeys; + + private KeyDifferenceResult(@NonNull List remoteOnlyKeys, + @NonNull List localOnlyKeys) + { + this.remoteOnlyKeys = remoteOnlyKeys; + this.localOnlyKeys = localOnlyKeys; + } + + public @NonNull List getRemoteOnlyKeys() { + return remoteOnlyKeys; + } + + public @NonNull List getLocalOnlyKeys() { + return localOnlyKeys; + } + + public boolean isEmpty() { + return remoteOnlyKeys.isEmpty() && localOnlyKeys.isEmpty(); + } + } + + public static final class MergeResult { + private final Set localContactInserts; + private final Set> localContactUpdates; + private final Set localGroupV1Inserts; + private final Set> localGroupV1Updates; + private final Set localUnknownInserts; + private final Set localUnknownDeletes; + private final Optional> localAccountUpdate; + private final Set remoteInserts; + private final Set> remoteUpdates; + private final Set remoteDeletes; + + @VisibleForTesting + MergeResult(@NonNull Set localContactInserts, + @NonNull Set> localContactUpdates, + @NonNull Set localGroupV1Inserts, + @NonNull Set> localGroupV1Updates, + @NonNull Set localUnknownInserts, + @NonNull Set localUnknownDeletes, + @NonNull Optional> localAccountUpdate, + @NonNull Set remoteInserts, + @NonNull Set> remoteUpdates, + @NonNull Set remoteDeletes) + { + this.localContactInserts = localContactInserts; + this.localContactUpdates = localContactUpdates; + this.localGroupV1Inserts = localGroupV1Inserts; + this.localGroupV1Updates = localGroupV1Updates; + this.localUnknownInserts = localUnknownInserts; + this.localUnknownDeletes = localUnknownDeletes; + this.localAccountUpdate = localAccountUpdate; + this.remoteInserts = remoteInserts; + this.remoteUpdates = remoteUpdates; + this.remoteDeletes = remoteDeletes; + } + + public @NonNull Set getLocalContactInserts() { + return localContactInserts; + } + + public @NonNull Set> getLocalContactUpdates() { + return localContactUpdates; + } + + public @NonNull Set getLocalGroupV1Inserts() { + return localGroupV1Inserts; + } + + public @NonNull Set> getLocalGroupV1Updates() { + return localGroupV1Updates; + } + + public @NonNull Set getLocalUnknownInserts() { + return localUnknownInserts; + } + + public @NonNull Set getLocalUnknownDeletes() { + return localUnknownDeletes; + } + + public @NonNull Optional> getLocalAccountUpdate() { + return localAccountUpdate; + } + + public @NonNull Set getRemoteInserts() { + return remoteInserts; + } + + public @NonNull Set> getRemoteUpdates() { + return remoteUpdates; + } + + public @NonNull Set getRemoteDeletes() { + return remoteDeletes; + } + + @NonNull Set getAllNewRecords() { + Set records = new HashSet<>(); + + records.addAll(localContactInserts); + records.addAll(localGroupV1Inserts); + records.addAll(remoteInserts); + records.addAll(localUnknownInserts); + records.addAll(Stream.of(localContactUpdates).map(RecordUpdate::getNew).toList()); + records.addAll(Stream.of(localGroupV1Updates).map(RecordUpdate::getNew).toList()); + records.addAll(Stream.of(remoteUpdates).map(RecordUpdate::getNew).toList()); + if (localAccountUpdate.isPresent()) records.add(localAccountUpdate.get().getNew()); + + return records; + } + + @NonNull Set getAllRemovedRecords() { + Set records = new HashSet<>(); + + records.addAll(localUnknownDeletes); + records.addAll(Stream.of(localContactUpdates).map(RecordUpdate::getOld).toList()); + records.addAll(Stream.of(localGroupV1Updates).map(RecordUpdate::getOld).toList()); + records.addAll(Stream.of(remoteUpdates).map(RecordUpdate::getOld).toList()); + records.addAll(remoteDeletes); + if (localAccountUpdate.isPresent()) records.add(localAccountUpdate.get().getOld()); + + return records; + } + + @Override + public @NonNull String toString() { + return String.format(Locale.ENGLISH, + "localContactInserts: %d, localContactUpdates: %d, localGroupV1Inserts: %d, localGroupV1Updates: %d, localUnknownInserts: %d, localUnknownDeletes: %d, localAccountUpdate: %b, remoteInserts: %d, remoteUpdates: %d", + localContactInserts.size(), localContactUpdates.size(), localGroupV1Inserts.size(), localGroupV1Updates.size(), localUnknownInserts.size(), localUnknownDeletes.size(), localAccountUpdate.isPresent(), remoteInserts.size(), remoteUpdates.size()); + } + } + + public static final class WriteOperationResult { + private final SignalStorageManifest manifest; + private final List inserts; + private final List deletes; + + private WriteOperationResult(@NonNull SignalStorageManifest manifest, + @NonNull List inserts, + @NonNull List deletes) + { + this.manifest = manifest; + this.inserts = inserts; + this.deletes = deletes; + } + + public @NonNull SignalStorageManifest getManifest() { + return manifest; + } + + public @NonNull List getInserts() { + return inserts; + } + + public @NonNull List 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.getStorageIds().size(), + inserts.size(), + deletes.size()); + } + } + + public static class LocalWriteResult { + private final WriteOperationResult writeResult; + private final Map storageKeyUpdates; + + private LocalWriteResult(WriteOperationResult writeResult, Map storageKeyUpdates) { + this.writeResult = writeResult; + this.storageKeyUpdates = storageKeyUpdates; + } + + public @NonNull WriteOperationResult getWriteResult() { + return writeResult; + } + + public @NonNull Map getStorageKeyUpdates() { + return storageKeyUpdates; + } + } + + public static class RecordUpdate { + private final E oldRecord; + private final E newRecord; + + RecordUpdate(@NonNull E oldRecord, @NonNull E newRecord) { + this.oldRecord = oldRecord; + this.newRecord = newRecord; + } + + public @NonNull E getOld() { + return oldRecord; + } + + public @NonNull E 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); + } + } + + private static class RecordMergeResult { + final Set localInserts; + final Set> localUpdates; + final Set remoteInserts; + final Set> remoteUpdates; + final Set remoteDeletes; + + RecordMergeResult(@NonNull Set localInserts, + @NonNull Set> localUpdates, + @NonNull Set remoteInserts, + @NonNull Set> remoteUpdates, + @NonNull Set remoteDeletes) + { + this.localInserts = localInserts; + this.localUpdates = localUpdates; + this.remoteInserts = remoteInserts; + this.remoteUpdates = remoteUpdates; + this.remoteDeletes = remoteDeletes; + } + } + + interface ConflictMerger { + @NonNull Optional getMatching(@NonNull E record); + @NonNull Collection getInvalidEntries(@NonNull Collection remoteRecords); + @NonNull E merge(@NonNull E remote, @NonNull E local, @NonNull KeyGenerator keyGenerator); + } + + interface KeyGenerator { + @NonNull byte[] generate(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java new file mode 100644 index 000000000..4ea3a416a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; +import org.thoughtcrime.securesms.recipients.RecipientId; +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.SignalStorageRecord; +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState; + +import java.util.Set; + +public final class StorageSyncModels { + + private StorageSyncModels() {} + + public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings, @NonNull Set archived) { + if (settings.getStorageId() == null) { + throw new AssertionError("Must have a storage key!"); + } + + return localToRemoteRecord(settings, settings.getStorageId(), archived); + } + + public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings, @NonNull byte[] rawStorageId, @NonNull Set archived) { + switch (settings.getGroupType()) { + case NONE: return SignalStorageRecord.forContact(localToRemoteContact(settings, rawStorageId, archived)); + case SIGNAL_V1: return SignalStorageRecord.forGroupV1(localToRemoteGroupV1(settings, rawStorageId, archived)); + default: throw new AssertionError("Unsupported type!"); + } + } + + private static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientSettings recipient, byte[] rawStorageId, @NonNull Set archived) { + if (recipient.getUuid() == null && recipient.getE164() == null) { + throw new AssertionError("Must have either a UUID or a phone number!"); + } + + return new SignalContactRecord.Builder(rawStorageId, new SignalServiceAddress(recipient.getUuid(), recipient.getE164())) + .setProfileKey(recipient.getProfileKey()) + .setGivenName(recipient.getProfileName().getGivenName()) + .setFamilyName(recipient.getProfileName().getFamilyName()) + .setBlocked(recipient.isBlocked()) + .setProfileSharingEnabled(recipient.isProfileSharing()) + .setIdentityKey(recipient.getIdentityKey()) + .setIdentityState(localToRemoteIdentityState(recipient.getIdentityStatus())) + .setArchived(archived.contains(recipient.getId())) + .build(); + } + + private static @NonNull SignalGroupV1Record localToRemoteGroupV1(@NonNull RecipientSettings recipient, byte[] rawStorageId, @NonNull Set archived) { + if (recipient.getGroupId() == null) { + throw new AssertionError("Must have a groupId!"); + } + + return new SignalGroupV1Record.Builder(rawStorageId, recipient.getGroupId().getDecodedId()) + .setBlocked(recipient.isBlocked()) + .setProfileSharingEnabled(recipient.isProfileSharing()) + .setArchived(archived.contains(recipient.getId())) + .build(); + } + + public static @NonNull IdentityDatabase.VerifiedStatus remoteToLocalIdentityStatus(@NonNull IdentityState identityState) { + switch (identityState) { + case VERIFIED: return IdentityDatabase.VerifiedStatus.VERIFIED; + case UNVERIFIED: return IdentityDatabase.VerifiedStatus.UNVERIFIED; + default: return IdentityDatabase.VerifiedStatus.DEFAULT; + } + } + + private static IdentityState localToRemoteIdentityState(@NonNull IdentityDatabase.VerifiedStatus local) { + switch (local) { + case VERIFIED: return IdentityState.VERIFIED; + case UNVERIFIED: return IdentityState.UNVERIFIED; + default: return IdentityState.DEFAULT; + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java new file mode 100644 index 000000000..f1e5ac42b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.Base64; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; + +import java.util.HashSet; +import java.util.Set; + +public final class StorageSyncValidations { + + private StorageSyncValidations() {} + + public static void validate(@NonNull StorageSyncHelper.WriteOperationResult result) { + Set allSet = new HashSet<>(result.getManifest().getStorageIds()); + Set insertSet = new HashSet<>(Stream.of(result.getInserts()).map(SignalStorageRecord::getId).toList()); + + int accountCount = 0; + for (StorageId id : result.getManifest().getStorageIds()) { + accountCount += id.getType() == ManifestRecord.Identifier.Type.ACCOUNT_VALUE ? 1 : 0; + } + + if (result.getInserts().size() > insertSet.size()) { + throw new DuplicateInsertInWriteError(); + } + + if (accountCount > 1) { + throw new MultipleAccountError(); + } + + if (accountCount == 0) { + throw new MissingAccountError(); + } + + for (SignalStorageRecord insert : result.getInserts()) { + if (!allSet.contains(insert.getId())) { + throw new InsertNotPresentInFullIdSetError(); + } + + if (insert.isUnknown()) { + throw new UnknownInsertError(); + } + + if (insert.getContact().isPresent()) { + Recipient self = Recipient.self().fresh(); + SignalServiceAddress address = insert.getContact().get().getAddress(); + if (self.getE164().get().equals(address.getNumber().or("")) || self.getUuid().get().equals(address.getUuid().orNull())) { + throw new SelfAddedAsContactError(); + } + } + } + + if (result.getDeletes().size() > 0) { + Set allSetEncoded = Stream.of(result.getManifest().getStorageIds()).map(StorageId::getRaw).map(Base64::encodeBytes).collect(Collectors.toSet()); + + for (byte[] delete : result.getDeletes()) { + String encoded = Base64.encodeBytes(delete); + if (allSetEncoded.contains(encoded)) { + throw new DeletePresentInFullIdSetError(); + } + } + } + } + + private static final class DuplicateInsertInWriteError extends Error { + } + + private static final class InsertNotPresentInFullIdSetError extends Error { + } + + private static final class DeletePresentInFullIdSetError extends Error { + } + + private static final class UnknownInsertError extends Error { + } + + private static final class MultipleAccountError extends Error { + } + + private static final class MissingAccountError extends Error { + } + + private static final class SelfAddedAsContactError extends Error { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java index e726257ec..2028013de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java @@ -5,6 +5,7 @@ import android.graphics.drawable.Drawable; import android.text.TextUtils; import android.widget.ImageView; +import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import androidx.core.graphics.drawable.IconCompat; @@ -43,15 +44,24 @@ public final class AvatarUtil { } } + public static GlideRequest getSelfAvatarOrFallbackIcon(@NonNull Context context, @DrawableRes int fallbackIcon) { + return GlideApp.with(context) + .asDrawable() + .load(new ProfileContactPhoto(Recipient.self(), Recipient.self().getProfileAvatar())) + .error(fallbackIcon) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.ALL); + } + private static GlideRequest request(@NonNull GlideRequest glideRequest, @NonNull Context context, @NonNull Recipient recipient) { - return glideRequest.load(new ProfileContactPhoto(recipient.getId(), String.valueOf(TextSecurePreferences.getProfileAvatarId(context)))) + return glideRequest.load(new ProfileContactPhoto(recipient, recipient.getProfileAvatar())) .error(getFallback(context, recipient)) .circleCrop() .diskCacheStrategy(DiskCacheStrategy.ALL); } private static Drawable getFallback(@NonNull Context context, @NonNull Recipient recipient) { - String name = Optional.fromNullable(recipient.getDisplayName(context)).or(Optional.fromNullable(TextSecurePreferences.getProfileName(context).toString())).or(""); + String name = Optional.fromNullable(recipient.getDisplayName(context)).or(""); MaterialColor fallbackColor = recipient.getColor(); if (fallbackColor == ContactColors.UNKNOWN_COLOR && !TextUtils.isEmpty(name)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java new file mode 100644 index 000000000..f03b27e3c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.util; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; + +public final class DrawableUtil { + + private DrawableUtil() {} + + public static @NonNull Bitmap toBitmap(@NonNull Drawable drawable, int width, int height) { + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + + return bitmap; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java index 3f0b2abcc..710408690 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java @@ -11,6 +11,7 @@ import com.google.protobuf.ByteString; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage; import org.thoughtcrime.securesms.recipients.Recipient; @@ -26,43 +27,16 @@ import java.util.List; import static org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; -public class GroupUtil { +public final class GroupUtil { - private static final String ENCODED_SIGNAL_GROUP_PREFIX = "__textsecure_group__!"; - private static final String ENCODED_MMS_GROUP_PREFIX = "__signal_mms_group__!"; - private static final String TAG = GroupUtil.class.getSimpleName(); - - public static String getEncodedId(byte[] groupId, boolean mms) { - return (mms ? ENCODED_MMS_GROUP_PREFIX : ENCODED_SIGNAL_GROUP_PREFIX) + Hex.toStringCondensed(groupId); + private GroupUtil() { } - public static byte[] getDecodedId(String groupId) throws IOException { - if (!isEncodedGroup(groupId)) { - throw new IOException("Invalid encoding"); - } - - return Hex.fromStringCondensed(groupId.split("!", 2)[1]); - } - - public static byte[] getDecodedIdOrThrow(String groupId) { - try { - return getDecodedId(groupId); - } catch (IOException e) { - throw new AssertionError(e); - } - } - - public static boolean isEncodedGroup(@NonNull String groupId) { - return groupId.startsWith(ENCODED_SIGNAL_GROUP_PREFIX) || groupId.startsWith(ENCODED_MMS_GROUP_PREFIX); - } - - public static boolean isMmsGroup(@NonNull String groupId) { - return groupId.startsWith(ENCODED_MMS_GROUP_PREFIX); - } + private static final String TAG = Log.tag(GroupUtil.class); @WorkerThread public static Optional createGroupLeaveMessage(@NonNull Context context, @NonNull Recipient groupRecipient) { - String encodedGroupId = groupRecipient.requireGroupId(); + GroupId encodedGroupId = groupRecipient.requireGroupId(); GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); if (!groupDatabase.isActive(encodedGroupId)) { @@ -70,13 +44,7 @@ public class GroupUtil { return Optional.absent(); } - ByteString decodedGroupId; - try { - decodedGroupId = ByteString.copyFrom(getDecodedId(encodedGroupId)); - } catch (IOException e) { - Log.w(TAG, "Failed to decode group ID.", e); - return Optional.absent(); - } + ByteString decodedGroupId = ByteString.copyFrom(encodedGroupId.getDecodedId()); GroupContext groupContext = GroupContext.newBuilder() .setId(decodedGroupId) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IdentityUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/IdentityUtil.java index 0313bbdc1..7acfe7eae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/IdentityUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IdentityUtil.java @@ -66,33 +66,35 @@ public class IdentityUtil { public static void markIdentityVerified(Context context, Recipient recipient, boolean verified, boolean remote) { - long time = System.currentTimeMillis(); - SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); - GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); - GroupDatabase.Reader reader = groupDatabase.getGroups(); + long time = System.currentTimeMillis(); + SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); - GroupDatabase.GroupRecord groupRecord; + try (GroupDatabase.Reader reader = groupDatabase.getGroups()) { - while ((groupRecord = reader.getNext()) != null) { - if (groupRecord.getMembers().contains(recipient.getId()) && groupRecord.isActive() && !groupRecord.isMms()) { + GroupDatabase.GroupRecord groupRecord; - if (remote) { - IncomingTextMessage incoming = new IncomingTextMessage(recipient.getId(), 1, time, null, Optional.of(groupRecord.getEncodedId()), 0, false); + while ((groupRecord = reader.getNext()) != null) { + if (groupRecord.getMembers().contains(recipient.getId()) && groupRecord.isActive() && !groupRecord.isMms()) { - if (verified) incoming = new IncomingIdentityVerifiedMessage(incoming); - else incoming = new IncomingIdentityDefaultMessage(incoming); + if (remote) { + IncomingTextMessage incoming = new IncomingTextMessage(recipient.getId(), 1, time, null, Optional.of(groupRecord.getId()), 0, false); - smsDatabase.insertMessageInbox(incoming); - } else { - RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupRecord.getEncodedId()); - Recipient groupRecipient = Recipient.resolved(recipientId); - long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); - OutgoingTextMessage outgoing ; + if (verified) incoming = new IncomingIdentityVerifiedMessage(incoming); + else incoming = new IncomingIdentityDefaultMessage(incoming); - if (verified) outgoing = new OutgoingIdentityVerifiedMessage(recipient); - else outgoing = new OutgoingIdentityDefaultMessage(recipient); + smsDatabase.insertMessageInbox(incoming); + } else { + RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupRecord.getId()); + Recipient groupRecipient = Recipient.resolved(recipientId); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); + OutgoingTextMessage outgoing ; - DatabaseFactory.getSmsDatabase(context).insertMessageOutbox(threadId, outgoing, false, time, null); + if (verified) outgoing = new OutgoingIdentityVerifiedMessage(recipient); + else outgoing = new OutgoingIdentityDefaultMessage(recipient); + + DatabaseFactory.getSmsDatabase(context).insertMessageOutbox(threadId, outgoing, false, time, null); + } } } } @@ -127,7 +129,7 @@ public class IdentityUtil { while ((groupRecord = reader.getNext()) != null) { if (groupRecord.getMembers().contains(recipient.getId()) && groupRecord.isActive()) { - IncomingTextMessage incoming = new IncomingTextMessage(recipient.getId(), 1, time, null, Optional.of(groupRecord.getEncodedId()), 0, false); + IncomingTextMessage incoming = new IncomingTextMessage(recipient.getId(), 1, time, null, Optional.of(groupRecord.getId()), 0, false); IncomingIdentityUpdateMessage groupUpdate = new IncomingIdentityUpdateMessage(incoming); smsDatabase.insertMessageInbox(groupUpdate); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java index 87437d54f..1624b411a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -434,22 +434,6 @@ public class TextSecurePreferences { setBooleanPreference(context, GIF_GRID_LAYOUT, isGrid); } - public static void setProfileName(Context context, ProfileName name) { - setStringPreference(context, PROFILE_NAME_PREF, name.serialize()); - } - - public static ProfileName getProfileName(Context context) { - return ProfileName.fromSerialized(getStringPreference(context, PROFILE_NAME_PREF, null)); - } - - public static void setProfileAvatarId(Context context, int id) { - setIntegerPrefrence(context, PROFILE_AVATAR_ID_PREF, id); - } - - public static int getProfileAvatarId(Context context) { - return getIntegerPreference(context, PROFILE_AVATAR_ID_PREF, 0); - } - public static int getNotificationPriority(Context context) { return Integer.valueOf(getStringPreference(context, NOTIFICATION_PRIORITY_PREF, String.valueOf(NotificationCompat.PRIORITY_HIGH))); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java index 06c3fd39a..01ed44172 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -39,6 +39,7 @@ import android.text.SpannableString; import android.text.TextUtils; import android.text.style.StyleSpan; +import com.annimon.stream.Stream; import com.google.android.mms.pdu_alt.CharacterSets; import com.google.android.mms.pdu_alt.EncodedStringValue; import com.google.i18n.phonenumbers.NumberParseException; @@ -595,11 +596,13 @@ public class Util { return handler; } - public static List concatenatedList(List first, List second) { - final List concat = new ArrayList<>(first.size() + second.size()); + @SafeVarargs + public static List concatenatedList(Collection ... items) { + final List concat = new ArrayList<>(Stream.of(items).reduce(0, (sum, list) -> sum + list.size())); - concat.addAll(first); - concat.addAll(second); + for (Collection list : items) { + concat.addAll(list); + } return concat; } diff --git a/app/src/main/res/color/help_fragment_next_dark.xml b/app/src/main/res/color/help_fragment_next_dark.xml index d210aa922..8fd6e060b 100644 --- a/app/src/main/res/color/help_fragment_next_dark.xml +++ b/app/src/main/res/color/help_fragment_next_dark.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/color/help_fragment_next_light.xml b/app/src/main/res/color/help_fragment_next_light.xml index 6ecdc9eb3..84421ed12 100644 --- a/app/src/main/res/color/help_fragment_next_light.xml +++ b/app/src/main/res/color/help_fragment_next_light.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/icon_notification.webp b/app/src/main/res/drawable-hdpi/icon_notification.webp deleted file mode 100644 index 819e144ff..000000000 Binary files a/app/src/main/res/drawable-hdpi/icon_notification.webp and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/icon_transparent.webp b/app/src/main/res/drawable-hdpi/icon_transparent.webp deleted file mode 100644 index e9936f254..000000000 Binary files a/app/src/main/res/drawable-hdpi/icon_transparent.webp and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/profile_splash.webp b/app/src/main/res/drawable-hdpi/profile_splash.webp deleted file mode 100644 index 92507cc4d..000000000 Binary files a/app/src/main/res/drawable-hdpi/profile_splash.webp and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/video_splash.webp b/app/src/main/res/drawable-hdpi/video_splash.webp deleted file mode 100644 index 4e618aaa3..000000000 Binary files a/app/src/main/res/drawable-hdpi/video_splash.webp and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/icon_notification.webp b/app/src/main/res/drawable-mdpi/icon_notification.webp deleted file mode 100644 index 76e4af9b9..000000000 Binary files a/app/src/main/res/drawable-mdpi/icon_notification.webp and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/icon_transparent.webp b/app/src/main/res/drawable-mdpi/icon_transparent.webp deleted file mode 100644 index 2e92a8b49..000000000 Binary files a/app/src/main/res/drawable-mdpi/icon_transparent.webp and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/profile_megaphone.png b/app/src/main/res/drawable-mdpi/profile_megaphone.png deleted file mode 100644 index a5d9b3133..000000000 Binary files a/app/src/main/res/drawable-mdpi/profile_megaphone.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/profile_splash.webp b/app/src/main/res/drawable-mdpi/profile_splash.webp deleted file mode 100644 index 829f11ef2..000000000 Binary files a/app/src/main/res/drawable-mdpi/profile_splash.webp and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/video_splash.webp b/app/src/main/res/drawable-mdpi/video_splash.webp deleted file mode 100644 index 49fce437d..000000000 Binary files a/app/src/main/res/drawable-mdpi/video_splash.webp and /dev/null differ diff --git a/app/src/main/res/drawable-v21/camera_send_button_background.xml b/app/src/main/res/drawable-v21/camera_send_button_background.xml index bc0ce271c..df91cfd22 100644 --- a/app/src/main/res/drawable-v21/camera_send_button_background.xml +++ b/app/src/main/res/drawable-v21/camera_send_button_background.xml @@ -11,7 +11,7 @@ - + diff --git a/app/src/main/res/drawable-v21/conversation_list_item_background.xml b/app/src/main/res/drawable-v21/conversation_list_item_background.xml index 642879178..6ef38ac5e 100644 --- a/app/src/main/res/drawable-v21/conversation_list_item_background.xml +++ b/app/src/main/res/drawable-v21/conversation_list_item_background.xml @@ -1,10 +1,10 @@ + android:color="@color/core_ultramarine"> - + diff --git a/app/src/main/res/drawable-v21/conversation_list_item_background_dark.xml b/app/src/main/res/drawable-v21/conversation_list_item_background_dark.xml index 642879178..6ef38ac5e 100644 --- a/app/src/main/res/drawable-v21/conversation_list_item_background_dark.xml +++ b/app/src/main/res/drawable-v21/conversation_list_item_background_dark.xml @@ -1,10 +1,10 @@ + android:color="@color/core_ultramarine"> - + diff --git a/app/src/main/res/drawable-v21/cta_button_background.xml b/app/src/main/res/drawable-v21/cta_button_background.xml index faa6232f9..059e9cc25 100644 --- a/app/src/main/res/drawable-v21/cta_button_background.xml +++ b/app/src/main/res/drawable-v21/cta_button_background.xml @@ -1,18 +1,18 @@ + android:color="@color/core_ultramarine"> - + - + diff --git a/app/src/main/res/drawable-v21/media_continue_button_background.xml b/app/src/main/res/drawable-v21/media_continue_button_background.xml index bc0ce271c..df91cfd22 100644 --- a/app/src/main/res/drawable-v21/media_continue_button_background.xml +++ b/app/src/main/res/drawable-v21/media_continue_button_background.xml @@ -11,7 +11,7 @@ - + diff --git a/app/src/main/res/drawable-xhdpi/icon_notification.webp b/app/src/main/res/drawable-xhdpi/icon_notification.webp deleted file mode 100644 index 67529804f..000000000 Binary files a/app/src/main/res/drawable-xhdpi/icon_notification.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/icon_transparent.webp b/app/src/main/res/drawable-xhdpi/icon_transparent.webp deleted file mode 100644 index 13fcd8418..000000000 Binary files a/app/src/main/res/drawable-xhdpi/icon_transparent.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/profile_megaphone.png b/app/src/main/res/drawable-xhdpi/profile_megaphone.png deleted file mode 100644 index 801c51019..000000000 Binary files a/app/src/main/res/drawable-xhdpi/profile_megaphone.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/profile_splash.webp b/app/src/main/res/drawable-xhdpi/profile_splash.webp deleted file mode 100644 index a1a2a6c78..000000000 Binary files a/app/src/main/res/drawable-xhdpi/profile_splash.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/video_splash.webp b/app/src/main/res/drawable-xhdpi/video_splash.webp deleted file mode 100644 index 1cec73988..000000000 Binary files a/app/src/main/res/drawable-xhdpi/video_splash.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/icon_notification.webp b/app/src/main/res/drawable-xxhdpi/icon_notification.webp deleted file mode 100644 index 7268fffca..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/icon_notification.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/icon_transparent.webp b/app/src/main/res/drawable-xxhdpi/icon_transparent.webp deleted file mode 100644 index 044d8a15b..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/icon_transparent.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/profile_megaphone.png b/app/src/main/res/drawable-xxhdpi/profile_megaphone.png deleted file mode 100644 index ca5f625a9..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/profile_megaphone.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/profile_splash.webp b/app/src/main/res/drawable-xxhdpi/profile_splash.webp deleted file mode 100644 index bc428eae3..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/profile_splash.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/splash_logo.webp b/app/src/main/res/drawable-xxhdpi/splash_logo.webp deleted file mode 100644 index 44ac02be4..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/splash_logo.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/video_splash.webp b/app/src/main/res/drawable-xxhdpi/video_splash.webp deleted file mode 100644 index b202d2c4a..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/video_splash.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/icon_notification.webp b/app/src/main/res/drawable-xxxhdpi/icon_notification.webp deleted file mode 100644 index 9011939c3..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/icon_notification.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/icon_transparent.webp b/app/src/main/res/drawable-xxxhdpi/icon_transparent.webp deleted file mode 100644 index 11c324dff..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/icon_transparent.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/profile_megaphone.png b/app/src/main/res/drawable-xxxhdpi/profile_megaphone.png deleted file mode 100644 index 21d914657..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/profile_megaphone.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/profile_splash.webp b/app/src/main/res/drawable-xxxhdpi/profile_splash.webp deleted file mode 100644 index 4b6f226cc..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/profile_splash.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/video_splash.webp b/app/src/main/res/drawable-xxxhdpi/video_splash.webp deleted file mode 100644 index f7bf3682a..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/video_splash.webp and /dev/null differ diff --git a/app/src/main/res/drawable/camera_send_button_background.xml b/app/src/main/res/drawable/camera_send_button_background.xml index f612f8c4f..7bcb1e9d4 100644 --- a/app/src/main/res/drawable/camera_send_button_background.xml +++ b/app/src/main/res/drawable/camera_send_button_background.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/conversation_item_background.xml b/app/src/main/res/drawable/conversation_item_background.xml index d4cbc9d84..31d6730d5 100644 --- a/app/src/main/res/drawable/conversation_item_background.xml +++ b/app/src/main/res/drawable/conversation_item_background.xml @@ -1,5 +1,5 @@ - - + + diff --git a/app/src/main/res/drawable/conversation_item_background_animated.xml b/app/src/main/res/drawable/conversation_item_background_animated.xml index 3664c2a1d..0116af86c 100644 --- a/app/src/main/res/drawable/conversation_item_background_animated.xml +++ b/app/src/main/res/drawable/conversation_item_background_animated.xml @@ -1,5 +1,5 @@ - - + + diff --git a/app/src/main/res/drawable/conversation_list_item_background.xml b/app/src/main/res/drawable/conversation_list_item_background.xml index 92908acc6..2b88d4b9c 100644 --- a/app/src/main/res/drawable/conversation_list_item_background.xml +++ b/app/src/main/res/drawable/conversation_list_item_background.xml @@ -1,6 +1,6 @@ - - - + + + diff --git a/app/src/main/res/drawable/conversation_list_item_background_dark.xml b/app/src/main/res/drawable/conversation_list_item_background_dark.xml index 92908acc6..2b88d4b9c 100644 --- a/app/src/main/res/drawable/conversation_list_item_background_dark.xml +++ b/app/src/main/res/drawable/conversation_list_item_background_dark.xml @@ -1,6 +1,6 @@ - - - + + + diff --git a/app/src/main/res/drawable/cta_button_background.xml b/app/src/main/res/drawable/cta_button_background.xml index 4695736fd..44e80001f 100644 --- a/app/src/main/res/drawable/cta_button_background.xml +++ b/app/src/main/res/drawable/cta_button_background.xml @@ -3,13 +3,13 @@ - + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/help_fragment_emoji_radio_background_dark.xml b/app/src/main/res/drawable/help_fragment_emoji_radio_background_dark.xml index d6f235e65..9145b1ce5 100644 --- a/app/src/main/res/drawable/help_fragment_emoji_radio_background_dark.xml +++ b/app/src/main/res/drawable/help_fragment_emoji_radio_background_dark.xml @@ -2,7 +2,7 @@ - + diff --git a/app/src/main/res/drawable/help_fragment_emoji_radio_background_light.xml b/app/src/main/res/drawable/help_fragment_emoji_radio_background_light.xml index c648a10f8..ce03a64ab 100644 --- a/app/src/main/res/drawable/help_fragment_emoji_radio_background_light.xml +++ b/app/src/main/res/drawable/help_fragment_emoji_radio_background_light.xml @@ -2,7 +2,7 @@ - + diff --git a/app/src/main/res/drawable/ic_check_circle_solid_20.xml b/app/src/main/res/drawable/ic_check_circle_solid_20.xml index 5e5b0a250..9a0af2a0b 100644 --- a/app/src/main/res/drawable/ic_check_circle_solid_20.xml +++ b/app/src/main/res/drawable/ic_check_circle_solid_20.xml @@ -4,7 +4,7 @@ android:viewportWidth="20" android:viewportHeight="20"> diff --git a/app/src/main/res/drawable/ic_help_solid_24.xml b/app/src/main/res/drawable/ic_help_solid_24.xml index 156c05791..693fc03a7 100644 --- a/app/src/main/res/drawable/ic_help_solid_24.xml +++ b/app/src/main/res/drawable/ic_help_solid_24.xml @@ -1,4 +1,4 @@ - diff --git a/app/src/main/res/drawable/ic_kbs_splash_light_svg.xml b/app/src/main/res/drawable/ic_kbs_splash_light_svg.xml index 7c09ac4b8..b22785dcd 100644 --- a/app/src/main/res/drawable/ic_kbs_splash_light_svg.xml +++ b/app/src/main/res/drawable/ic_kbs_splash_light_svg.xml @@ -39,6 +39,6 @@ + android:fillColor="@color/core_ultramarine"/> diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..b59070865 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_map_marker.xml b/app/src/main/res/drawable/ic_map_marker.xml index 29967a639..a2c1b00d8 100644 --- a/app/src/main/res/drawable/ic_map_marker.xml +++ b/app/src/main/res/drawable/ic_map_marker.xml @@ -4,6 +4,6 @@ android:viewportWidth="438.536" android:viewportHeight="438.536"> diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 000000000..b6be28133 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profilename_64.xml b/app/src/main/res/drawable/ic_profilename_64.xml new file mode 100644 index 000000000..5f0a523f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_profilename_64.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_send_lock_24.xml b/app/src/main/res/drawable/ic_send_lock_24.xml index 3e6167796..23f5573e8 100644 --- a/app/src/main/res/drawable/ic_send_lock_24.xml +++ b/app/src/main/res/drawable/ic_send_lock_24.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_send_unlock_24.xml b/app/src/main/res/drawable/ic_send_unlock_24.xml index cedd90d5f..963208490 100644 --- a/app/src/main/res/drawable/ic_send_unlock_24.xml +++ b/app/src/main/res/drawable/ic_send_unlock_24.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_share_outline_24.xml b/app/src/main/res/drawable/ic_share_outline_24.xml index e0d004e64..3018fe04d 100644 --- a/app/src/main/res/drawable/ic_share_outline_24.xml +++ b/app/src/main/res/drawable/ic_share_outline_24.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_share_solid_24.xml b/app/src/main/res/drawable/ic_share_solid_24.xml index 75a92502b..06458017c 100644 --- a/app/src/main/res/drawable/ic_share_solid_24.xml +++ b/app/src/main/res/drawable/ic_share_solid_24.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/info_round.xml b/app/src/main/res/drawable/info_round.xml index 668eaf3be..4dc3e2fad 100644 --- a/app/src/main/res/drawable/info_round.xml +++ b/app/src/main/res/drawable/info_round.xml @@ -10,7 +10,7 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/insights_cta_button_background.xml b/app/src/main/res/drawable/insights_cta_button_background.xml index 5d8a5c7ee..8992c530e 100644 --- a/app/src/main/res/drawable/insights_cta_button_background.xml +++ b/app/src/main/res/drawable/insights_cta_button_background.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/labeled_edit_text_background_active.xml b/app/src/main/res/drawable/labeled_edit_text_background_active.xml index 00a2dc992..3bb9c5f1a 100644 --- a/app/src/main/res/drawable/labeled_edit_text_background_active.xml +++ b/app/src/main/res/drawable/labeled_edit_text_background_active.xml @@ -7,7 +7,7 @@ diff --git a/app/src/main/res/drawable/media_continue_button_background.xml b/app/src/main/res/drawable/media_continue_button_background.xml index f612f8c4f..7bcb1e9d4 100644 --- a/app/src/main/res/drawable/media_continue_button_background.xml +++ b/app/src/main/res/drawable/media_continue_button_background.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/media_count_button_background.xml b/app/src/main/res/drawable/media_count_button_background.xml index be4d965cf..febeaab9f 100644 --- a/app/src/main/res/drawable/media_count_button_background.xml +++ b/app/src/main/res/drawable/media_count_button_background.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/main/res/drawable/mediarail_media_outline.xml b/app/src/main/res/drawable/mediarail_media_outline.xml index 8b6e4ec97..54e19058e 100644 --- a/app/src/main/res/drawable/mediarail_media_outline.xml +++ b/app/src/main/res/drawable/mediarail_media_outline.xml @@ -4,5 +4,5 @@ + android:color="@color/core_ultramarine"/> \ No newline at end of file diff --git a/app/src/main/res/drawable/pill.xml b/app/src/main/res/drawable/pill.xml index be4d965cf..febeaab9f 100644 --- a/app/src/main/res/drawable/pill.xml +++ b/app/src/main/res/drawable/pill.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/main/res/drawable/progress_button_state.xml b/app/src/main/res/drawable/progress_button_state.xml index 7e6bcc024..1db6b41dc 100644 --- a/app/src/main/res/drawable/progress_button_state.xml +++ b/app/src/main/res/drawable/progress_button_state.xml @@ -1,11 +1,11 @@ + android:color="@color/core_ultramarine"/> + android:color="@color/core_ultramarine"/> + android:color="@color/core_ultramarine"/> + android:color="@color/core_ultramarine"/> \ No newline at end of file diff --git a/app/src/main/res/drawable/read_receipt_vector.xml b/app/src/main/res/drawable/read_receipt_vector.xml index 8184b984e..7e9f8f603 100644 --- a/app/src/main/res/drawable/read_receipt_vector.xml +++ b/app/src/main/res/drawable/read_receipt_vector.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/reminder_background_normal.xml b/app/src/main/res/drawable/reminder_background_normal.xml index 349f949c2..45628500e 100644 --- a/app/src/main/res/drawable/reminder_background_normal.xml +++ b/app/src/main/res/drawable/reminder_background_normal.xml @@ -1,6 +1,6 @@ - - - + + + diff --git a/app/src/main/res/drawable/touch_highlight_background.xml b/app/src/main/res/drawable/touch_highlight_background.xml index 8089a0280..b5a98171b 100644 --- a/app/src/main/res/drawable/touch_highlight_background.xml +++ b/app/src/main/res/drawable/touch_highlight_background.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/drawable/unread_count_background_dark.xml b/app/src/main/res/drawable/unread_count_background_dark.xml index 8666e43f6..ddaf847ec 100644 --- a/app/src/main/res/drawable/unread_count_background_dark.xml +++ b/app/src/main/res/drawable/unread_count_background_dark.xml @@ -5,7 +5,7 @@ - + - + diff --git a/app/src/main/res/layout/activity_shared_contact_details.xml b/app/src/main/res/layout/activity_shared_contact_details.xml index 38f3afd8d..33c73f92f 100644 --- a/app/src/main/res/layout/activity_shared_contact_details.xml +++ b/app/src/main/res/layout/activity_shared_contact_details.xml @@ -97,14 +97,14 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="6dp" - android:tint="@color/signal_primary" + android:tint="@color/core_ultramarine" android:src="@drawable/message_24dp"/> + android:textColor="@color/core_ultramarine"/> @@ -121,14 +121,14 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="6dp" - android:tint="@color/signal_primary" + android:tint="@color/core_ultramarine" android:src="@drawable/phone_24dp"/> + android:textColor="@color/core_ultramarine"/> diff --git a/app/src/main/res/layout/base_kbs_pin_fragment.xml b/app/src/main/res/layout/base_kbs_pin_fragment.xml index 5d31925f0..8acfc0533 100644 --- a/app/src/main/res/layout/base_kbs_pin_fragment.xml +++ b/app/src/main/res/layout/base_kbs_pin_fragment.xml @@ -88,7 +88,6 @@ android:layout_height="wrap_content" android:layout_marginStart="32dp" android:layout_marginEnd="32dp" - android:textColor="@color/signal_primary" app:layout_constraintTop_toBottomOf="@id/edit_kbs_pin_input_label" app:layout_constraintBottom_toTopOf="@id/edit_kbs_pin_confirm" app:layout_constraintVertical_bias="1.0" diff --git a/app/src/main/res/layout/color_fragment.xml b/app/src/main/res/layout/color_fragment.xml deleted file mode 100644 index de2f5f2ed..000000000 --- a/app/src/main/res/layout/color_fragment.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/contact_selection_list_fragment.xml b/app/src/main/res/layout/contact_selection_list_fragment.xml index 27171aebc..1393c1a5b 100644 --- a/app/src/main/res/layout/contact_selection_list_fragment.xml +++ b/app/src/main/res/layout/contact_selection_list_fragment.xml @@ -54,8 +54,8 @@ android:visibility="invisible" app:matProg_circleRadius="145dp" app:matProg_barWidth="6dp" - app:matProg_rimColor="@color/signal_primary" - app:matProg_barColor="@color/signal_primary_dark" + app:matProg_rimColor="@color/core_ultramarine" + app:matProg_barColor="@color/core_ultramarine_dark" app:matProg_progressIndeterminate="true" tools:visibility="visible" /> @@ -84,7 +84,7 @@ android:layout_height="wrap_content" android:layout_marginTop="20dp" android:layout_gravity="center_horizontal" - android:background="@color/signal_primary" + style="@style/Button.Primary" android:textColor="@color/white" android:padding="10dp" android:text="@string/contact_selection_list_fragment__show_contacts"/> diff --git a/app/src/main/res/layout/conversation_search_nav.xml b/app/src/main/res/layout/conversation_search_nav.xml index e3d97456d..5ceab1347 100644 --- a/app/src/main/res/layout/conversation_search_nav.xml +++ b/app/src/main/res/layout/conversation_search_nav.xml @@ -34,7 +34,7 @@ android:background="?selectableItemBackgroundBorderless" android:padding="8dp" android:src="@drawable/ic_keyboard_arrow_up_white_36dp" - android:tint="@color/signal_primary" + android:tint="@color/core_ultramarine" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/conversation_search_down" app:layout_constraintTop_toTopOf="parent" /> @@ -49,7 +49,7 @@ android:background="?selectableItemBackgroundBorderless" android:padding="8dp" android:src="@drawable/ic_keyboard_arrow_down_white_24dp" - android:tint="@color/signal_primary" + android:tint="@color/core_ultramarine" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" /> diff --git a/app/src/main/res/layout/create_passphrase_activity.xml b/app/src/main/res/layout/create_passphrase_activity.xml index fbffc4842..4ef785604 100644 --- a/app/src/main/res/layout/create_passphrase_activity.xml +++ b/app/src/main/res/layout/create_passphrase_activity.xml @@ -1,27 +1,29 @@ + - + - - - - + diff --git a/app/src/main/res/layout/device_list_fragment.xml b/app/src/main/res/layout/device_list_fragment.xml index 163b1b586..82e6c8c69 100644 --- a/app/src/main/res/layout/device_list_fragment.xml +++ b/app/src/main/res/layout/device_list_fragment.xml @@ -52,7 +52,7 @@ android:focusable="true" android:contentDescription="@string/device_list_fragment__link_new_device" fab:fab_colorNormal="?fab_color" - fab:fab_colorPressed="@color/textsecure_primary_dark" - fab:fab_colorRipple="@color/textsecure_primary_dark" /> + fab:fab_colorPressed="@color/core_ultramarine_dark" + fab:fab_colorRipple="@color/core_ultramarine_dark" /> \ No newline at end of file diff --git a/app/src/main/res/layout/experience_upgrade_activity.xml b/app/src/main/res/layout/experience_upgrade_activity.xml deleted file mode 100644 index 73c64014d..000000000 --- a/app/src/main/res/layout/experience_upgrade_activity.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - -