Implement Stories feature behind flag.

Co-Authored-By: Greyson Parrelli <37311915+greyson-signal@users.noreply.github.com>
Co-Authored-By: Rashad Sookram <95182499+rashad-signal@users.noreply.github.com>
This commit is contained in:
Alex Hart
2022-02-24 13:40:28 -04:00
parent 765185952e
commit 174cd860a0
416 changed files with 19506 additions and 857 deletions

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.whispersystems.signalservice.api.account.AccountAttributes;
public final class AppCapabilities {
@@ -19,6 +20,6 @@ public final class AppCapabilities {
* asking if the user has set a Signal PIN or not.
*/
public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER);
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER, FeatureFlags.stories());
}
}

View File

@@ -51,6 +51,7 @@ import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob;
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
import org.thoughtcrime.securesms.jobs.FontDownloaderJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
@@ -187,6 +188,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addNonBlocking(EmojiSource::refresh)
.addNonBlocking(() -> ApplicationDependencies.getGiphyMp4Cache().onAppStart(this))
.addNonBlocking(this::ensureProfileUploaded)
.addNonBlocking(() -> ApplicationDependencies.getExpireStoriesManager().scheduleIfNecessary())
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
.addPostRender(this::initializeExpiringMessageManager)
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))
@@ -196,6 +198,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
.addPostRender(RetrieveReleaseChannelJob::enqueue)
.addPostRender(() -> AndroidTelecomUtil.registerPhoneAccount())
.addPostRender(() -> ApplicationDependencies.getJobManager().add(new FontDownloaderJob()))
.execute();
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");

View File

@@ -20,6 +20,7 @@ import android.content.Context;
import android.os.AsyncTask;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
@@ -123,12 +124,12 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
}
@Override
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
callback.accept(true);
}
@Override
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {}
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {}
@Override
public void onBeginScroll() {

View File

@@ -23,6 +23,7 @@ import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.Rect;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.TextUtils;
@@ -66,6 +67,7 @@ import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.HeaderAction;
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
@@ -77,6 +79,7 @@ import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.sharing.ShareContact;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -96,10 +99,9 @@ import java.util.function.Consumer;
* Fragment for selecting a one or more contacts from a list.
*
* @author Moxie Marlinspike
*
*/
public final class ContactSelectionListFragment extends LoggingFragment
implements LoaderManager.LoaderCallbacks<Cursor>
implements LoaderManager.LoaderCallbacks<Cursor>
{
@SuppressWarnings("unused")
private static final String TAG = Log.tag(ContactSelectionListFragment.class);
@@ -138,18 +140,19 @@ public final class ContactSelectionListFragment extends LoggingFragment
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
private View shadowView;
private ToolbarShadowAnimationHelper toolbarShadowAnimationHelper;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
@Nullable private FixedViewsAdapter headerAdapter;
@Nullable private FixedViewsAdapter footerAdapter;
@Nullable private ListCallback listCallback;
@Nullable private ScrollCallback scrollCallback;
private GlideRequests glideRequests;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
private boolean isMulti;
private boolean hideCount;
private boolean canSelectSelf;
private GlideRequests glideRequests;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
private boolean isMulti;
private boolean hideCount;
private boolean canSelectSelf;
@Override
public void onAttach(@NonNull Context context) {
@@ -190,6 +193,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) getParentFragment();
}
if (context instanceof HeaderActionProvider) {
headerActionProvider = (HeaderActionProvider) context;
}
if (getParentFragment() instanceof HeaderActionProvider) {
headerActionProvider = (HeaderActionProvider) getParentFragment();
}
}
@Override
@@ -243,11 +254,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
constraintLayout = view.findViewById(R.id.container);
shadowView = view.findViewById(R.id.toolbar_shadow);
headerActionView = view.findViewById(R.id.header_action);
toolbarShadowAnimationHelper = new ToolbarShadowAnimationHelper(shadowView);
final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
recyclerView.addOnScrollListener(toolbarShadowAnimationHelper);
recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
recyclerView.setLayoutManager(layoutManager);
recyclerView.setItemAnimator(new DefaultItemAnimator() {
@Override
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
@@ -285,6 +299,40 @@ public final class ContactSelectionListFragment extends LoggingFragment
currentSelection = getCurrentSelection();
final HeaderAction headerAction;
if (headerActionProvider != null) {
headerAction = headerActionProvider.getHeaderAction();
headerActionView.setEnabled(true);
headerActionView.setText(headerAction.getLabel());
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0);
headerActionView.setOnClickListener(v -> headerAction.getAction().run());
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
private final Rect bounds = new Rect();
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
}
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (hideLetterHeaders()) {
return;
}
int firstPosition = layoutManager.findFirstVisibleItemPosition();
if (firstPosition == 0) {
View firstChild = recyclerView.getChildAt(0);
recyclerView.getDecoratedBoundsWithMargins(firstChild, bounds);
headerActionView.setTranslationY(bounds.top);
}
}
});
} else {
headerActionView.setEnabled(false);
}
return view;
}
@@ -491,12 +539,19 @@ public final class ContactSelectionListFragment extends LoggingFragment
fastScroller.setRecyclerView(null);
fastScroller.setVisibility(View.GONE);
}
if (headerActionView.isEnabled() && !hasQueryFilter()) {
headerActionView.setVisibility(View.VISIBLE);
} else {
headerActionView.setVisibility(View.GONE);
}
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
cursorRecyclerViewAdapter.changeCursor(null);
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
}
private boolean shouldDisplayRecents() {
@@ -546,6 +601,39 @@ public final class ContactSelectionListFragment extends LoggingFragment
}.execute();
}
/**
* Allows the caller to submit a list of recipients to be marked selected. Useful for when a screen needs to load preselected
* entries in the background before setting them in the adapter.
*
* @param contacts List of the contacts to select. This will not overwrite the current selection, but append to it.
*/
public void markSelected(@NonNull Set<ShareContact> contacts) {
if (contacts.isEmpty()) {
return;
}
Set<SelectedContact> toMarkSelected = contacts.stream()
.map(contact -> {
if (contact.getRecipientId().isPresent()) {
return SelectedContact.forRecipientId(contact.getRecipientId().get());
} else {
return SelectedContact.forPhone(null, contact.getNumber());
}
})
.filter(c -> !cursorRecyclerViewAdapter.isSelectedContact(c))
.collect(java.util.stream.Collectors.toSet());
if (toMarkSelected.isEmpty()) {
return;
}
for (final SelectedContact selectedContact : toMarkSelected) {
markContactSelected(selectedContact);
}
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount());
}
private class ListClickListener implements ContactSelectionListAdapter.ItemClickListener {
@Override
public void onItemClick(ContactSelectionListItem contact) {
@@ -575,8 +663,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
}, uuid -> {
loadingDialog.dismiss();
if (uuid.isPresent()) {
Recipient recipient = Recipient.externalUsername(uuid.get(), contact.getNumber());
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
Recipient recipient = Recipient.externalUsername(uuid.get(), contact.getNumber());
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
@@ -668,7 +756,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
resolved -> addChipForRecipient(resolved, selectedContact));
}
@@ -768,19 +856,25 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
public interface OnContactSelectedListener {
/** Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it. */
void onBeforeContactSelected(Optional<RecipientId> recipientId, @Nullable String number, Consumer<Boolean> callback);
void onContactDeselected(Optional<RecipientId> recipientId, @Nullable String number);
/**
* Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it.
*/
void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback);
void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number);
void onSelectionChanged();
}
public interface OnSelectionLimitReachedListener {
void onSuggestedLimitReached(int limit);
void onHardLimitReached(int limit);
}
public interface ListCallback {
void onInvite();
void onNewGroup(boolean forceV1);
}
@@ -788,6 +882,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
void onBeginScroll();
}
public interface HeaderActionProvider {
@NonNull HeaderAction getHeaderAction();
}
public interface AbstractContactsCursorLoaderFactoryProvider {
@NonNull AbstractContactsCursorLoader.Factory get();
}

View File

@@ -16,6 +16,7 @@ import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.AnimRes;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.Toolbar;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
@@ -134,13 +135,13 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
}
@Override
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
callback.accept(true);
}
@Override
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
}

View File

@@ -5,19 +5,28 @@ import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferLockedDialog;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsState;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.WindowUtil;
public class MainActivity extends PassphraseRequiredActivity implements VoiceNoteMediaControllerOwner {
@@ -26,13 +35,14 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
private final MainNavigator navigator = new MainNavigator(this);
private VoiceNoteMediaController mediaController;
private VoiceNoteMediaController mediaController;
private ConversationListTabsViewModel conversationListTabsViewModel;
public static @NonNull Intent clearTop(@NonNull Context context) {
Intent intent = new Intent(context, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
Intent.FLAG_ACTIVITY_NEW_TASK |
Intent.FLAG_ACTIVITY_NEW_TASK |
Intent.FLAG_ACTIVITY_SINGLE_TOP);
return intent;
@@ -42,9 +52,14 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
protected void onCreate(Bundle savedInstanceState, boolean ready) {
AppStartup.getInstance().onCriticalRenderEventStart();
super.onCreate(savedInstanceState, ready);
setContentView(R.layout.main_activity);
mediaController = new VoiceNoteMediaController(this);
ConversationListTabRepository repository = new ConversationListTabRepository();
ConversationListTabsViewModel.Factory factory = new ConversationListTabsViewModel.Factory(repository);
navigator.onCreate(savedInstanceState);
handleGroupLinkInIntent(getIntent());
@@ -52,12 +67,27 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
handleSignalMeIntent(getIntent());
CachedInflater.from(this).clear();
conversationListTabsViewModel = new ViewModelProvider(this, factory).get(ConversationListTabsViewModel.class);
Transformations.map(conversationListTabsViewModel.getState(), ConversationListTabsState::getTab)
.observe(this, tab -> {
switch (tab) {
case CHATS:
getSupportFragmentManager().popBackStack();
break;
case STORIES:
navigator.goToStories();
break;
}
});
updateTabVisibility();
}
@Override
public Intent getIntent() {
return super.getIntent().setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
Intent.FLAG_ACTIVITY_NEW_TASK |
Intent.FLAG_ACTIVITY_NEW_TASK |
Intent.FLAG_ACTIVITY_SINGLE_TOP);
}
@@ -82,6 +112,8 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
if (SignalStore.misc().isOldDeviceTransferLocked()) {
OldDeviceTransferLockedDialog.show(getSupportFragmentManager());
}
updateTabVisibility();
}
@Override
@@ -99,6 +131,17 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
}
}
private void updateTabVisibility() {
if (FeatureFlags.stories() && !SignalStore.storyValues().isFeatureDisabled()) {
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
WindowUtil.setNavigationBarColor(getWindow(), ContextCompat.getColor(this, R.color.signal_background_secondary));
} else {
findViewById(R.id.conversation_list_tabs).setVisibility(View.GONE);
WindowUtil.setNavigationBarColor(getWindow(), ContextCompat.getColor(this, R.color.signal_background_primary));
navigator.goToChats();
}
}
public @NonNull MainNavigator getNavigator() {
return navigator;
}

View File

@@ -9,7 +9,6 @@ import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment;
@@ -17,9 +16,12 @@ import org.thoughtcrime.securesms.conversationlist.ConversationListFragment;
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment;
public class MainNavigator {
public static final String STORIES_TAG = "STORIES";
public static final int REQUEST_CONFIG_CHANGES = 901;
private final MainActivity activity;
@@ -82,6 +84,21 @@ public class MainNavigator {
.commit();
}
public void goToStories() {
if (getFragmentManager().findFragmentByTag(STORIES_TAG) == null) {
getFragmentManager().beginTransaction()
.replace(R.id.fragment_container, new StoriesLandingFragment(), STORIES_TAG)
.addToBackStack(null)
.commit();
}
}
public void goToChats() {
if (getFragmentManager().findFragmentByTag(STORIES_TAG) != null) {
getFragmentManager().popBackStack();
}
}
public void goToGroupCreation() {
activity.startActivity(CreateGroupActivity.newIntent(activity));
}

View File

@@ -21,6 +21,7 @@ import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import org.signal.core.util.logging.Log;
@@ -61,7 +62,7 @@ public class NewConversationActivity extends ContactSelectionActivity
}
@Override
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
if (recipientId.isPresent()) {
launch(Recipient.resolved(recipientId.get()));
} else {

View File

@@ -0,0 +1,77 @@
package org.thoughtcrime.securesms.avatar.view
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.visible
/**
* AvatarView encapsulating the AvatarImageView and decorations.
*/
class AvatarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
init {
inflate(context, R.layout.avatar_view, this)
}
private val avatar: AvatarImageView = findViewById(R.id.avatar_image_view)
private val storyRing: View = findViewById(R.id.avatar_story_ring)
fun showStoryRing(hasUnreadStory: Boolean) {
if (!FeatureFlags.stories() || SignalStore.storyValues().isFeatureDisabled) {
return
}
storyRing.visible = true
storyRing.isActivated = hasUnreadStory
avatar.scaleX = 0.82f
avatar.scaleY = 0.82f
}
fun hideStoryRing() {
storyRing.visible = false
avatar.scaleX = 1f
avatar.scaleY = 1f
}
/**
* Displays Note-to-Self
*/
fun displayChatAvatar(recipient: Recipient) {
avatar.setAvatar(recipient)
}
/**
* Displays Note-to-Self
*/
fun displayChatAvatar(requestManager: GlideRequests, recipient: Recipient, isQuickContactEnabled: Boolean) {
avatar.setAvatar(requestManager, recipient, isQuickContactEnabled)
}
/**
* Displays Profile image
*/
fun displayProfileAvatar(recipient: Recipient) {
avatar.setRecipient(recipient)
}
fun setFallbackPhotoProvider(fallbackPhotoProvider: Recipient.FallbackPhotoProvider) {
avatar.setFallbackPhotoProvider(fallbackPhotoProvider)
}
fun disableQuickContact() {
avatar.disableQuickContact()
}
}

View File

@@ -87,7 +87,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
}
@Override
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
final String displayName = recipientId.transform(id -> Recipient.resolved(id).getDisplayName(this)).or(number);
AlertDialog confirmationDialog = new MaterialAlertDialogBuilder(this)
@@ -116,7 +116,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
}
@Override
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
}

View File

@@ -2,8 +2,9 @@ package org.thoughtcrime.securesms.components
import android.app.Dialog
import android.os.Bundle
import android.view.ContextThemeWrapper
import android.view.View
import androidx.core.content.ContextCompat
import androidx.annotation.StyleRes
import androidx.core.view.ViewCompat
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
@@ -12,6 +13,7 @@ import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.ViewUtil
/**
@@ -21,9 +23,12 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr
protected open val peekHeightPercentage: Float = 0.5f
@StyleRes
protected open val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.Widget_Signal_FixedRoundedCorners)
setStyle(STYLE_NORMAL, themeResId)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
@@ -38,7 +43,8 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr
val dialogBackground = MaterialShapeDrawable(shapeAppearanceModel)
dialogBackground.setTint(ContextCompat.getColor(requireContext(), R.color.signal_background_dialog))
val bottomSheetStyle = ThemeUtil.getThemedResourceId(ContextThemeWrapper(requireContext(), themeResId), R.attr.bottomSheetStyle)
dialogBackground.setTint(ThemeUtil.getThemedColor(ContextThemeWrapper(requireContext(), bottomSheetStyle), R.attr.backgroundTint))
dialog.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.components
import android.os.Bundle
import androidx.fragment.app.Fragment
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
/**
* Activity that wraps a given fragment
*/
abstract class FragmentWrapperActivity : PassphraseRequiredActivity() {
protected open val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
setContentView(R.layout.fragment_container)
dynamicTheme.onCreate(this)
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, getFragment())
.commit()
}
}
abstract fun getFragment(): Fragment
override fun onResume() {
super.onResume()
dynamicTheme.onResume(this)
}
}

View File

@@ -20,6 +20,8 @@ abstract class KeyboardEntryDialogFragment(@LayoutRes contentLayoutId: Int) :
private var hasShown = false
protected open val withDim: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
setStyle(STYLE_NORMAL, R.style.Theme_Signal_RoundedBottomSheet)
super.onCreate(savedInstanceState)
@@ -29,7 +31,10 @@ abstract class KeyboardEntryDialogFragment(@LayoutRes contentLayoutId: Int) :
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.window?.setDimAmount(0f)
if (!withDim) {
dialog.window?.setDimAmount(0f)
}
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
return dialog

View File

@@ -29,13 +29,17 @@ public class OutlinedThumbnailView extends ThumbnailView {
cornerMask = new CornerMask(this);
outliner = new Outliner();
outliner.setColor(ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_20));
int defaultOutlinerColor = ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_20);
outliner.setColor(defaultOutlinerColor);
int radius = 0;
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.OutlinedThumbnailView, 0, 0);
radius = typedArray.getDimensionPixelOffset(R.styleable.OutlinedThumbnailView_otv_cornerRadius, 0);
outliner.setStrokeWidth(typedArray.getDimensionPixelSize(R.styleable.OutlinedThumbnailView_otv_strokeWidth, 1));
outliner.setColor(typedArray.getColor(R.styleable.OutlinedThumbnailView_otv_strokeColor, defaultOutlinerColor));
}
setRadius(radius);

View File

@@ -5,6 +5,7 @@ import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.TextUtils;
import android.util.AttributeSet;
@@ -19,7 +20,6 @@ import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.content.ContextCompat;
import com.annimon.stream.Stream;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.signal.core.util.logging.Log;
@@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequest;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
@@ -45,9 +46,10 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
private static final String TAG = Log.tag(QuoteView.class);
private static final int MESSAGE_TYPE_PREVIEW = 0;
private static final int MESSAGE_TYPE_OUTGOING = 1;
private static final int MESSAGE_TYPE_INCOMING = 2;
private static final int MESSAGE_TYPE_PREVIEW = 0;
private static final int MESSAGE_TYPE_OUTGOING = 1;
private static final int MESSAGE_TYPE_INCOMING = 2;
private static final int MESSAGE_TYPE_STORY_REPLY = 3;
private ViewGroup mainView;
private ViewGroup footerView;
@@ -71,6 +73,8 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
private int smallCornerRadius;
private CornerMask cornerMask;
private int thumbHeight;
private int thumbWidth;
public QuoteView(Context context) {
super(context);
@@ -136,6 +140,21 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
}
}
if (messageType == MESSAGE_TYPE_STORY_REPLY) {
thumbWidth = getResources().getDimensionPixelOffset(R.dimen.quote_story_thumb_width);
thumbHeight = getResources().getDimensionPixelOffset(R.dimen.quote_story_thumb_height);
mainView.setMinimumHeight(thumbHeight);
ViewGroup.LayoutParams params = thumbnailView.getLayoutParams();
params.height = thumbHeight;
params.width = thumbWidth;
thumbnailView.setLayoutParams(params);
} else {
thumbWidth = thumbHeight = getResources().getDimensionPixelSize(R.dimen.quote_thumb_size);
}
dismissView.setOnClickListener(view -> setVisibility(GONE));
}
@@ -209,10 +228,14 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
private void setQuoteAuthor(@NonNull Recipient author) {
boolean outgoing = messageType != MESSAGE_TYPE_INCOMING;
boolean preview = messageType == MESSAGE_TYPE_PREVIEW;
boolean preview = messageType == MESSAGE_TYPE_PREVIEW || messageType == MESSAGE_TYPE_STORY_REPLY;
authorView.setText(author.isSelf() ? getContext().getString(R.string.QuoteView_you)
: author.getDisplayName(getContext()));
if (messageType == MESSAGE_TYPE_STORY_REPLY && author.isGroup()) {
authorView.setText(getContext().getString(R.string.QuoteView_s_story, author.getDisplayName(getContext())));
} else {
authorView.setText(author.isSelf() ? getContext().getString(R.string.QuoteView_you)
: author.getDisplayName(getContext()));
}
quoteBarView.setBackgroundColor(ContextCompat.getColor(getContext(), outgoing ? R.color.core_white : android.R.color.transparent));
mainView.setBackgroundColor(ContextCompat.getColor(getContext(), preview ? R.color.quote_preview_background : R.color.quote_view_background));
@@ -279,7 +302,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
}
glideRequests.load(new DecryptableUri(imageVideoSlide.getUri()))
.centerCrop()
.override(getContext().getResources().getDimensionPixelSize(R.dimen.quote_thumb_size))
.override(thumbWidth, thumbHeight)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.into(thumbnailView);
} else if (documentSlide != null){

View File

@@ -5,10 +5,6 @@ import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.ShapeDrawable;
import android.graphics.drawable.shapes.RoundRectShape;
import android.graphics.drawable.shapes.Shape;
import android.net.Uri;
import android.os.Build;
import android.util.AttributeSet;
@@ -19,7 +15,6 @@ import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.UiThread;
import androidx.core.content.ContextCompat;
import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
@@ -33,7 +28,6 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.blurhash.BlurHash;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequest;
import org.thoughtcrime.securesms.mms.GlideRequests;
@@ -45,11 +39,8 @@ import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import org.thoughtcrime.securesms.util.views.Stub;
import org.thoughtcrime.securesms.video.VideoPlayer;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Arrays;
import java.util.Collections;
import java.util.Locale;
import java.util.Objects;

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
@@ -9,6 +10,8 @@ import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.content.res.TypedArrayUtils;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
@@ -20,8 +23,8 @@ import org.thoughtcrime.securesms.components.InputAwareLayout.InputView;
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment;
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment;
import java.util.Objects;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.ThemedFragment;
public class MediaKeyboard extends FrameLayout implements InputView {
@@ -34,6 +37,7 @@ public class MediaKeyboard extends FrameLayout implements InputView {
private State keyboardState;
private KeyboardPagerFragment keyboardPagerFragment;
private FragmentManager fragmentManager;
private int mediaKeyboardTheme;
public MediaKeyboard(Context context) {
this(context, null);
@@ -41,6 +45,12 @@ public class MediaKeyboard extends FrameLayout implements InputView {
public MediaKeyboard(Context context, AttributeSet attrs) {
super(context, attrs);
if (attrs != null) {
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.MediaKeyboard);
mediaKeyboardTheme = array.getResourceId(R.styleable.MediaKeyboard_media_keyboard_theme, -1);
array.recycle();
}
}
public void setFragmentManager(@NonNull FragmentManager fragmentManager) {
@@ -70,6 +80,10 @@ public class MediaKeyboard extends FrameLayout implements InputView {
show();
}
public boolean isInitialised() {
return isInitialised;
}
public void show() {
if (!isInitialised) initView();
@@ -122,9 +136,14 @@ public class MediaKeyboard extends FrameLayout implements InputView {
keyboardState = State.EMOJI_SEARCH;
EmojiSearchFragment emojiSearchFragment = new EmojiSearchFragment();
if (mediaKeyboardTheme != -1) {
ThemedFragment.withTheme(emojiSearchFragment, mediaKeyboardTheme);
}
fragmentManager.beginTransaction()
.hide(keyboardPagerFragment)
.add(R.id.media_keyboard_fragment_container, new EmojiSearchFragment(), EMOJI_SEARCH)
.add(R.id.media_keyboard_fragment_container, emojiSearchFragment, EMOJI_SEARCH)
.runOnCommit(() -> show(latestKeyboardHeight, true))
.setCustomAnimations(R.anim.fade_in, R.anim.fade_out)
.commitAllowingStateLoss();
@@ -141,6 +160,10 @@ public class MediaKeyboard extends FrameLayout implements InputView {
}
keyboardPagerFragment = new KeyboardPagerFragment();
if (mediaKeyboardTheme != -1) {
ThemedFragment.withTheme(keyboardPagerFragment, mediaKeyboardTheme);
}
fragmentManager.beginTransaction()
.replace(R.id.media_keyboard_fragment_container, keyboardPagerFragment)
.commitNowAllowingStateLoss();

View File

@@ -0,0 +1,57 @@
/*
MIT License
Copyright (c) 2020 Tiago Ornelas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
package org.thoughtcrime.securesms.components.segmentedprogressbar
/**
* Created by Tiago Ornelas on 18/04/2020.
* Model that holds the segment state
*/
class Segment(val animationDurationMillis: Long) {
private var animationProgress: Int = 0
var animationState: AnimationState = AnimationState.IDLE
set(value) {
animationProgress = when (value) {
AnimationState.ANIMATED -> 100
AnimationState.IDLE -> 0
else -> animationProgress
}
field = value
}
/**
* Represents possible drawing states of the segment
*/
enum class AnimationState {
ANIMATED,
ANIMATING,
IDLE
}
val progressPercentage: Float
get() = animationProgress.toFloat() / 100
fun progress() = animationProgress++
}

View File

@@ -0,0 +1,375 @@
/*
MIT License
Copyright (c) 2020 Tiago Ornelas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
package org.thoughtcrime.securesms.components.segmentedprogressbar
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Path
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import androidx.viewpager.widget.ViewPager
import org.thoughtcrime.securesms.R
/**
* Created by Tiago Ornelas on 18/04/2020.
* Represents a segmented progress bar on which, the progress is set by segments
* @see Segment
* And the progress of each segment is animated based on a set speed
*/
class SegmentedProgressBar : View, Runnable, ViewPager.OnPageChangeListener, View.OnTouchListener {
private val path = Path()
private val corners = floatArrayOf(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f)
/**
* Number of total segments to draw
*/
var segmentCount: Int = resources.getInteger(R.integer.segmentedprogressbar_default_segments_count)
set(value) {
field = value
this.initSegments()
}
/**
* Mapping of segment index -> duration in millis
*/
var segmentDurations: Map<Int, Long> = mapOf()
set(value) {
field = value
this.initSegments()
}
var margin: Int = resources.getDimensionPixelSize(R.dimen.segmentedprogressbar_default_segment_margin)
private set
var radius: Int = resources.getDimensionPixelSize(R.dimen.segmentedprogressbar_default_corner_radius)
private set
var segmentStrokeWidth: Int =
resources.getDimensionPixelSize(R.dimen.segmentedprogressbar_default_segment_stroke_width)
private set
var segmentBackgroundColor: Int = Color.WHITE
private set
var segmentSelectedBackgroundColor: Int =
context.getThemeColor(R.attr.colorAccent)
private set
var segmentStrokeColor: Int = Color.BLACK
private set
var segmentSelectedStrokeColor: Int = Color.BLACK
private set
var timePerSegmentMs: Long =
resources.getInteger(R.integer.segmentedprogressbar_default_time_per_segment_ms).toLong()
private set
private var segments = mutableListOf<Segment>()
private val selectedSegment: Segment?
get() = segments.firstOrNull { it.animationState == Segment.AnimationState.ANIMATING }
private val selectedSegmentIndex: Int
get() = segments.indexOf(this.selectedSegment)
private val animationHandler = Handler(Looper.getMainLooper())
// Drawing
val strokeApplicable: Boolean
get() = segmentStrokeWidth * 4 <= measuredHeight
val segmentWidth: Float
get() = (measuredWidth - margin * (segmentCount - 1)).toFloat() / segmentCount
var viewPager: ViewPager? = null
@SuppressLint("ClickableViewAccessibility")
set(value) {
field = value
if (value == null) {
viewPager?.removeOnPageChangeListener(this)
viewPager?.setOnTouchListener(null)
} else {
viewPager?.addOnPageChangeListener(this)
viewPager?.setOnTouchListener(this)
}
}
/**
* Sets callbacks for progress bar state changes
* @see SegmentedProgressBarListener
*/
var listener: SegmentedProgressBarListener? = null
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
val typedArray =
context.theme.obtainStyledAttributes(attrs, R.styleable.SegmentedProgressBar, 0, 0)
segmentCount =
typedArray.getInt(R.styleable.SegmentedProgressBar_totalSegments, segmentCount)
margin =
typedArray.getDimensionPixelSize(
R.styleable.SegmentedProgressBar_segmentMargins,
margin
)
radius =
typedArray.getDimensionPixelSize(
R.styleable.SegmentedProgressBar_segmentCornerRadius,
radius
)
segmentStrokeWidth =
typedArray.getDimensionPixelSize(
R.styleable.SegmentedProgressBar_segmentStrokeWidth,
segmentStrokeWidth
)
segmentBackgroundColor =
typedArray.getColor(
R.styleable.SegmentedProgressBar_segmentBackgroundColor,
segmentBackgroundColor
)
segmentSelectedBackgroundColor =
typedArray.getColor(
R.styleable.SegmentedProgressBar_segmentSelectedBackgroundColor,
segmentSelectedBackgroundColor
)
segmentStrokeColor =
typedArray.getColor(
R.styleable.SegmentedProgressBar_segmentStrokeColor,
segmentStrokeColor
)
segmentSelectedStrokeColor =
typedArray.getColor(
R.styleable.SegmentedProgressBar_segmentSelectedStrokeColor,
segmentSelectedStrokeColor
)
timePerSegmentMs =
typedArray.getInt(
R.styleable.SegmentedProgressBar_timePerSegment,
timePerSegmentMs.toInt()
).toLong()
typedArray.recycle()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
init {
setLayerType(LAYER_TYPE_SOFTWARE, null)
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
segments.forEachIndexed { index, segment ->
val drawingComponents = getDrawingComponents(segment, index)
when (index) {
0 -> {
corners.indices.forEach { corners[it] = 0f }
corners[0] = radius.toFloat()
corners[1] = radius.toFloat()
corners[6] = radius.toFloat()
corners[7] = radius.toFloat()
}
segments.lastIndex -> {
corners.indices.forEach { corners[it] = 0f }
corners[2] = radius.toFloat()
corners[3] = radius.toFloat()
corners[4] = radius.toFloat()
corners[5] = radius.toFloat()
}
}
drawingComponents.first.forEachIndexed { drawingIndex, rectangle ->
when (index) {
0, segments.lastIndex -> {
path.reset()
path.addRoundRect(rectangle, corners, Path.Direction.CW)
canvas?.drawPath(path, drawingComponents.second[drawingIndex])
}
else -> canvas?.drawRect(
rectangle,
drawingComponents.second[drawingIndex]
)
}
}
}
}
/**
* Start/Resume progress animation
*/
fun start() {
pause()
val segment = selectedSegment
if (segment == null)
next()
else
animationHandler.postDelayed(this, segment.animationDurationMillis / 100)
}
/**
* Pauses the animation process
*/
fun pause() {
animationHandler.removeCallbacks(this)
}
/**
* Resets the whole animation state and selected segments
* !Doesn't restart it!
* To restart, call the start() method
*/
fun reset() {
this.segments.map { it.animationState = Segment.AnimationState.IDLE }
this.invalidate()
}
/**
* Starts animation for the following segment
*/
fun next() {
loadSegment(offset = 1, userAction = true)
}
/**
* Starts animation for the previous segment
*/
fun previous() {
loadSegment(offset = -1, userAction = true)
}
/**
* Restarts animation for the current segment
*/
fun restartSegment() {
loadSegment(offset = 0, userAction = true)
}
/**
* Skips a number of segments
* @param offset number o segments fo skip
*/
fun skip(offset: Int) {
loadSegment(offset = offset, userAction = true)
}
/**
* Sets current segment to the
* @param position index
*/
fun setPosition(position: Int) {
loadSegment(offset = position - this.selectedSegmentIndex, userAction = true)
}
// Private methods
private fun loadSegment(offset: Int, userAction: Boolean) {
val oldSegmentIndex = this.segments.indexOf(this.selectedSegment)
val nextSegmentIndex = oldSegmentIndex + offset
// Index out of bounds, ignore operation
if (userAction && nextSegmentIndex !in 0 until segmentCount) {
if (nextSegmentIndex >= segmentCount) {
this.listener?.onFinished()
} else {
restartSegment()
}
return
}
segments.mapIndexed { index, segment ->
if (offset > 0) {
if (index < nextSegmentIndex) segment.animationState =
Segment.AnimationState.ANIMATED
} else if (offset < 0) {
if (index > nextSegmentIndex - 1) segment.animationState =
Segment.AnimationState.IDLE
} else if (offset == 0) {
if (index == nextSegmentIndex) segment.animationState = Segment.AnimationState.IDLE
}
}
val nextSegment = this.segments.getOrNull(nextSegmentIndex)
// Handle next segment transition/ending
if (nextSegment != null) {
pause()
nextSegment.animationState = Segment.AnimationState.ANIMATING
animationHandler.postDelayed(this, nextSegment.animationDurationMillis / 100)
this.listener?.onPage(oldSegmentIndex, this.selectedSegmentIndex)
viewPager?.currentItem = this.selectedSegmentIndex
} else {
animationHandler.removeCallbacks(this)
this.listener?.onFinished()
}
}
private fun initSegments() {
this.segments.clear()
segments.addAll(
List(segmentCount) {
val duration = segmentDurations[it] ?: timePerSegmentMs
Segment(duration)
}
)
this.invalidate()
reset()
}
override fun run() {
if (this.selectedSegment?.progress() ?: 0 >= 100) {
loadSegment(offset = 1, userAction = false)
} else {
this.invalidate()
animationHandler.postDelayed(this, this.selectedSegment?.animationDurationMillis?.let { it / 100 } ?: (timePerSegmentMs / 100))
}
}
override fun onPageScrollStateChanged(state: Int) {}
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {}
override fun onPageSelected(position: Int) {
this.setPosition(position)
}
override fun onTouch(p0: View?, p1: MotionEvent?): Boolean {
when (p1?.action) {
MotionEvent.ACTION_DOWN -> pause()
MotionEvent.ACTION_UP -> start()
}
return false
}
}

View File

@@ -0,0 +1,40 @@
/*
MIT License
Copyright (c) 2020 Tiago Ornelas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
package org.thoughtcrime.securesms.components.segmentedprogressbar
/**
* Created by Tiago Ornelas on 18/04/2020.
* Interface to communicate progress events
*/
interface SegmentedProgressBarListener {
/**
* Notifies when selected segment changed
*/
fun onPage(oldPageIndex: Int, newPageIndex: Int)
/**
* Notifies when last segment finished animating
*/
fun onFinished()
}

View File

@@ -0,0 +1,95 @@
/*
MIT License
Copyright (c) 2020 Tiago Ornelas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
package org.thoughtcrime.securesms.components.segmentedprogressbar
import android.content.Context
import android.graphics.Paint
import android.graphics.RectF
import android.util.TypedValue
fun Context.getThemeColor(attributeColor: Int): Int {
val typedValue = TypedValue()
this.theme.resolveAttribute(attributeColor, typedValue, true)
return typedValue.data
}
fun SegmentedProgressBar.getDrawingComponents(
segment: Segment,
segmentIndex: Int
): Pair<MutableList<RectF>, MutableList<Paint>> {
val rectangles = mutableListOf<RectF>()
val paints = mutableListOf<Paint>()
val segmentWidth = segmentWidth
val startBound = segmentIndex * segmentWidth + ((segmentIndex) * margin)
val endBound = startBound + segmentWidth
val stroke = if (!strokeApplicable) 0f else this.segmentStrokeWidth.toFloat()
val backgroundPaint = Paint().apply {
style = Paint.Style.FILL
color = segmentBackgroundColor
}
val selectedBackgroundPaint = Paint().apply {
style = Paint.Style.FILL
color = segmentSelectedBackgroundColor
}
val strokePaint = Paint().apply {
color =
if (segment.animationState == Segment.AnimationState.IDLE) segmentStrokeColor else segmentSelectedStrokeColor
style = Paint.Style.STROKE
strokeWidth = stroke
}
// Background component
if (segment.animationState == Segment.AnimationState.ANIMATED) {
rectangles.add(RectF(startBound + stroke, height - stroke, endBound - stroke, stroke))
paints.add(selectedBackgroundPaint)
} else {
rectangles.add(RectF(startBound + stroke, height - stroke, endBound - stroke, stroke))
paints.add(backgroundPaint)
}
// Progress component
if (segment.animationState == Segment.AnimationState.ANIMATING) {
rectangles.add(
RectF(
startBound + stroke,
height - stroke,
startBound + segment.progressPercentage * segmentWidth,
stroke
)
)
paints.add(selectedBackgroundPaint)
}
// Stroke component
if (stroke > 0) {
rectangles.add(RectF(startBound + stroke, height - stroke, endBound - stroke, stroke))
paints.add(strokePaint)
}
return Pair(rectangles, paints)
}

View File

@@ -25,35 +25,44 @@ abstract class DSLSettingsFragment(
protected var layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) }
) : Fragment(layoutId) {
private var recyclerView: RecyclerView? = null
protected var recyclerView: RecyclerView? = null
private set
private var scrollAnimationHelper: OnScrollAnimationHelper? = null
@CallSuper
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
val toolbarShadow: View = view.findViewById(R.id.toolbar_shadow)
val toolbar: Toolbar? = view.findViewById(R.id.toolbar)
val toolbarShadow: View? = view.findViewById(R.id.toolbar_shadow)
if (titleId != -1) {
toolbar.setTitle(titleId)
toolbar?.setTitle(titleId)
}
toolbar.setNavigationOnClickListener {
toolbar?.setNavigationOnClickListener {
requireActivity().onBackPressed()
}
if (menuId != -1) {
toolbar.inflateMenu(menuId)
toolbar.setOnMenuItemClickListener { onOptionsItemSelected(it) }
toolbar?.inflateMenu(menuId)
toolbar?.setOnMenuItemClickListener { onOptionsItemSelected(it) }
}
if (toolbarShadow != null) {
scrollAnimationHelper = getOnScrollAnimationHelper(toolbarShadow)
}
scrollAnimationHelper = getOnScrollAnimationHelper(toolbarShadow)
val settingsAdapter = DSLSettingsAdapter()
recyclerView = view.findViewById<RecyclerView>(R.id.recycler).apply {
edgeEffectFactory = EdgeEffectFactory()
layoutManager = layoutManagerProducer(requireContext())
adapter = settingsAdapter
addOnScrollListener(scrollAnimationHelper!!)
val helper = scrollAnimationHelper
if (helper != null) {
addOnScrollListener(helper)
}
}
bindAdapter(settingsAdapter)

View File

@@ -4,8 +4,11 @@ import android.content.Context
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Drawable
import android.graphics.drawable.InsetDrawable
import android.graphics.drawable.LayerDrawable
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.annotation.Px
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
@@ -24,6 +27,23 @@ sealed class DSLSettingsIcon {
}
}
private data class FromResourceWithBackground(
@DrawableRes private val iconId: Int,
@ColorRes private val iconTintId: Int,
@DrawableRes private val backgroundId: Int,
@ColorRes private val backgroundTint: Int,
@Px private val insetPx: Int,
) : DSLSettingsIcon() {
override fun resolve(context: Context): Drawable {
return LayerDrawable(
arrayOf(
FromResource(backgroundId, backgroundTint).resolve(context),
InsetDrawable(FromResource(iconId, iconTintId).resolve(context), insetPx, insetPx, insetPx, insetPx)
)
)
}
}
private data class FromDrawable(
private val drawable: Drawable
) : DSLSettingsIcon() {
@@ -33,6 +53,17 @@ sealed class DSLSettingsIcon {
abstract fun resolve(context: Context): Drawable
companion object {
@JvmStatic
fun from(
@DrawableRes iconId: Int,
@ColorRes iconTintId: Int,
@DrawableRes backgroundId: Int,
@ColorRes backgroundTint: Int,
@Px insetPx: Int = 0
): DSLSettingsIcon {
return FromResourceWithBackground(iconId, iconTintId, backgroundId, backgroundTint, insetPx)
}
@JvmStatic
fun from(@DrawableRes iconId: Int, @ColorRes iconTintId: Int = R.color.signal_icon_tint_primary): DSLSettingsIcon = FromResource(iconId, iconTintId)

View File

@@ -366,6 +366,17 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
SignalStore.releaseChannelValues().highestVersionNoteReceived = max(SignalStore.releaseChannelValues().highestVersionNoteReceived - 10, 0)
}
)
dividerPref()
sectionHeaderPref(R.string.ConversationListTabs__stories)
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_disable_stories),
isChecked = state.disableStories,
onClick = {
viewModel.toggleStories()
}
)
}
}

View File

@@ -20,4 +20,5 @@ data class InternalSettingsState(
val removeSenderKeyMinimium: Boolean,
val delayResends: Boolean,
val disableStorageService: Boolean,
val disableStories: Boolean
)

View File

@@ -96,6 +96,12 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun toggleStories() {
val newState = !SignalStore.storyValues().isFeatureDisabled
SignalStore.storyValues().isFeatureDisabled = newState
store.update { getState().copy(disableStories = newState) }
}
private fun refresh() {
store.update { getState().copy(emojiVersion = it.emojiVersion) }
}
@@ -116,7 +122,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
emojiVersion = null,
removeSenderKeyMinimium = SignalStore.internalValues().removeSenderKeyMinimum(),
delayResends = SignalStore.internalValues().delayResends(),
disableStorageService = SignalStore.internalValues().storageServiceDisabled()
disableStorageService = SignalStore.internalValues().storageServiceDisabled(),
disableStories = SignalStore.storyValues().isFeatureDisabled
)
class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory {

View File

@@ -15,10 +15,12 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import mobi.upod.timedurationpicker.TimeDurationPicker
import mobi.upod.timedurationpicker.TimeDurationPickerDialog
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.PassphraseChangeActivity
import org.thoughtcrime.securesms.R
@@ -34,7 +36,11 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberListingMode
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.stories.settings.custom.PrivateStorySettingsFragmentArgs
import org.thoughtcrime.securesms.stories.settings.story.PrivateStoryItem
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.ExpirationUtil
@@ -71,6 +77,7 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
override fun bindAdapter(adapter: DSLSettingsAdapter) {
adapter.registerFactory(ValueClickPreference::class.java, LayoutFactory(::ValueClickPreferenceViewHolder, R.layout.value_click_preference_item))
PrivateStoryItem.register(adapter)
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val repository = PrivacySettingsRepository()
@@ -288,6 +295,55 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
summary = DSLSettingsText.from(incognitoSummary),
)
if (FeatureFlags.stories()) {
dividerPref()
sectionHeaderPref(R.string.ConversationListTabs__stories)
if (!SignalStore.storyValues().isFeatureDisabled) {
customPref(
PrivateStoryItem.RecipientModel(
recipient = Recipient.self(),
onClick = { findNavController().safeNavigate(R.id.action_privacySettings_to_myStorySettings) }
)
)
space(DimensionUnit.DP.toPixels(24f).toInt())
customPref(
PrivateStoryItem.NewModel(
onClick = {
findNavController().safeNavigate(R.id.action_privacySettings_to_newPrivateStory)
}
)
)
state.privateStories.forEach {
customPref(
PrivateStoryItem.PartialModel(
privateStoryItemData = it,
onClick = { model ->
findNavController().safeNavigate(
R.id.action_privacySettings_to_privateStorySettings,
PrivateStorySettingsFragmentArgs.Builder(model.privateStoryItemData.id).build().toBundle()
)
}
)
)
}
}
switchPref(
title = DSLSettingsText.from(R.string.PrivacySettingsFragment__share_and_view_stories),
summary = DSLSettingsText.from(R.string.PrivacySettingsFragment__you_will_no_longer_be_able),
isChecked = state.isStoriesEnabled,
onClick = {
viewModel.setStoriesEnabled(!state.isStoriesEnabled)
}
)
}
dividerPref()
clickPref(

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.privacy
import android.content.Context
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -22,6 +23,12 @@ class PrivacySettingsRepository {
}
}
fun getPrivateStories(consumer: (List<DistributionListPartialRecord>) -> Unit) {
SignalExecutors.BOUNDED.execute {
consumer(SignalDatabase.distributionLists.getCustomListsForUi())
}
}
fun syncReadReceiptState() {
SignalExecutors.BOUNDED.execute {
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.privacy
import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
data class PrivacySettingsState(
@@ -15,5 +16,7 @@ data class PrivacySettingsState(
val isObsoletePasswordEnabled: Boolean,
val isObsoletePasswordTimeoutEnabled: Boolean,
val obsoletePasswordTimeout: Int,
val universalExpireTimer: Int
val universalExpireTimer: Int,
val privateStories: List<DistributionListPartialRecord>,
val isStoriesEnabled: Boolean
)

View File

@@ -26,6 +26,11 @@ class PrivacySettingsViewModel(
store.update { it.copy(blockedCount = count) }
refresh()
}
repository.getPrivateStories { privateStories ->
store.update { it.copy(privateStories = privateStories) }
refresh()
}
}
fun setReadReceiptsEnabled(enabled: Boolean) {
@@ -83,6 +88,11 @@ class PrivacySettingsViewModel(
refresh()
}
fun setStoriesEnabled(isStoriesEnabled: Boolean) {
SignalStore.storyValues().isFeatureDisabled = !isStoriesEnabled
refresh()
}
fun refresh() {
store.update(this::updateState)
}
@@ -101,12 +111,14 @@ class PrivacySettingsViewModel(
isObsoletePasswordEnabled = !TextSecurePreferences.isPasswordDisabled(ApplicationDependencies.getApplication()),
isObsoletePasswordTimeoutEnabled = TextSecurePreferences.isPassphraseTimeoutEnabled(ApplicationDependencies.getApplication()),
obsoletePasswordTimeout = TextSecurePreferences.getPassphraseTimeoutInterval(ApplicationDependencies.getApplication()),
universalExpireTimer = SignalStore.settings().universalExpireTimer
universalExpireTimer = SignalStore.settings().universalExpireTimer,
privateStories = emptyList(),
isStoriesEnabled = !SignalStore.storyValues().isFeatureDisabled
)
}
private fun updateState(state: PrivacySettingsState): PrivacySettingsState {
return getState().copy(blockedCount = state.blockedCount)
return getState().copy(blockedCount = state.blockedCount, privateStories = state.privateStories)
}
class Factory(

View File

@@ -268,6 +268,9 @@ class ConversationSettingsFragment : DSLSettingsFragment(
recipient = state.recipient,
onAvatarClick = { avatar ->
if (!state.recipient.isSelf) {
// startActivity(StoryViewerActivity.createIntent(requireContext(), state.recipient.id))
// TODO [stories] -- If recipient has a story, go to story viewer.
requireActivity().apply {
startActivity(
AvatarPreviewActivity.intentFromRecipientId(this, state.recipient.id),

View File

@@ -195,6 +195,8 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
colorize("SenderKey", recipient.senderKeyCapability),
", ",
colorize("ChangeNumber", recipient.changeNumberCapability),
", ",
colorize("Stories", recipient.storiesCapability),
)
}

View File

@@ -3,9 +3,9 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.view.View
import androidx.core.view.ViewCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.view.AvatarView
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto
import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto
@@ -39,7 +39,7 @@ object AvatarPreference {
}
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val avatar: AvatarImageView = itemView.findViewById<AvatarImageView>(R.id.bio_preference_avatar).apply {
private val avatar: AvatarView = itemView.findViewById<AvatarView>(R.id.bio_preference_avatar).apply {
setFallbackPhotoProvider(AvatarPreferenceFallbackPhotoProvider())
}
@@ -63,7 +63,7 @@ object AvatarPreference {
}
}
avatar.setAvatar(model.recipient)
avatar.displayChatAvatar(model.recipient)
avatar.disableQuickContact()
avatar.setOnClickListener { model.onAvatarClick(avatar) }
}

View File

@@ -21,6 +21,7 @@ object LargeIconClickPreference {
class Model(
override val title: DSLSettingsText?,
override val icon: DSLSettingsIcon,
override val summary: DSLSettingsText? = null,
val onClick: () -> Unit
) : PreferenceModel<Model>()

View File

@@ -17,9 +17,9 @@ fun configure(init: DSLConfiguration.() -> Unit): DSLConfiguration {
}
class DSLConfiguration {
private val children = arrayListOf<PreferenceModel<*>>()
private val children = arrayListOf<MappingModel<*>>()
fun customPref(customPreference: PreferenceModel<*>) {
fun customPref(customPreference: MappingModel<*>) {
children.add(customPreference)
}

View File

@@ -115,14 +115,14 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
Recipient recipientSnapshot = recipient != null ? recipient.get() : null;
if (recipientSnapshot != null && !recipientSnapshot.isResolving()) {
if (recipientSnapshot != null && !recipientSnapshot.isResolving() && !recipientSnapshot.isMyStory()) {
contactName = recipientSnapshot.getDisplayName(getContext());
name = contactName;
} else if (recipient != null) {
name = "";
}
if (recipientSnapshot == null || recipientSnapshot.isResolving() || recipientSnapshot.isRegistered()) {
if (recipientSnapshot == null || recipientSnapshot.isResolving() || recipientSnapshot.isRegistered() || recipientSnapshot.isDistributionList()) {
smsTag.setVisibility(GONE);
} else {
smsTag.setVisibility(VISIBLE);
@@ -131,6 +131,9 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
if (recipientSnapshot == null || recipientSnapshot.isResolving()) {
this.contactPhotoImage.setAvatar(glideRequests, null, false);
setText(null, type, name, number, label, about);
} else if (recipientSnapshot.isMyStory()) {
this.contactPhotoImage.setRecipient(Recipient.self(), false);
setText(recipientSnapshot, type, name, number, label, about);
} else {
this.contactPhotoImage.setAvatar(glideRequests, recipientSnapshot, false);
setText(recipientSnapshot, type, name, number, label, about);
@@ -180,6 +183,9 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
this.nameView.setEnabled(true);
this.labelView.setText(label);
this.labelView.setVisibility(View.VISIBLE);
} else if (recipient != null && recipient.isDistributionList()) {
this.numberView.setText(getViewerCount(number));
this.labelView.setVisibility(View.GONE);
} else {
this.numberView.setText(!Util.isEmpty(about) ? about : number);
this.nameView.setEnabled(true);
@@ -212,6 +218,11 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
return getContext().getResources().getQuantityString(R.plurals.contact_selection_list_item__number_of_members, memberCount, memberCount);
}
private String getViewerCount(@NonNull String number) {
int viewerCount = Integer.parseInt(number);
return getContext().getResources().getQuantityString(R.plurals.contact_selection_list_item__number_of_viewers, viewerCount, viewerCount);
}
public @Nullable LiveRecipient getRecipient() {
return recipient;
}
@@ -234,13 +245,18 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
contactNumber = recipient.getGroupId().get().toString();
} else if (recipient.hasE164()) {
contactNumber = PhoneNumberFormatter.prettyPrint(recipient.getE164().or(""));
} else {
} else if (!recipient.isDistributionList()) {
contactNumber = recipient.getEmail().or("");
}
contactPhotoImage.setAvatar(glideRequests, recipient, false);
if (recipient.isMyStory()) {
contactPhotoImage.setRecipient(Recipient.self(), false);
} else {
contactPhotoImage.setAvatar(glideRequests, recipient, false);
}
setText(recipient, contactType, contactName, contactNumber, contactLabel, contactAbout);
smsTag.setVisibility(recipient.isRegistered() ? GONE : VISIBLE);
smsTag.setVisibility(recipient.isRegistered() || recipient.isDistributionList() ? GONE : VISIBLE);
badge.setBadgeFromRecipient(recipient);
} else {
Log.w(TAG, "Bad change! Local recipient doesn't match. Ignoring. Local: " + (this.recipient == null ? "null" : this.recipient.getId()) + ", Changed: " + recipient.getId());

View File

@@ -27,13 +27,18 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.phonenumbers.NumberUtil;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.UsernameUtil;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
/**
* CursorLoader that initializes a ContactsDatabase instance
@@ -55,13 +60,14 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
public static final int FLAG_HIDE_NEW = 1 << 6;
public static final int FLAG_HIDE_RECENT_HEADER = 1 << 7;
public static final int FLAG_GROUPS_AFTER_CONTACTS = 1 << 8;
public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF;
public static final int FLAG_STORIES = 1 << 9;
public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF | FLAG_STORIES;
}
private static final int RECENT_CONVERSATION_MAX = 25;
private final int mode;
private final boolean recents;
private final int mode;
private final boolean recents;
private final ContactRepository contactRepository;
@@ -85,6 +91,7 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
addRecentGroupsSection(cursorList);
addGroupsSection(cursorList);
} else {
addStoriesSection(cursorList);
addRecentsSection(cursorList);
addContactsSection(cursorList);
if (addGroupsAfterContacts(mode)) {
@@ -163,6 +170,19 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
}
}
private void addStoriesSection(@NonNull List<Cursor> cursorList) {
if (!FeatureFlags.stories() || !storiesEnabled(mode) || SignalStore.storyValues().isFeatureDisabled()) {
return;
}
Cursor stories = getStoriesCursor();
if (stories.getCount() > 0) {
cursorList.add(ContactsCursorRows.forStoriesHeader(getContext()));
cursorList.add(stories);
}
}
private void addNewNumberSection(@NonNull List<Cursor> cursorList) {
if (FeatureFlags.usernames() && NumberUtil.isVisuallyValidNumberOrEmail(getFilter())) {
cursorList.add(ContactsCursorRows.forPhoneNumberSearchHeader(getContext()));
@@ -223,6 +243,16 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
return groupContacts;
}
private Cursor getStoriesCursor() {
MatrixCursor distributionListsCursor = ContactsCursorRows.createMatrixCursor();
List<DistributionListPartialRecord> distributionLists = SignalDatabase.distributionLists().getAllListsForContactSelectionUi(null, true);
for (final DistributionListPartialRecord distributionList : distributionLists) {
distributionListsCursor.addRow(ContactsCursorRows.forDistributionList(distributionList));
}
return distributionListsCursor;
}
private Cursor getNewNumberCursor() {
return ContactsCursorRows.forNewNumber(getUnknownContactTitle(), getFilter());
}
@@ -293,16 +323,20 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
return flagSet(mode, DisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
}
private static boolean storiesEnabled(int mode) {
return flagSet(mode, DisplayMode.FLAG_STORIES);
}
private static boolean flagSet(int mode, int flag) {
return (mode & flag) > 0;
}
public static class Factory implements AbstractContactsCursorLoader.Factory {
private final Context context;
private final int displayMode;
private final String cursorFilter;
private final boolean displayRecents;
private final Context context;
private final int displayMode;
private final String cursorFilter;
private final boolean displayRecents;
public Factory(Context context, int displayMode, String cursorFilter, boolean displayRecents) {
this.context = context;

View File

@@ -9,8 +9,11 @@ import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
/**
* Helper utility for generating cursors and cursor rows for subclasses of {@link AbstractContactsCursorLoader}.
@@ -83,6 +86,16 @@ public final class ContactsCursorRows {
""};
}
public static @NonNull Object[] forDistributionList(@NonNull DistributionListPartialRecord distributionListPartialRecord) {
return new Object[]{ distributionListPartialRecord.getRecipientId().serialize(),
distributionListPartialRecord.getName(),
SignalDatabase.distributionLists().getMemberCount(distributionListPartialRecord.getId()),
ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM,
"",
ContactRepository.NORMAL_TYPE,
""};
}
/**
* Create a row for a contacts cursor for a new number the user is entering or has entered.
*/
@@ -117,6 +130,10 @@ public final class ContactsCursorRows {
return matrixCursor;
}
public static @NonNull MatrixCursor forStoriesHeader(@NonNull Context context) {
return forHeader(context.getString(R.string.ContactsCursorLoader_my_stories));
}
public static @NonNull MatrixCursor forUsernameSearchHeader(@NonNull Context context) {
return forHeader(context.getString(R.string.ContactsCursorLoader_username_search));
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.contacts
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
/**
* An action which can be attached to the first item in the list, but only if that item is a divider.
*/
class HeaderAction(@param:StringRes val label: Int, @param:DrawableRes val icon: Int, val action: Runnable) {
constructor(@StringRes label: Int, action: Runnable) : this(label, 0, action) {}
}

View File

@@ -26,6 +26,10 @@ public final class SelectedContact {
return new SelectedContact(recipientId, null, username);
}
public static @NonNull SelectedContact forRecipientId(@NonNull RecipientId recipientId) {
return new SelectedContact(recipientId, null, null);
}
private SelectedContact(@Nullable RecipientId recipientId, @Nullable String number, @Nullable String username) {
this.recipientId = recipientId;
this.number = number;

View File

@@ -0,0 +1,134 @@
package org.thoughtcrime.securesms.contacts.paged
import org.thoughtcrime.securesms.contacts.HeaderAction
/**
* A strongly typed descriptor of how a given list of contacts should be formatted
*/
class ContactSearchConfiguration private constructor(
val query: String?,
val sections: List<Section>
) {
sealed class Section(val sectionKey: SectionKey) {
abstract val includeHeader: Boolean
open val headerAction: HeaderAction? = null
abstract val expandConfig: ExpandConfig?
/**
* Distribution lists and group stories.
*/
data class Stories(
val groupStories: Set<ContactSearchData.Story> = emptySet(),
override val includeHeader: Boolean,
override val headerAction: HeaderAction? = null,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.STORIES)
/**
* Recent contacts
*/
data class Recents(
val limit: Int = 25,
val groupsOnly: Boolean = false,
val includeInactiveGroups: Boolean = false,
val includeGroupsV1: Boolean = false,
val includeSms: Boolean = false,
override val includeHeader: Boolean,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.RECENTS)
/**
* 1:1 Recipients
*/
data class Individuals(
val includeSelf: Boolean,
val transportType: TransportType,
override val includeHeader: Boolean,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.INDIVIDUALS)
/**
* Group Recipients
*/
data class Groups(
val includeMms: Boolean = false,
val includeV1: Boolean = false,
val includeInactive: Boolean = false,
val returnAsGroupStories: Boolean = false,
override val includeHeader: Boolean,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.GROUPS)
}
/**
* Describes a given section. Useful for labeling sections and managing expansion state.
*/
enum class SectionKey {
STORIES,
RECENTS,
INDIVIDUALS,
GROUPS
}
/**
* Describes how a given section can be expanded.
*/
data class ExpandConfig(
val isExpanded: Boolean,
val maxCountWhenNotExpanded: Int = 2
)
/**
* Network transport type for individual recipients.
*/
enum class TransportType {
PUSH,
SMS,
ALL
}
companion object {
/**
* DSL Style builder function. Example:
*
* ```
* val configuration = ContactSearchConfiguration.build {
* query = "My Query"
* addSection(Recents(...))
* }
* ```
*/
fun build(builderFunction: Builder.() -> Unit): ContactSearchConfiguration {
return ConfigurationBuilder().let {
it.builderFunction()
it.build()
}
}
}
/**
* Internal builder class with build method.
*/
private class ConfigurationBuilder : Builder {
private val sections: MutableList<Section> = mutableListOf()
override var query: String? = null
override fun addSection(section: Section) {
sections.add(section)
}
fun build(): ContactSearchConfiguration {
return ContactSearchConfiguration(query, sections)
}
}
/**
* Exposed Builder interface without build method.
*/
interface Builder {
var query: String?
fun addSection(section: Section)
}
}

View File

@@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.contacts.paged
import org.thoughtcrime.securesms.contacts.HeaderAction
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Represents the data backed by a ContactSearchKey
*/
sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
/**
* A row displaying a story.
*
* Note that if the recipient is a group, it's participant list size is used instead of viewerCount.
*/
data class Story(val recipient: Recipient, val viewerCount: Int) : ContactSearchData(ContactSearchKey.Story(recipient.id))
/**
* A row displaying a known recipient.
*/
data class KnownRecipient(val recipient: Recipient) : ContactSearchData(ContactSearchKey.KnownRecipient(recipient.id))
/**
* A row containing a title for a given section
*/
class Header(
val sectionKey: ContactSearchConfiguration.SectionKey,
val action: HeaderAction?
) : ContactSearchData(ContactSearchKey.Header(sectionKey))
/**
* A row which the user can click to view all entries for a given section.
*/
class Expand(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchData(ContactSearchKey.Expand(sectionKey))
}

View File

@@ -0,0 +1,247 @@
package org.thoughtcrime.securesms.contacts.paged
import android.view.View
import android.widget.CheckBox
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
/**
* Mapping Models and View Holders for ContactSearchData
*/
object ContactSearchItems {
fun register(
mappingAdapter: MappingAdapter,
recipientListener: (ContactSearchData.KnownRecipient, Boolean) -> Unit,
storyListener: (ContactSearchData.Story, Boolean) -> Unit,
expandListener: (ContactSearchData.Expand) -> Unit
) {
mappingAdapter.registerFactory(
StoryModel::class.java,
LayoutFactory({ StoryViewHolder(it, storyListener) }, R.layout.contact_search_item)
)
mappingAdapter.registerFactory(
RecipientModel::class.java,
LayoutFactory({ KnownRecipientViewHolder(it, recipientListener) }, R.layout.contact_search_item)
)
mappingAdapter.registerFactory(
HeaderModel::class.java,
LayoutFactory({ HeaderViewHolder(it) }, R.layout.contact_search_section_header)
)
mappingAdapter.registerFactory(
ExpandModel::class.java,
LayoutFactory({ ExpandViewHolder(it, expandListener) }, R.layout.contacts_expand_item)
)
}
fun toMappingModelList(contactSearchData: List<ContactSearchData?>, selection: Set<ContactSearchKey>): MappingModelList {
return MappingModelList(
contactSearchData.filterNotNull().map {
when (it) {
is ContactSearchData.Story -> StoryModel(it, selection.contains(it.contactSearchKey))
is ContactSearchData.KnownRecipient -> RecipientModel(it, selection.contains(it.contactSearchKey))
is ContactSearchData.Expand -> ExpandModel(it)
is ContactSearchData.Header -> HeaderModel(it)
}
}
)
}
/**
* Story Model
*/
private class StoryModel(val story: ContactSearchData.Story, val isSelected: Boolean) : MappingModel<StoryModel> {
override fun areItemsTheSame(newItem: StoryModel): Boolean {
return newItem.story == story
}
override fun areContentsTheSame(newItem: StoryModel): Boolean {
return story.recipient.hasSameContent(newItem.story.recipient) && isSelected == newItem.isSelected
}
override fun getChangePayload(newItem: StoryModel): Any? {
return if (story.recipient.hasSameContent(newItem.story.recipient) && newItem.isSelected != isSelected) {
0
} else {
null
}
}
}
private class StoryViewHolder(itemView: View, onClick: (ContactSearchData.Story, Boolean) -> Unit) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(itemView, onClick) {
override fun isSelected(model: StoryModel): Boolean = model.isSelected
override fun getData(model: StoryModel): ContactSearchData.Story = model.story
override fun getRecipient(model: StoryModel): Recipient = model.story.recipient
override fun bindNumberField(model: StoryModel) {
number.visible = true
val count = if (model.story.recipient.isGroup) {
model.story.recipient.participants.size
} else {
model.story.viewerCount
}
number.text = context.resources.getQuantityString(R.plurals.SelectViewersFragment__d_viewers, count, count)
}
}
/**
* Recipient model
*/
private class RecipientModel(val knownRecipient: ContactSearchData.KnownRecipient, val isSelected: Boolean) : MappingModel<RecipientModel> {
override fun areItemsTheSame(newItem: RecipientModel): Boolean {
return newItem.knownRecipient == knownRecipient
}
override fun areContentsTheSame(newItem: RecipientModel): Boolean {
return knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && isSelected == newItem.isSelected
}
override fun getChangePayload(newItem: RecipientModel): Any? {
return if (knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && newItem.isSelected != isSelected) {
0
} else {
null
}
}
}
private class KnownRecipientViewHolder(itemView: View, onClick: (ContactSearchData.KnownRecipient, Boolean) -> Unit) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, onClick) {
override fun isSelected(model: RecipientModel): Boolean = model.isSelected
override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient
override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
}
/**
* Base Recipient View Holder
*/
private abstract class BaseRecipientViewHolder<T, D : ContactSearchData>(itemView: View, val onClick: (D, Boolean) -> Unit) : MappingViewHolder<T>(itemView) {
protected val avatar: AvatarImageView = itemView.findViewById(R.id.contact_photo_image)
protected val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
protected val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
protected val name: TextView = itemView.findViewById(R.id.name)
protected val number: TextView = itemView.findViewById(R.id.number)
protected val label: TextView = itemView.findViewById(R.id.label)
protected val smsTag: View = itemView.findViewById(R.id.sms_tag)
override fun bind(model: T) {
checkbox.isChecked = isSelected(model)
itemView.setOnClickListener { onClick(getData(model), isSelected(model)) }
if (payload.isNotEmpty()) {
return
}
if (getRecipient(model).isSelf) {
name.setText(R.string.note_to_self)
} else {
name.text = getRecipient(model).getDisplayName(context)
}
avatar.setAvatar(getRecipient(model))
badge.setBadgeFromRecipient(getRecipient(model))
bindNumberField(model)
bindLabelField(model)
bindSmsTagField(model)
}
protected open fun bindNumberField(model: T) {
number.visible = getRecipient(model).isGroup
if (getRecipient(model).isGroup) {
val members = getRecipient(model).participants.size
number.text = context.resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, members, members)
}
}
protected open fun bindLabelField(model: T) {
label.visible = false
}
protected open fun bindSmsTagField(model: T) {
smsTag.visible = false
}
abstract fun isSelected(model: T): Boolean
abstract fun getData(model: T): D
abstract fun getRecipient(model: T): Recipient
}
/**
* Mapping Model for section headers
*/
private class HeaderModel(val header: ContactSearchData.Header) : MappingModel<HeaderModel> {
override fun areItemsTheSame(newItem: HeaderModel): Boolean {
return header.sectionKey == newItem.header.sectionKey
}
override fun areContentsTheSame(newItem: HeaderModel): Boolean {
return areItemsTheSame(newItem) &&
header.action?.icon == newItem.header.action?.icon &&
header.action?.label == newItem.header.action?.label
}
}
/**
* View Holder for section headers
*/
private class HeaderViewHolder(itemView: View) : MappingViewHolder<HeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
private val headerActionView: TextView = itemView.findViewById(R.id.section_header_action)
override fun bind(model: HeaderModel) {
headerTextView.setText(
when (model.header.sectionKey) {
ContactSearchConfiguration.SectionKey.STORIES -> R.string.ContactsCursorLoader_my_stories
ContactSearchConfiguration.SectionKey.RECENTS -> R.string.ContactsCursorLoader_recent_chats
ContactSearchConfiguration.SectionKey.INDIVIDUALS -> R.string.ContactsCursorLoader_contacts
ContactSearchConfiguration.SectionKey.GROUPS -> R.string.ContactsCursorLoader_groups
}
)
if (model.header.action != null) {
headerActionView.visible = true
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(model.header.action.icon, 0, 0, 0)
headerActionView.setText(model.header.action.label)
headerActionView.setOnClickListener { model.header.action.action.run() }
} else {
headerActionView.visible = false
}
}
}
/**
* Mapping Model for expandable content rows.
*/
private class ExpandModel(val expand: ContactSearchData.Expand) : MappingModel<ExpandModel> {
override fun areItemsTheSame(newItem: ExpandModel): Boolean {
return expand.contactSearchKey == newItem.expand.contactSearchKey
}
override fun areContentsTheSame(newItem: ExpandModel): Boolean {
return areItemsTheSame(newItem)
}
}
/**
* View Holder for expandable content rows.
*/
private class ExpandViewHolder(itemView: View, private val expandListener: (ContactSearchData.Expand) -> Unit) : MappingViewHolder<ExpandModel>(itemView) {
override fun bind(model: ExpandModel) {
itemView.setOnClickListener { expandListener.invoke(model.expand) }
}
}
}

View File

@@ -0,0 +1,76 @@
package org.thoughtcrime.securesms.contacts.paged
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.ShareContact
/**
* Represents a row in a list of Contact results.
*/
sealed class ContactSearchKey {
/**
* Generates a ShareContact object used to display which contacts have been selected. This should *not*
* be used for the final sharing process, as it is not always truthful about, for example, KnownRecipient of
* a group vs. a group's Story.
*/
open fun requireShareContact(): ShareContact = error("This key cannot be converted into a ShareContact")
open fun requireParcelable(): Parcelable = error("This key cannot be parcelized")
/**
* Key to a Story
*/
data class Story(override val recipientId: RecipientId) : ContactSearchKey(), RecipientSearchKey {
override fun requireShareContact(): ShareContact {
return ShareContact(recipientId)
}
override fun requireParcelable(): Parcelable {
return ParcelableContactSearchKey(ParcelableType.STORY, recipientId)
}
override val isStory: Boolean = true
}
/**
* Key to a recipient which already exists in our database
*/
data class KnownRecipient(override val recipientId: RecipientId) : ContactSearchKey(), RecipientSearchKey {
override fun requireShareContact(): ShareContact {
return ShareContact(recipientId)
}
override fun requireParcelable(): Parcelable {
return ParcelableContactSearchKey(ParcelableType.KNOWN_RECIPIENT, recipientId)
}
override val isStory: Boolean = false
}
/**
* Key to a header for a given section
*/
data class Header(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchKey()
/**
* Key to an expand button for a given section
*/
data class Expand(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchKey()
@Parcelize
data class ParcelableContactSearchKey(val type: ParcelableType, val recipientId: RecipientId) : Parcelable {
fun asContactSearchKey(): ContactSearchKey {
return when (type) {
ParcelableType.STORY -> Story(recipientId)
ParcelableType.KNOWN_RECIPIENT -> KnownRecipient(recipientId)
}
}
}
enum class ParcelableType {
STORY,
KNOWN_RECIPIENT
}
}

View File

@@ -0,0 +1,81 @@
package org.thoughtcrime.securesms.contacts.paged
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
class ContactSearchMediator(
fragment: Fragment,
recyclerView: RecyclerView,
selectionLimits: SelectionLimits,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration
) {
private val viewModel: ContactSearchViewModel = ViewModelProvider(fragment, ContactSearchViewModel.Factory(selectionLimits, ContactSearchRepository())).get(ContactSearchViewModel::class.java)
init {
val adapter = PagingMappingAdapter<ContactSearchKey>()
recyclerView.adapter = adapter
ContactSearchItems.register(
mappingAdapter = adapter,
recipientListener = this::toggleSelection,
storyListener = this::toggleSelection,
expandListener = { viewModel.expandSection(it.sectionKey) }
)
val dataAndSelection: LiveData<Pair<List<ContactSearchData>, Set<ContactSearchKey>>> = LiveDataUtil.combineLatest(
viewModel.data,
viewModel.selectionState,
::Pair
)
dataAndSelection.observe(fragment.viewLifecycleOwner) { (data, selection) ->
adapter.submitList(ContactSearchItems.toMappingModelList(data, selection))
}
viewModel.controller.observe(fragment.viewLifecycleOwner) { controller ->
adapter.setPagingController(controller)
}
viewModel.configurationState.observe(fragment.viewLifecycleOwner) {
viewModel.setConfiguration(mapStateToConfiguration(it))
}
}
fun onFilterChanged(filter: String?) {
viewModel.setQuery(filter)
}
fun setKeysSelected(keys: Set<ContactSearchKey>) {
viewModel.setKeysSelected(keys)
}
fun setKeysNotSelected(keys: Set<ContactSearchKey>) {
viewModel.setKeysNotSelected(keys)
}
fun getSelectedContacts(): Set<ContactSearchKey> {
return viewModel.getSelectedContacts()
}
fun getSelectionState(): LiveData<Set<ContactSearchKey>> {
return viewModel.selectionState
}
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.Story>) {
viewModel.addToVisibleGroupStories(groupStories)
}
private fun toggleSelection(contactSearchData: ContactSearchData, isSelected: Boolean) {
if (isSelected) {
viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey))
} else {
viewModel.setKeysSelected(setOf(contactSearchData.contactSearchKey))
}
}
}

View File

@@ -0,0 +1,260 @@
package org.thoughtcrime.securesms.contacts.paged
import android.database.Cursor
import org.signal.paging.PagedDataSource
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import kotlin.math.min
/**
* Manages the querying of contact information based off a configuration.
*/
class ContactSearchPagedDataSource(
private val contactConfiguration: ContactSearchConfiguration,
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(ApplicationDependencies.getApplication())
) : PagedDataSource<ContactSearchKey, ContactSearchData> {
override fun size(): Int {
return contactConfiguration.sections.sumBy {
getSectionSize(it, contactConfiguration.query)
}
}
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<ContactSearchData> {
val sizeMap: Map<ContactSearchConfiguration.Section, Int> = contactConfiguration.sections.associateWith { getSectionSize(it, contactConfiguration.query) }
val startIndex: Index = findIndex(sizeMap, start)
val endIndex: Index = findIndex(sizeMap, start + length)
val indexOfStartSection = contactConfiguration.sections.indexOf(startIndex.category)
val indexOfEndSection = contactConfiguration.sections.indexOf(endIndex.category)
val results: List<List<ContactSearchData>> = contactConfiguration.sections.mapIndexed { index, section ->
if (index in indexOfStartSection..indexOfEndSection) {
getSectionData(
section = section,
query = contactConfiguration.query,
startIndex = if (index == indexOfStartSection) startIndex.offset else 0,
endIndex = if (index == indexOfEndSection) endIndex.offset else sizeMap[section] ?: error("Unknown section")
)
} else {
emptyList()
}
}
return results.flatten().toMutableList()
}
private fun findIndex(sizeMap: Map<ContactSearchConfiguration.Section, Int>, target: Int): Index {
var offset = 0
sizeMap.forEach { (key, size) ->
if (offset + size > target) {
return Index(key, target - offset)
}
offset += size
}
return Index(sizeMap.keys.last(), sizeMap.values.last())
}
data class Index(val category: ContactSearchConfiguration.Section, val offset: Int)
override fun load(key: ContactSearchKey?): ContactSearchData? {
throw UnsupportedOperationException()
}
override fun getKey(data: ContactSearchData): ContactSearchKey {
return data.contactSearchKey
}
private fun getSectionSize(section: ContactSearchConfiguration.Section, query: String?): Int {
val cursor: Cursor = when (section) {
is ContactSearchConfiguration.Section.Individuals -> getNonGroupContactsCursor(section, query)
is ContactSearchConfiguration.Section.Groups -> contactSearchPagedDataSourceRepository.getGroupContacts(section, query)
is ContactSearchConfiguration.Section.Recents -> getRecentsCursor(section, query)
is ContactSearchConfiguration.Section.Stories -> getStoriesCursor(query)
}!!
val extras: List<ContactSearchData> = when (section) {
is ContactSearchConfiguration.Section.Stories -> getFilteredGroupStories(section, query)
else -> emptyList()
}
val collection = ResultsCollection(
section = section,
cursor = cursor,
extraData = extras,
cursorMapper = { error("Unsupported") }
)
return collection.getSize()
}
private fun getFilteredGroupStories(section: ContactSearchConfiguration.Section.Stories, query: String?): List<ContactSearchData> {
return section.groupStories.filter { contactSearchPagedDataSourceRepository.recipientNameContainsQuery(it.recipient, query) }
}
private fun getSectionData(section: ContactSearchConfiguration.Section, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return when (section) {
is ContactSearchConfiguration.Section.Groups -> getGroupContactsData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.Individuals -> getNonGroupContactsData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.Recents -> getRecentsContactData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.Stories -> getStoriesContactData(section, query, startIndex, endIndex)
}
}
private fun getNonGroupContactsCursor(section: ContactSearchConfiguration.Section.Individuals, query: String?): Cursor? {
return when (section.transportType) {
ContactSearchConfiguration.TransportType.PUSH -> contactSearchPagedDataSourceRepository.querySignalContacts(query, section.includeSelf)
ContactSearchConfiguration.TransportType.SMS -> contactSearchPagedDataSourceRepository.queryNonSignalContacts(query)
ContactSearchConfiguration.TransportType.ALL -> contactSearchPagedDataSourceRepository.queryNonGroupContacts(query, section.includeSelf)
}
}
private fun getStoriesCursor(query: String?): Cursor? {
return contactSearchPagedDataSourceRepository.getStories(query)
}
private fun getRecentsCursor(section: ContactSearchConfiguration.Section.Recents, query: String?): Cursor? {
if (!query.isNullOrEmpty()) {
throw IllegalArgumentException("Searching Recents is not supported")
}
return contactSearchPagedDataSourceRepository.getRecents(section)
}
private fun readContactDataFromCursor(
cursor: Cursor,
section: ContactSearchConfiguration.Section,
startIndex: Int,
endIndex: Int,
cursorRowToData: (Cursor) -> ContactSearchData,
extraData: List<ContactSearchData> = emptyList()
): List<ContactSearchData> {
val results = mutableListOf<ContactSearchData>()
val collection = ResultsCollection(section, cursor, extraData, cursorRowToData)
results.addAll(collection.getSublist(startIndex, endIndex))
return results
}
private fun getStoriesContactData(section: ContactSearchConfiguration.Section.Stories, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getStoriesCursor(query)?.use { cursor ->
readContactDataFromCursor(
cursor = cursor,
section = section,
startIndex = startIndex,
endIndex = endIndex,
cursorRowToData = {
val recipient = contactSearchPagedDataSourceRepository.getRecipientFromDistributionListCursor(it)
ContactSearchData.Story(recipient, contactSearchPagedDataSourceRepository.getDistributionListMembershipCount(recipient))
},
extraData = getFilteredGroupStories(section, query)
)
} ?: emptyList()
}
private fun getRecentsContactData(section: ContactSearchConfiguration.Section.Recents, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getRecentsCursor(section, query)?.use { cursor ->
readContactDataFromCursor(
cursor = cursor,
section = section,
startIndex = startIndex,
endIndex = endIndex,
cursorRowToData = {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromThreadCursor(cursor))
}
)
} ?: emptyList()
}
private fun getNonGroupContactsData(section: ContactSearchConfiguration.Section.Individuals, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getNonGroupContactsCursor(section, query)?.use { cursor ->
readContactDataFromCursor(
cursor = cursor,
section = section,
startIndex = startIndex,
endIndex = endIndex,
cursorRowToData = {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromRecipientCursor(cursor))
}
)
} ?: emptyList()
}
private fun getGroupContactsData(section: ContactSearchConfiguration.Section.Groups, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return contactSearchPagedDataSourceRepository.getGroupContacts(section, query)?.use { cursor ->
readContactDataFromCursor(
cursor = cursor,
section = section,
startIndex = startIndex,
endIndex = endIndex,
cursorRowToData = {
if (section.returnAsGroupStories) {
ContactSearchData.Story(contactSearchPagedDataSourceRepository.getRecipientFromGroupCursor(cursor), 0)
} else {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromGroupCursor(cursor))
}
}
)
} ?: emptyList()
}
/**
* We assume that the collection is [cursor contents] + [extraData contents]
*/
private data class ResultsCollection(
val section: ContactSearchConfiguration.Section,
val cursor: Cursor,
val extraData: List<ContactSearchData>,
val cursorMapper: (Cursor) -> ContactSearchData
) {
private val contentSize = cursor.count + extraData.count()
fun getSize(): Int {
val contentsAndExpand = min(
section.expandConfig?.let {
if (it.isExpanded) Int.MAX_VALUE else (it.maxCountWhenNotExpanded + 1)
} ?: Int.MAX_VALUE,
contentSize
)
return contentsAndExpand + (if (contentsAndExpand > 0 && section.includeHeader) 1 else 0)
}
fun getSublist(start: Int, end: Int): List<ContactSearchData> {
val results = mutableListOf<ContactSearchData>()
for (i in start until end) {
results.add(getItemAt(i))
}
return results
}
private fun getItemAt(index: Int): ContactSearchData {
return when {
index == 0 && section.includeHeader -> ContactSearchData.Header(section.sectionKey, section.headerAction)
index == getSize() - 1 && shouldDisplayExpandRow() -> ContactSearchData.Expand(section.sectionKey)
else -> {
val correctedIndex = if (section.includeHeader) index - 1 else index
if (correctedIndex < cursor.count) {
cursor.moveToPosition(correctedIndex)
cursorMapper.invoke(cursor)
} else {
val extraIndex = correctedIndex - cursor.count
extraData[extraIndex]
}
}
}
}
private fun shouldDisplayExpandRow(): Boolean {
val expandConfig = section.expandConfig
return when {
expandConfig == null || expandConfig.isExpanded -> false
else -> contentSize > expandConfig.maxCountWhenNotExpanded + 1
}
}
}
}

View File

@@ -0,0 +1,87 @@
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.database.Cursor
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.ContactRepository
import org.thoughtcrime.securesms.database.DistributionListDatabase
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.CursorUtil
/**
* Database boundary interface which allows us to safely unit test the data source without
* having to deal with database access.
*/
open class ContactSearchPagedDataSourceRepository(
private val context: Context
) {
private val contactRepository = ContactRepository(context, context.getString(R.string.note_to_self))
open fun querySignalContacts(query: String?, includeSelf: Boolean): Cursor? {
return contactRepository.querySignalContacts(query ?: "", includeSelf)
}
open fun queryNonSignalContacts(query: String?): Cursor? {
return contactRepository.queryNonSignalContacts(query ?: "")
}
open fun queryNonGroupContacts(query: String?, includeSelf: Boolean): Cursor? {
return contactRepository.queryNonGroupContacts(query ?: "", includeSelf)
}
open fun getGroupContacts(section: ContactSearchConfiguration.Section.Groups, query: String?): Cursor? {
return SignalDatabase.groups.getGroupsFilteredByTitle(query ?: "", section.includeInactive, !section.includeV1, !section.includeMms).cursor
}
open fun getRecents(section: ContactSearchConfiguration.Section.Recents): Cursor? {
return SignalDatabase.threads.getRecentConversationList(
section.limit,
section.includeInactiveGroups,
section.groupsOnly,
!section.includeGroupsV1,
!section.includeSms
)
}
open fun getStories(query: String?): Cursor? {
return SignalDatabase.distributionLists.getAllListsForContactSelectionUiCursor(query, myStoryContainsQuery(query ?: ""))
}
open fun getRecipientFromDistributionListCursor(cursor: Cursor): Recipient {
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, DistributionListDatabase.RECIPIENT_ID)))
}
open fun getRecipientFromThreadCursor(cursor: Cursor): Recipient {
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, ThreadDatabase.RECIPIENT_ID)))
}
open fun getRecipientFromRecipientCursor(cursor: Cursor): Recipient {
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, ContactRepository.ID_COLUMN)))
}
open fun getRecipientFromGroupCursor(cursor: Cursor): Recipient {
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, GroupDatabase.RECIPIENT_ID)))
}
open fun getDistributionListMembershipCount(recipient: Recipient): Int {
return SignalDatabase.distributionLists.getMemberCount(recipient.requireDistributionListId())
}
open fun recipientNameContainsQuery(recipient: Recipient, query: String?): Boolean {
return query.isNullOrBlank() || recipient.getDisplayName(context).contains(query)
}
open fun myStoryContainsQuery(query: String): Boolean {
if (query.isEmpty()) {
return true
}
val myStory = context.getString(R.string.Recipient_my_story)
return myStory.contains(query)
}
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.contacts.paged
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
class ContactSearchRepository {
fun filterOutUnselectableContactSearchKeys(contactSearchKeys: Set<ContactSearchKey>): Single<Set<ContactSearchSelectionResult>> {
return Single.fromCallable {
contactSearchKeys.map {
val isSelectable = when (it) {
is ContactSearchKey.Expand -> false
is ContactSearchKey.Header -> false
is ContactSearchKey.KnownRecipient -> canSelectRecipient(it.recipientId)
is ContactSearchKey.Story -> canSelectRecipient(it.recipientId)
}
ContactSearchSelectionResult(it, isSelectable)
}.toSet()
}
}
private fun canSelectRecipient(recipientId: RecipientId): Boolean {
val recipient = Recipient.resolved(recipientId)
return if (recipient.isPushV2Group) {
val record = SignalDatabase.groups.getGroup(recipient.requireGroupId())
!(record.isPresent && record.get().isAnnouncementGroup && !record.get().isAdmin(Recipient.self()))
} else {
true
}
}
}

View File

@@ -0,0 +1,3 @@
package org.thoughtcrime.securesms.contacts.paged
data class ContactSearchSelectionResult(val key: ContactSearchKey, val isSelectable: Boolean)

View File

@@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.contacts.paged
/**
* Simple search state for contacts.
*/
data class ContactSearchState(
val query: String? = null,
val expandedSections: Set<ContactSearchConfiguration.SectionKey> = emptySet(),
val groupStories: Set<ContactSearchData.Story> = emptySet()
)

View File

@@ -0,0 +1,104 @@
package org.thoughtcrime.securesms.contacts.paged
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.signal.paging.PagedData
import org.signal.paging.PagingConfig
import org.signal.paging.PagingController
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.livedata.Store
/**
* Simple, reusable view model that manages a ContactSearchPagedDataSource as well as filter and expansion state.
*/
class ContactSearchViewModel(
private val selectionLimits: SelectionLimits,
private val contactSearchRepository: ContactSearchRepository
) : ViewModel() {
private val disposables = CompositeDisposable()
private val pagingConfig = PagingConfig.Builder()
.setBufferPages(1)
.setPageSize(20)
.setStartIndex(0)
.build()
private val pagedData = MutableLiveData<PagedData<ContactSearchKey, ContactSearchData>>()
private val configurationStore = Store(ContactSearchState())
private val selectionStore = Store<Set<ContactSearchKey>>(emptySet())
val controller: LiveData<PagingController<ContactSearchKey>> = Transformations.map(pagedData) { it.controller }
val data: LiveData<List<ContactSearchData>> = Transformations.switchMap(pagedData) { it.data }
val configurationState: LiveData<ContactSearchState> = configurationStore.stateLiveData
val selectionState: LiveData<Set<ContactSearchKey>> = selectionStore.stateLiveData
override fun onCleared() {
disposables.clear()
}
fun setConfiguration(contactSearchConfiguration: ContactSearchConfiguration) {
val pagedDataSource = ContactSearchPagedDataSource(contactSearchConfiguration)
pagedData.value = PagedData.create(pagedDataSource, pagingConfig)
}
fun setQuery(query: String?) {
configurationStore.update { it.copy(query = query) }
}
fun expandSection(sectionKey: ContactSearchConfiguration.SectionKey) {
configurationStore.update { it.copy(expandedSections = it.expandedSections + sectionKey) }
}
fun setKeysSelected(contactSearchKeys: Set<ContactSearchKey>) {
disposables += contactSearchRepository.filterOutUnselectableContactSearchKeys(contactSearchKeys).subscribe { results ->
if (results.any { !it.isSelectable }) {
// TODO [alex] -- Pop an error.
return@subscribe
}
val newSelectionEntries = results.filter { it.isSelectable }.map { it.key } - getSelectedContacts()
val newSelectionSize = newSelectionEntries.size + getSelectedContacts().size
if (selectionLimits.hasRecommendedLimit() && getSelectedContacts().size < selectionLimits.recommendedLimit && newSelectionSize >= selectionLimits.recommendedLimit) {
// Pop a warning
} else if (selectionLimits.hasHardLimit() && newSelectionSize > selectionLimits.hardLimit) {
// Pop an error
return@subscribe
}
selectionStore.update { state -> state + newSelectionEntries }
}
}
fun setKeysNotSelected(contactSearchKeys: Set<ContactSearchKey>) {
selectionStore.update { it - contactSearchKeys }
}
fun getSelectedContacts(): Set<ContactSearchKey> {
return selectionStore.state
}
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.Story>) {
configurationStore.update { state ->
state.copy(
groupStories = state.groupStories + groupStories.map {
val recipient = Recipient.resolved(it.recipientId)
ContactSearchData.Story(recipient, recipient.participants.size)
}
)
}
}
class Factory(private val selectionLimits: SelectionLimits, private val repository: ContactSearchRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(ContactSearchViewModel(selectionLimits, repository)) as T
}
}
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.contacts.paged
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* A Contact Search Key that is backed by a recipient, along with information about whether it is a story.
*/
interface RecipientSearchKey {
val recipientId: RecipientId
val isStory: Boolean
}

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.contacts.selection
import android.os.Bundle
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.RecipientId
data class ContactSelectionArguments(
val displayMode: Int = ContactsCursorLoader.DisplayMode.FLAG_ALL,
val isRefreshable: Boolean = true,
val displayRecents: Boolean = false,
val selectionLimits: SelectionLimits? = null,
val currentSelection: List<RecipientId> = emptyList(),
val displaySelectionCount: Boolean = true,
val canSelectSelf: Boolean = selectionLimits == null,
val displayChips: Boolean = true,
val recyclerPadBottom: Int = -1,
val recyclerChildClipping: Boolean = true
) {
fun toArgumentBundle(): Bundle {
return Bundle().apply {
putInt(DISPLAY_MODE, displayMode)
putBoolean(REFRESHABLE, isRefreshable)
putBoolean(RECENTS, displayRecents)
putParcelable(SELECTION_LIMITS, selectionLimits)
putBoolean(HIDE_COUNT, !displaySelectionCount)
putBoolean(CAN_SELECT_SELF, canSelectSelf)
putBoolean(DISPLAY_CHIPS, displayChips)
putInt(RV_PADDING_BOTTOM, recyclerPadBottom)
putBoolean(RV_CLIP, recyclerChildClipping)
putParcelableArrayList(CURRENT_SELECTION, ArrayList(currentSelection))
}
}
companion object {
const val DISPLAY_MODE = "display_mode"
const val REFRESHABLE = "refreshable"
const val RECENTS = "recents"
const val SELECTION_LIMITS = "selection_limits"
const val CURRENT_SELECTION = "current_selection"
const val HIDE_COUNT = "hide_count"
const val CAN_SELECT_SELF = "can_select_self"
const val DISPLAY_CHIPS = "display_chips"
const val RV_PADDING_BOTTOM = "recycler_view_padding_bottom"
const val RV_CLIP = "recycler_view_clipping"
}
}

View File

@@ -93,6 +93,7 @@ import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer;
import org.thoughtcrime.securesms.conversation.mutiselect.ConversationItemAnimator;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardBottomSheet;
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment;
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs;
import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog;
@@ -185,7 +186,7 @@ import java.util.concurrent.ExecutionException;
import kotlin.Unit;
@SuppressLint("StaticFieldLeak")
public class ConversationFragment extends LoggingFragment implements MultiselectForwardFragment.Callback {
public class ConversationFragment extends LoggingFragment implements MultiselectForwardBottomSheet.Callback {
private static final String TAG = Log.tag(ConversationFragment.class);
private static final int SCROLL_ANIMATION_THRESHOLD = 50;
@@ -1013,7 +1014,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
MultiselectForwardFragmentArgs.create(requireContext(),
multiselectParts,
args -> MultiselectForwardFragment.show(getChildFragmentManager(), args));
args -> MultiselectForwardFragment.showBottomSheet(getChildFragmentManager(), args));
}
private void handleResendMessage(final MessageRecord message) {
@@ -1307,6 +1308,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
}
@Override
public void onDismissForwardSheet() {
}
public interface ConversationFragmentListener extends VoiceNoteMediaControllerOwner {
boolean isKeyboardOpen();

View File

@@ -108,6 +108,7 @@ import org.thoughtcrime.securesms.PromptMmsActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.ShortcutLauncherActivity;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.TombstoneAttachment;
import org.thoughtcrime.securesms.audio.AudioRecorder;
@@ -300,6 +301,7 @@ import org.whispersystems.signalservice.api.SignalSessionLock;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
@@ -2955,7 +2957,7 @@ public class ConversationParentFragment extends Fragment
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orNull();
List<Mention> mentions = new ArrayList<>(result.getMentions());
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, quote, Collections.emptyList(), Collections.emptyList(), mentions);
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, result.isStory(), null, quote, Collections.emptyList(), Collections.emptyList(), mentions);
OutgoingMediaMessage secureMessage = new OutgoingSecureMediaMessage(message);
final Context context = requireContext().getApplicationContext();
@@ -3031,7 +3033,7 @@ public class ConversationParentFragment extends Fragment
}
}
OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(Recipient.resolved(recipientId), slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, viewOnce, distributionType, quote, contacts, previews, mentions);
OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(Recipient.resolved(recipientId), slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, viewOnce, distributionType, false, null, quote, contacts, previews, mentions);
final SettableFuture<Void> future = new SettableFuture<>();
final Context context = requireContext().getApplicationContext();

View File

@@ -17,6 +17,7 @@ import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.view.AvatarView;
import org.thoughtcrime.securesms.badges.BadgeImageView;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.mms.GlideRequests;
@@ -29,7 +30,7 @@ import org.thoughtcrime.securesms.util.ViewUtil;
public class ConversationTitleView extends RelativeLayout {
private AvatarImageView avatar;
private AvatarView avatar;
private BadgeImageView badge;
private TextView title;
private TextView subtitle;
@@ -111,7 +112,7 @@ public class ConversationTitleView extends RelativeLayout {
title.setCompoundDrawablesRelativeWithIntrinsicBounds(startDrawable, null, endDrawable, null);
if (recipient != null) {
this.avatar.setAvatar(glideRequests, recipient, false);
this.avatar.displayChatAvatar(glideRequests, recipient, false);
}
if (recipient == null || recipient.isSelf()) {

View File

@@ -10,8 +10,11 @@ import android.graphics.drawable.Drawable
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.OvalShape
import android.os.Build
import android.os.Parcel
import android.os.Parcelable
import androidx.annotation.ColorInt
import com.google.common.base.Objects
import kotlinx.parcelize.Parcelize
import org.signal.core.util.ColorUtil
import org.thoughtcrime.securesms.components.RotatableGradientDrawable
import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor
@@ -25,11 +28,12 @@ import kotlin.math.min
* @param linearGradient The LinearGradient to render. Null if this is for a single color.
* @param singleColor The single color to render. Null if this is for a linear gradient.
*/
@Parcelize
class ChatColors private constructor(
val id: Id,
private val linearGradient: LinearGradient?,
private val singleColor: Int?
) {
) : Parcelable {
fun isGradient(): Boolean = Build.VERSION.SDK_INT >= 21 && linearGradient != null
@@ -182,7 +186,7 @@ class ChatColors private constructor(
ChatColors(id, null, color)
}
sealed class Id(val longValue: Long) {
sealed class Id(val longValue: Long) : Parcelable {
/**
* Represents user selection of 'auto'.
*/
@@ -211,6 +215,12 @@ class ChatColors private constructor(
return Objects.hashCode(longValue)
}
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeLong(longValue)
}
override fun describeContents(): Int = 0
companion object {
@JvmStatic
fun forLongValue(longValue: Long): Id {
@@ -221,14 +231,26 @@ class ChatColors private constructor(
else -> Custom(longValue)
}
}
@JvmField
val CREATOR = object : Parcelable.Creator<Id> {
override fun createFromParcel(parcel: Parcel): Id {
return forLongValue(parcel.readLong())
}
override fun newArray(size: Int): Array<Id?> {
return arrayOfNulls(size)
}
}
}
}
@Parcelize
data class LinearGradient(
val degrees: Float,
val colors: IntArray,
val positions: FloatArray
) {
) : Parcelable {
override fun equals(other: Any?): Boolean {
if (this === other) return true

View File

@@ -0,0 +1,63 @@
package org.thoughtcrime.securesms.conversation.colors
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import com.annimon.stream.Stream
import org.signal.core.util.MapUtil
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette.Names.all
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.LiveGroup
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry.FullMember
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.DefaultValueLiveData
import org.whispersystems.libsignal.util.guava.Optional
import java.util.HashMap
import java.util.HashSet
object NameColors {
fun createSessionMembersCache(): MutableMap<GroupId, Set<Recipient>> {
return mutableMapOf()
}
fun getNameColorsMapLiveData(
recipientId: LiveData<RecipientId>,
sessionMemberCache: MutableMap<GroupId, Set<Recipient>>
): LiveData<Map<RecipientId, NameColor>> {
val recipient = Transformations.switchMap(recipientId) { r: RecipientId? -> Recipient.live(r!!).liveData }
val group = Transformations.map(recipient) { obj: Recipient -> obj.groupId }
val groupMembers = Transformations.switchMap(group) { g: Optional<GroupId> ->
g.transform { groupId: GroupId -> this.getSessionGroupRecipients(groupId, sessionMemberCache) }
.or { DefaultValueLiveData(emptySet()) }
}
return Transformations.map(groupMembers) { members: Set<Recipient>? ->
val sorted = Stream.of(members)
.filter { member: Recipient? -> member != Recipient.self() }
.sortBy { obj: Recipient -> obj.requireStringId() }
.toList()
val names = all
val colors: MutableMap<RecipientId, NameColor> = HashMap()
for (i in sorted.indices) {
colors[sorted[i].id] = names[i % names.size]
}
colors
}
}
private fun getSessionGroupRecipients(groupId: GroupId, sessionMemberCache: MutableMap<GroupId, Set<Recipient>>): LiveData<Set<Recipient>> {
val fullMembers = Transformations.map(
LiveGroup(groupId).fullMembers
) { members: List<FullMember>? ->
Stream.of(members)
.map { it.member }
.toList()
}
return Transformations.map(fullMembers) { currentMembership: List<Recipient>? ->
val cachedMembers: MutableSet<Recipient> = MapUtil.getOrDefault(sessionMemberCache, groupId, HashSet()).toMutableSet()
cachedMembers.addAll(currentMembership!!)
sessionMemberCache[groupId] = cachedMembers
cachedMembers
}
}
}

View File

@@ -0,0 +1,67 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.setFragmentResult
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.fragments.findListener
class MultiselectForwardBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(), MultiselectForwardFragment.Callback {
override val peekHeightPercentage: Float = 0.67f
private var callback: Callback? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.multiselect_bottom_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
callback = findListener<Callback>()
if (savedInstanceState == null) {
val fragment = MultiselectForwardFragment()
fragment.arguments = requireArguments()
childFragmentManager.beginTransaction()
.replace(R.id.multiselect_container, fragment)
.commitAllowingStateLoss()
}
}
override fun getContainer(): ViewGroup {
return requireView().parent.parent.parent as ViewGroup
}
override fun setResult(bundle: Bundle) {
setFragmentResult(MultiselectForwardFragment.RESULT_SELECTION, bundle)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
callback?.onDismissForwardSheet()
}
override fun onFinishForwardAction() {
callback?.onFinishForwardAction()
}
override fun exitFlow() {
dismissAllowingStateLoss()
}
override fun onSearchInputFocused() {
(requireDialog() as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
interface Callback {
fun onFinishForwardAction()
fun onDismissForwardSheet()
}
}

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import android.content.DialogInterface
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -9,67 +8,56 @@ import android.view.View
import android.view.ViewGroup
import android.view.animation.AnimationUtils
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.PluralsRes
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.ContactSelectionListFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ContactFilterView
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader
import org.thoughtcrime.securesms.contacts.HeaderAction
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter
import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryFlowDialogFragment
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryWithViewersFragment
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.fragments.findListener
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
import org.thoughtcrime.securesms.util.visible
import org.whispersystems.libsignal.util.guava.Optional
import java.util.function.Consumer
private const val ARG_MULTISHARE_ARGS = "multiselect.forward.fragment.arg.multishare.args"
private const val ARG_CAN_SEND_TO_NON_PUSH = "multiselect.forward.fragment.arg.can.send.to.non.push"
private const val ARG_TITLE = "multiselect.forward.fragment.title"
private val TAG = Log.tag(MultiselectForwardFragment::class.java)
class MultiselectForwardFragment :
FixedRoundedCornerBottomSheetDialogFragment(),
ContactSelectionListFragment.OnContactSelectedListener,
ContactSelectionListFragment.OnSelectionLimitReachedListener,
SafetyNumberChangeDialog.Callback {
override val peekHeightPercentage: Float = 0.67f
Fragment(),
SafetyNumberChangeDialog.Callback,
ChooseStoryTypeBottomSheet.Callback {
private val viewModel: MultiselectForwardViewModel by viewModels(factoryProducer = this::createViewModelFactory)
private val disposables = LifecycleDisposable()
private lateinit var selectionFragment: ContactSelectionListFragment
private lateinit var contactFilterView: ContactFilterView
private lateinit var addMessage: EditText
private lateinit var contactSearchMediator: ContactSearchMediator
private var callback: Callback? = null
private lateinit var callback: Callback
private var dismissibleDialog: SimpleProgressDialog.DismissibleDialog? = null
private var handler: Handler? = null
private fun createViewModelFactory(): MultiselectForwardViewModel.Factory {
@@ -79,63 +67,44 @@ class MultiselectForwardFragment :
private fun getMultiShareArgs(): ArrayList<MultiShareArgs> = requireNotNull(requireArguments().getParcelableArrayList(ARG_MULTISHARE_ARGS))
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
childFragmentManager.addFragmentOnAttachListener { _, fragment ->
fragment.arguments = Bundle().apply {
putInt(ContactSelectionListFragment.DISPLAY_MODE, getDefaultDisplayMode())
putBoolean(ContactSelectionListFragment.REFRESHABLE, false)
putBoolean(ContactSelectionListFragment.RECENTS, true)
putParcelable(ContactSelectionListFragment.SELECTION_LIMITS, FeatureFlags.shareSelectionLimit())
putBoolean(ContactSelectionListFragment.HIDE_COUNT, true)
putBoolean(ContactSelectionListFragment.DISPLAY_CHIPS, false)
putBoolean(ContactSelectionListFragment.CAN_SELECT_SELF, true)
putBoolean(ContactSelectionListFragment.RV_CLIP, false)
putInt(ContactSelectionListFragment.RV_PADDING_BOTTOM, ViewUtil.dpToPx(48))
}
}
val view = inflater.inflate(R.layout.multiselect_forward_fragment, container, false)
view.minimumHeight = resources.displayMetrics.heightPixels
return view
return inflater.inflate(R.layout.multiselect_forward_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
callback = findListener()
disposables.bindTo(viewLifecycleOwner.lifecycle)
view.minimumHeight = resources.displayMetrics.heightPixels
selectionFragment = childFragmentManager.findFragmentById(R.id.contact_selection_list_fragment) as ContactSelectionListFragment
val contactSearchRecycler: RecyclerView = view.findViewById(R.id.contact_selection_list)
contactSearchMediator = ContactSearchMediator(this, contactSearchRecycler, FeatureFlags.shareSelectionLimit(), this::getConfiguration)
callback = findListener()!!
disposables.bindTo(viewLifecycleOwner.lifecycle)
contactFilterView = view.findViewById(R.id.contact_filter_edit_text)
contactFilterView.setOnSearchInputFocusChangedListener { _, hasFocus ->
if (hasFocus) {
(requireDialog() as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED
callback.onSearchInputFocused()
}
}
contactFilterView.setOnFilterChangedListener {
if (it.isNullOrEmpty()) {
selectionFragment.resetQueryFilter()
} else {
selectionFragment.setQueryFilter(it)
}
contactSearchMediator.onFilterChanged(it)
}
val title: TextView = view.findViewById(R.id.title)
val container = view.parent.parent.parent as FrameLayout
val title: TextView? = view.findViewById(R.id.title)
val container = callback.getContainer()
val bottomBar = LayoutInflater.from(requireContext()).inflate(R.layout.multiselect_forward_fragment_bottom_bar, container, false)
val shareSelectionRecycler: RecyclerView = bottomBar.findViewById(R.id.selected_list)
val shareSelectionAdapter = ShareSelectionAdapter()
val sendButton: View = bottomBar.findViewById(R.id.share_confirm)
title.setText(requireArguments().getInt(ARG_TITLE))
title?.setText(requireArguments().getInt(ARG_TITLE))
addMessage = bottomBar.findViewById(R.id.add_message)
sendButton.setOnClickListener {
sendButton.isEnabled = false
viewModel.send(addMessage.text.toString())
viewModel.send(addMessage.text.toString(), contactSearchMediator.getSelectedContacts())
}
shareSelectionRecycler.adapter = shareSelectionAdapter
@@ -144,8 +113,8 @@ class MultiselectForwardFragment :
container.addView(bottomBar)
viewModel.shareContactMappingModels.observe(viewLifecycleOwner) {
shareSelectionAdapter.submitList(it)
contactSearchMediator.getSelectionState().observe(viewLifecycleOwner) {
shareSelectionAdapter.submitList(it.mapIndexed { index, key -> ShareSelectionMappingModel(key.requireShareContact(), index == 0) })
if (it.isNotEmpty() && !bottomBar.isVisible) {
bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_from_bottom)
@@ -158,7 +127,7 @@ class MultiselectForwardFragment :
viewModel.state.observe(viewLifecycleOwner) {
when (it.stage) {
MultiselectForwardState.Stage.Selection -> { }
MultiselectForwardState.Stage.Selection -> {}
MultiselectForwardState.Stage.FirstConfirmation -> displayFirstSendConfirmation()
is MultiselectForwardState.Stage.SafetyConfirmation -> displaySafetyNumberConfirmation(it.stage.identities)
MultiselectForwardState.Stage.LoadingIdentities -> {}
@@ -170,17 +139,27 @@ class MultiselectForwardFragment :
MultiselectForwardState.Stage.SomeFailed -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_sent)
MultiselectForwardState.Stage.AllFailed -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_failed_to_send)
MultiselectForwardState.Stage.Success -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_sent)
is MultiselectForwardState.Stage.SelectionConfirmed -> dismissWithResult(it.stage.recipients)
is MultiselectForwardState.Stage.SelectionConfirmed -> dismissWithSelection(it.stage.selectedContacts)
}
sendButton.isEnabled = it.stage == MultiselectForwardState.Stage.Selection
}
bottomBar.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
selectionFragment.setRecyclerViewPaddingBottom(bottom - top)
addMessage.visible = getMultiShareArgs().isNotEmpty()
setFragmentResultListener(CreateStoryWithViewersFragment.REQUEST_KEY) { _, bundle ->
val recipientId: RecipientId = bundle.getParcelable(CreateStoryWithViewersFragment.STORY_RECIPIENT)!!
contactSearchMediator.setKeysSelected(setOf(ContactSearchKey.Story(recipientId)))
contactFilterView.clear()
}
addMessage.visible = getMultiShareArgs().isNotEmpty()
setFragmentResultListener(ChooseGroupStoryBottomSheet.GROUP_STORY) { _, bundle ->
val groups: Set<RecipientId> = bundle.getParcelableArrayList<RecipientId>(ChooseGroupStoryBottomSheet.RESULT_SET)?.toSet() ?: emptySet()
val keys: Set<ContactSearchKey.Story> = groups.map { ContactSearchKey.Story(it) }.toSet()
contactSearchMediator.addToVisibleGroupStories(keys)
contactSearchMediator.setKeysSelected(keys)
contactFilterView.clear()
}
}
override fun onResume() {
@@ -207,9 +186,9 @@ class MultiselectForwardFragment :
handler?.removeCallbacksAndMessages(null)
}
override fun onDismiss(dialog: DialogInterface) {
override fun onDestroyView() {
dismissibleDialog?.dismissNow()
super.onDismiss(dialog)
super.onDestroyView()
}
private fun displayFirstSendConfirmation() {
@@ -222,7 +201,7 @@ class MultiselectForwardFragment :
.setMessage(R.string.MultiselectForwardFragment__forwarded_messages_are_now)
.setPositiveButton(resources.getQuantityString(R.plurals.MultiselectForwardFragment_send_d_messages, messageCount, messageCount)) { d, _ ->
d.dismiss()
viewModel.confirmFirstSend(addMessage.text.toString())
viewModel.confirmFirstSend(addMessage.text.toString(), contactSearchMediator.getSelectedContacts())
}
.setNegativeButton(android.R.string.cancel) { d, _ ->
d.dismiss()
@@ -238,84 +217,35 @@ class MultiselectForwardFragment :
private fun dismissAndShowToast(@PluralsRes toastTextResId: Int) {
val argCount = getMessageCount()
callback?.onFinishForwardAction()
callback.onFinishForwardAction()
dismissibleDialog?.dismiss()
Toast.makeText(requireContext(), requireContext().resources.getQuantityString(toastTextResId, argCount), Toast.LENGTH_SHORT).show()
dismissAllowingStateLoss()
}
private fun dismissWithResult(recipientIds: List<RecipientId>) {
callback?.onFinishForwardAction()
dismissibleDialog?.dismiss()
setFragmentResult(
RESULT_SELECTION,
Bundle().apply {
putParcelableArrayList(RESULT_SELECTION_RECIPIENTS, ArrayList(recipientIds))
}
)
dismissAllowingStateLoss()
callback.exitFlow()
}
private fun getMessageCount(): Int = getMultiShareArgs().size + if (addMessage.text.isNotEmpty()) 1 else 0
private fun handleMessageExpired() {
dismissAllowingStateLoss()
callback?.onFinishForwardAction()
callback.onFinishForwardAction()
dismissibleDialog?.dismiss()
Toast.makeText(requireContext(), resources.getQuantityString(R.plurals.MultiselectForwardFragment__couldnt_forward_messages, getMultiShareArgs().size), Toast.LENGTH_LONG).show()
callback.exitFlow()
}
private fun getDefaultDisplayMode(): Int {
var mode = ContactsCursorLoader.DisplayMode.FLAG_PUSH or
ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS or
ContactsCursorLoader.DisplayMode.FLAG_SELF or
ContactsCursorLoader.DisplayMode.FLAG_HIDE_NEW or
ContactsCursorLoader.DisplayMode.FLAG_HIDE_RECENT_HEADER
private fun dismissWithSelection(selectedContacts: Set<ContactSearchKey>) {
callback.onFinishForwardAction()
dismissibleDialog?.dismiss()
if (Util.isDefaultSmsProvider(requireContext()) && requireArguments().getBoolean(ARG_CAN_SEND_TO_NON_PUSH)) {
mode = mode or ContactsCursorLoader.DisplayMode.FLAG_SMS
val resultsBundle = Bundle().apply {
putParcelableArrayList(RESULT_SELECTION_RECIPIENTS, ArrayList(selectedContacts.map { it.requireParcelable() }))
}
return mode or ContactsCursorLoader.DisplayMode.FLAG_HIDE_GROUPS_V1
}
override fun onBeforeContactSelected(recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
if (recipientId.isPresent) {
disposables.add(
viewModel.addSelectedContact(recipientId, null)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { success ->
if (!success) {
Toast.makeText(requireContext(), R.string.ShareActivity_you_do_not_have_permission_to_send_to_this_group, Toast.LENGTH_SHORT).show()
}
callback.accept(success)
contactFilterView.clear()
}
)
} else {
Log.w(TAG, "Rejecting non-present recipient. Can't forward to an unknown contact.")
callback.accept(false)
}
}
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?) {
viewModel.removeSelectedContact(recipientId, null)
}
override fun onSelectionChanged() {
}
override fun onSuggestedLimitReached(limit: Int) {
}
override fun onHardLimitReached(limit: Int) {
Toast.makeText(requireContext(), R.string.MultiselectForwardFragment__limit_reached, Toast.LENGTH_SHORT).show()
callback.setResult(resultsBundle)
callback.exitFlow()
}
override fun onSendAnywayAfterSafetyNumberChange(changedRecipients: MutableList<RecipientId>) {
viewModel.confirmSafetySend(addMessage.text.toString())
viewModel.confirmSafetySend(addMessage.text.toString(), contactSearchMediator.getSelectedContacts())
}
override fun onMessageResentAfterSafetyNumberChange() {
@@ -326,14 +256,98 @@ class MultiselectForwardFragment :
viewModel.cancelSend()
}
companion object {
private fun getHeaderAction(): HeaderAction {
return HeaderAction(
R.string.ContactsCursorLoader_new_story,
R.drawable.ic_plus_20
) {
ChooseStoryTypeBottomSheet().show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
private fun getConfiguration(contactSearchState: ContactSearchState): ContactSearchConfiguration {
return ContactSearchConfiguration.build {
query = contactSearchState.query
addSection(
ContactSearchConfiguration.Section.Stories(
groupStories = contactSearchState.groupStories,
includeHeader = true,
headerAction = getHeaderAction(),
expandConfig = ContactSearchConfiguration.ExpandConfig(
isExpanded = contactSearchState.expandedSections.contains(ContactSearchConfiguration.SectionKey.STORIES)
)
)
)
if (query.isNullOrEmpty()) {
addSection(
ContactSearchConfiguration.Section.Recents(
includeHeader = true
)
)
}
addSection(
ContactSearchConfiguration.Section.Individuals(
includeHeader = true,
transportType = if (includeSms()) ContactSearchConfiguration.TransportType.ALL else ContactSearchConfiguration.TransportType.PUSH,
includeSelf = true
)
)
addSection(
ContactSearchConfiguration.Section.Groups(
includeHeader = true,
includeMms = includeSms()
)
)
}
}
private fun includeSms(): Boolean {
return Util.isDefaultSmsProvider(requireContext()) && requireArguments().getBoolean(ARG_CAN_SEND_TO_NON_PUSH)
}
override fun onGroupStoryClicked() {
ChooseGroupStoryBottomSheet().show(parentFragmentManager, ChooseGroupStoryBottomSheet.GROUP_STORY)
}
override fun onNewStoryClicked() {
CreateStoryFlowDialogFragment().show(parentFragmentManager, CreateStoryWithViewersFragment.REQUEST_KEY)
}
interface Callback {
fun onFinishForwardAction()
fun exitFlow()
fun onSearchInputFocused()
fun setResult(bundle: Bundle)
fun getContainer(): ViewGroup
}
companion object {
const val ARG_MULTISHARE_ARGS = "multiselect.forward.fragment.arg.multishare.args"
const val ARG_CAN_SEND_TO_NON_PUSH = "multiselect.forward.fragment.arg.can.send.to.non.push"
const val ARG_TITLE = "multiselect.forward.fragment.title"
const val RESULT_SELECTION = "result_selection"
const val RESULT_SELECTION_RECIPIENTS = "result_selection_recipients"
@JvmStatic
fun show(supportFragmentManager: FragmentManager, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) {
val fragment = MultiselectForwardFragment()
fun showBottomSheet(supportFragmentManager: FragmentManager, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) {
val fragment = MultiselectForwardBottomSheet()
fragment.arguments = Bundle().apply {
putParcelableArrayList(ARG_MULTISHARE_ARGS, ArrayList(multiselectForwardFragmentArgs.multiShareArgs))
putBoolean(ARG_CAN_SEND_TO_NON_PUSH, multiselectForwardFragmentArgs.canSendToNonPush)
putInt(ARG_TITLE, multiselectForwardFragmentArgs.title)
}
fragment.show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
@JvmStatic
fun showFullScreen(supportFragmentManager: FragmentManager, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) {
val fragment = MultiselectForwardFullScreenDialogFragment()
fragment.arguments = Bundle().apply {
putParcelableArrayList(ARG_MULTISHARE_ARGS, ArrayList(multiselectForwardFragmentArgs.multiShareArgs))
@@ -344,8 +358,4 @@ class MultiselectForwardFragment :
fragment.show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
interface Callback {
fun onFinishForwardAction()
}
}

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.setFragmentResult
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FullScreenDialogFragment
import org.thoughtcrime.securesms.util.fragments.findListener
class MultiselectForwardFullScreenDialogFragment : FullScreenDialogFragment(), MultiselectForwardFragment.Callback {
override fun getTitle(): Int = R.string.MediaReviewFragment__send_to
override fun getDialogLayoutResource(): Int = R.layout.fragment_container
override fun onFinishForwardAction() {
findListener<Callback>()?.onFinishForwardAction()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (savedInstanceState == null) {
val fragment = MultiselectForwardFragment()
fragment.arguments = requireArguments()
childFragmentManager.beginTransaction()
.replace(R.id.fragment_container, fragment)
.commitAllowingStateLoss()
}
}
override fun getContainer(): ViewGroup {
return requireView().findViewById(R.id.full_screen_dialog_content) as ViewGroup
}
override fun setResult(bundle: Bundle) {
setFragmentResult(MultiselectForwardFragment.RESULT_SELECTION, bundle)
}
override fun exitFlow() {
dismissAllowingStateLoss()
}
override fun onSearchInputFocused() = Unit
interface Callback {
fun onFinishForwardAction()
}
}

View File

@@ -4,6 +4,7 @@ import android.content.Context
import androidx.core.util.Consumer
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.identity.IdentityRecordList
@@ -13,7 +14,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.sharing.MultiShareSender
import org.thoughtcrime.securesms.sharing.ShareContact
import org.thoughtcrime.securesms.sharing.ShareContactAndThread
import org.whispersystems.libsignal.util.guava.Optional
@@ -27,9 +27,11 @@ class MultiselectForwardRepository(context: Context) {
val onAllMessagesFailed: () -> Unit
)
fun checkForBadIdentityRecords(shareContacts: List<ShareContact>, consumer: Consumer<List<IdentityRecord>>) {
fun checkForBadIdentityRecords(contactSearchKeys: Set<ContactSearchKey>, consumer: Consumer<List<IdentityRecord>>) {
SignalExecutors.BOUNDED.execute {
val recipients: List<Recipient> = shareContacts.map { Recipient.resolved(it.recipientId.get()) }
val recipients: List<Recipient> = contactSearchKeys
.filterIsInstance<ContactSearchKey.KnownRecipient>()
.map { Recipient.resolved(it.recipientId) }
val identityRecordList: IdentityRecordList = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(recipients)
consumer.accept(identityRecordList.untrustedRecords)
@@ -55,7 +57,7 @@ class MultiselectForwardRepository(context: Context) {
fun send(
additionalMessage: String,
multiShareArgs: List<MultiShareArgs>,
shareContacts: List<ShareContact>,
shareContacts: Set<ContactSearchKey>,
resultHandlers: MultiselectForwardResultHandlers
) {
SignalExecutors.BOUNDED.execute {
@@ -63,10 +65,13 @@ class MultiselectForwardRepository(context: Context) {
val sharedContactsAndThreads: Set<ShareContactAndThread> = shareContacts
.asSequence()
.distinct()
.filter { it.recipientId.isPresent }
.map { Recipient.resolved(it.recipientId.get()) }
.map { ShareContactAndThread(it.id, threadDatabase.getOrCreateThreadIdFor(it), it.isForceSmsSelection) }
.filter { it is ContactSearchKey.Story || it is ContactSearchKey.KnownRecipient }
.map {
val recipient = Recipient.resolved(it.requireShareContact().recipientId.get())
val isStory = it is ContactSearchKey.Story || recipient.isDistributionList
val thread = if (isStory) -1L else threadDatabase.getOrCreateThreadIdFor(recipient)
ShareContactAndThread(recipient.id, thread, recipient.isForceSmsSelection, it is ContactSearchKey.Story)
}
.toSet()
val mappedArgs: List<MultiShareArgs> = multiShareArgs.map { it.buildUpon(sharedContactsAndThreads).build() }

View File

@@ -1,11 +1,9 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.ShareContact
data class MultiselectForwardState(
val selectedContacts: List<ShareContact> = emptyList(),
val stage: Stage = Stage.Selection
) {
sealed class Stage {
@@ -17,6 +15,6 @@ data class MultiselectForwardState(
object SomeFailed : Stage()
object AllFailed : Stage()
object Success : Stage()
data class SelectionConfirmed(val recipients: List<RecipientId>) : Stage()
data class SelectionConfirmed(val selectedContacts: Set<ContactSearchKey>) : Stage()
}
}

View File

@@ -1,17 +1,12 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.sharing.ShareContact
import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.libsignal.util.guava.Optional
class MultiselectForwardViewModel(
private val records: List<MultiShareArgs>,
@@ -22,31 +17,15 @@ class MultiselectForwardViewModel(
val state: LiveData<MultiselectForwardState> = store.stateLiveData
val shareContactMappingModels: LiveData<List<ShareSelectionMappingModel>> = Transformations.map(state) { s -> s.selectedContacts.mapIndexed { i, c -> ShareSelectionMappingModel(c, i == 0) } }
fun addSelectedContact(recipientId: Optional<RecipientId>, number: String?): Single<Boolean> {
return repository
.canSelectRecipient(recipientId)
.doOnSuccess { allowed ->
if (allowed) {
store.update { it.copy(selectedContacts = it.selectedContacts + ShareContact(recipientId, number)) }
}
}
}
fun removeSelectedContact(recipientId: Optional<RecipientId>, number: String?) {
store.update { it.copy(selectedContacts = it.selectedContacts - ShareContact(recipientId, number)) }
}
fun send(additionalMessage: String) {
fun send(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
if (SignalStore.tooltips().showMultiForwardDialog()) {
SignalStore.tooltips().markMultiForwardDialogSeen()
store.update { it.copy(stage = MultiselectForwardState.Stage.FirstConfirmation) }
} else {
store.update { it.copy(stage = MultiselectForwardState.Stage.LoadingIdentities) }
repository.checkForBadIdentityRecords(store.state.selectedContacts) { identityRecords ->
repository.checkForBadIdentityRecords(selectedContacts) { identityRecords ->
if (identityRecords.isEmpty()) {
performSend(additionalMessage)
performSend(additionalMessage, selectedContacts)
} else {
store.update { it.copy(stage = MultiselectForwardState.Stage.SafetyConfirmation(identityRecords)) }
}
@@ -54,33 +33,27 @@ class MultiselectForwardViewModel(
}
}
fun confirmFirstSend(additionalMessage: String) {
send(additionalMessage)
fun confirmFirstSend(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
send(additionalMessage, selectedContacts)
}
fun confirmSafetySend(additionalMessage: String) {
send(additionalMessage)
fun confirmSafetySend(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
send(additionalMessage, selectedContacts)
}
fun cancelSend() {
store.update { it.copy(stage = MultiselectForwardState.Stage.Selection) }
}
private fun performSend(additionalMessage: String) {
private fun performSend(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
store.update { it.copy(stage = MultiselectForwardState.Stage.SendPending) }
if (records.isEmpty()) {
store.update { state ->
state.copy(
stage = MultiselectForwardState.Stage.SelectionConfirmed(
state.selectedContacts.filter { it.recipientId.isPresent }.map { it.recipientId.get() }.distinct()
)
)
}
store.update { it.copy(stage = MultiselectForwardState.Stage.SelectionConfirmed(selectedContacts)) }
} else {
repository.send(
additionalMessage = additionalMessage,
multiShareArgs = records,
shareContacts = store.state.selectedContacts,
shareContacts = selectedContacts,
MultiselectForwardRepository.MultiselectForwardResultHandlers(
onAllMessageSentSuccessfully = { store.update { it.copy(stage = MultiselectForwardState.Stage.Success) } },
onAllMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.AllFailed) } },

View File

@@ -1,10 +1,7 @@
package org.thoughtcrime.securesms.crypto;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.crypto.storage.SignalSenderKeyStore;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -20,7 +17,7 @@ public final class SenderKeyUtil {
/**
* Clears the state for a sender key session we created. It will naturally get re-created when it is next needed, rotating the key.
*/
public static void rotateOurKey(@NonNull Context context, @NonNull DistributionId distributionId) {
public static void rotateOurKey(@NonNull DistributionId distributionId) {
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
ApplicationDependencies.getProtocolStore().aci().senderKeys().deleteAllFor(SignalStore.account().requireAci().toString(), distributionId);
SignalDatabase.senderKeyShared().deleteAllFor(distributionId);
@@ -30,7 +27,7 @@ public final class SenderKeyUtil {
/**
* Gets when the sender key session was created, or -1 if it doesn't exist.
*/
public static long getCreateTimeForOurKey(@NonNull Context context, @NonNull DistributionId distributionId) {
public static long getCreateTimeForOurKey(@NonNull DistributionId distributionId) {
SignalProtocolAddress address = new SignalProtocolAddress(SignalStore.account().requireAci().toString(), SignalStore.account().getDeviceId());
return SignalDatabase.senderKeys().getCreatedTime(address, distributionId);
}
@@ -38,7 +35,7 @@ public final class SenderKeyUtil {
/**
* Deletes all stored state around session keys. Should only really be used when the user is re-registering.
*/
public static void clearAllState(@NonNull Context context) {
public static void clearAllState() {
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
ApplicationDependencies.getProtocolStore().aci().senderKeys().deleteAll();
SignalDatabase.senderKeyShared().deleteAll();

View File

@@ -40,6 +40,7 @@ public class DatabaseObserver {
private static final String KEY_MESSAGE_INSERT = "MessageInsert:";
private static final String KEY_NOTIFICATION_PROFILES = "NotificationProfiles";
private static final String KEY_RECIPIENT = "Recipient";
private static final String KEY_STORY_OBSERVER = "Story";
private final Application application;
private final Executor executor;
@@ -56,6 +57,7 @@ public class DatabaseObserver {
private final Set<MessageObserver> messageUpdateObservers;
private final Map<Long, Set<MessageObserver>> messageInsertObservers;
private final Set<Observer> notificationProfileObservers;
private final Map<RecipientId, Set<Observer>> storyObservers;
public DatabaseObserver(Application application) {
this.application = application;
@@ -72,6 +74,7 @@ public class DatabaseObserver {
this.messageUpdateObservers = new HashSet<>();
this.messageInsertObservers = new HashMap<>();
this.notificationProfileObservers = new HashSet<>();
this.storyObservers = new HashMap<>();
}
public void registerConversationListObserver(@NonNull Observer listener) {
@@ -146,6 +149,15 @@ public class DatabaseObserver {
});
}
/**
* Adds an observer which will be notified whenever a new Story message is inserted into the database.
*/
public void registerStoryObserver(@NonNull RecipientId recipientId, @NonNull Observer listener) {
executor.execute(() -> {
registerMapped(storyObservers, recipientId, listener);
});
}
public void unregisterObserver(@NonNull Observer listener) {
executor.execute(() -> {
conversationListObservers.remove(listener);
@@ -157,6 +169,7 @@ public class DatabaseObserver {
stickerPackObservers.remove(listener);
attachmentObservers.remove(listener);
notificationProfileObservers.remove(listener);
unregisterMapped(storyObservers, listener);
});
}
@@ -262,6 +275,12 @@ public class DatabaseObserver {
});
}
public void notifyStoryObservers(@NonNull RecipientId recipientId) {
runPostSuccessfulTransaction(KEY_STORY_OBSERVER, () -> {
notifyMapped(storyObservers, recipientId);
});
}
private void runPostSuccessfulTransaction(@NonNull String dedupeKey, @NonNull Runnable runnable) {
SignalDatabase.runPostSuccessfulTransaction(dedupeKey, () -> {
executor.execute(runnable);

View File

@@ -0,0 +1,297 @@
package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import androidx.core.content.contentValuesOf
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord
import org.thoughtcrime.securesms.database.model.DistributionListRecord
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.CursorUtil
import org.thoughtcrime.securesms.util.SqlUtil
import org.whispersystems.signalservice.api.push.DistributionId
import java.util.UUID
/**
* Stores distribution lists, which represent different sets of people you may want to share a story with.
*/
class DistributionListDatabase constructor(context: Context?, databaseHelper: SignalDatabase?) : Database(context, databaseHelper) {
companion object {
@JvmField
val CREATE_TABLE: Array<String> = arrayOf(ListTable.CREATE_TABLE, MembershipTable.CREATE_TABLE)
const val RECIPIENT_ID = ListTable.RECIPIENT_ID
fun insertInitialDistributionListAtCreationTime(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
val recipientId = db.insert(
RecipientDatabase.TABLE_NAME, null,
contentValuesOf(
RecipientDatabase.DISTRIBUTION_LIST_ID to DistributionListId.MY_STORY_ID,
RecipientDatabase.STORAGE_SERVICE_ID to Base64.encodeBytes(StorageSyncHelper.generateKey()),
RecipientDatabase.PROFILE_SHARING to 1
)
)
val listUUID = UUID.randomUUID().toString()
db.insert(
ListTable.TABLE_NAME, null,
contentValuesOf(
ListTable.ID to DistributionListId.MY_STORY_ID,
ListTable.NAME to listUUID,
ListTable.DISTRIBUTION_ID to listUUID,
ListTable.RECIPIENT_ID to recipientId
)
)
}
}
private object ListTable {
const val TABLE_NAME = "distribution_list"
const val ID = "_id"
const val NAME = "name"
const val DISTRIBUTION_ID = "distribution_id"
const val RECIPIENT_ID = "recipient_id"
const val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$NAME TEXT UNIQUE NOT NULL,
$DISTRIBUTION_ID TEXT UNIQUE NOT NULL,
$RECIPIENT_ID INTEGER UNIQUE REFERENCES ${RecipientDatabase.TABLE_NAME} (${RecipientDatabase.ID})
)
"""
}
private object MembershipTable {
const val TABLE_NAME = "distribution_list_member"
const val ID = "_id"
const val LIST_ID = "list_id"
const val RECIPIENT_ID = "recipient_id"
const val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$LIST_ID INTEGER NOT NULL REFERENCES ${ListTable.TABLE_NAME} (${ListTable.ID}) ON DELETE CASCADE,
$RECIPIENT_ID INTEGER NOT NULL REFERENCES ${RecipientDatabase.TABLE_NAME} (${RecipientDatabase.ID}),
UNIQUE($LIST_ID, $RECIPIENT_ID) ON CONFLICT IGNORE
)
"""
}
/**
* @return true if the name change happened, false otherwise.
*/
fun setName(distributionListId: DistributionListId, name: String): Boolean {
val db = writableDatabase
return db.updateWithOnConflict(
ListTable.TABLE_NAME,
contentValuesOf(ListTable.NAME to name),
ID_WHERE,
SqlUtil.buildArgs(distributionListId),
SQLiteDatabase.CONFLICT_IGNORE
) == 1
}
fun getAllListsForContactSelectionUi(query: String?, includeMyStory: Boolean): List<DistributionListPartialRecord> {
return getAllListsForContactSelectionUiCursor(query, includeMyStory)?.use {
val results = mutableListOf<DistributionListPartialRecord>()
while (it.moveToNext()) {
results.add(
DistributionListPartialRecord(
id = DistributionListId.from(CursorUtil.requireLong(it, ListTable.ID)),
name = CursorUtil.requireString(it, ListTable.NAME),
recipientId = RecipientId.from(CursorUtil.requireLong(it, ListTable.RECIPIENT_ID))
)
)
}
results
} ?: emptyList()
}
fun getAllListsForContactSelectionUiCursor(query: String?, includeMyStory: Boolean): Cursor? {
val db = readableDatabase
val projection = arrayOf(ListTable.ID, ListTable.NAME, ListTable.RECIPIENT_ID)
val where = when {
query.isNullOrEmpty() && includeMyStory -> null
query.isNullOrEmpty() -> "${ListTable.ID} != ?"
includeMyStory -> "${ListTable.NAME} LIKE ? OR ${ListTable.ID} == ?"
else -> "${ListTable.NAME} LIKE ? AND ${ListTable.ID} != ?"
}
val whereArgs = when {
query.isNullOrEmpty() && includeMyStory -> null
query.isNullOrEmpty() -> SqlUtil.buildArgs(DistributionListId.MY_STORY_ID)
else -> SqlUtil.buildArgs("%$query%", DistributionListId.MY_STORY_ID)
}
return db.query(ListTable.TABLE_NAME, projection, where, whereArgs, null, null, null)
}
fun getCustomListsForUi(): List<DistributionListPartialRecord> {
val db = readableDatabase
val projection = SqlUtil.buildArgs(ListTable.ID, ListTable.NAME, ListTable.RECIPIENT_ID)
val selection = "${ListTable.ID} != ${DistributionListId.MY_STORY_ID}"
return db.query(ListTable.TABLE_NAME, projection, selection, null, null, null, null)?.use {
val results = mutableListOf<DistributionListPartialRecord>()
while (it.moveToNext()) {
results.add(
DistributionListPartialRecord(
id = DistributionListId.from(CursorUtil.requireLong(it, ListTable.ID)),
name = CursorUtil.requireString(it, ListTable.NAME),
recipientId = RecipientId.from(CursorUtil.requireLong(it, ListTable.RECIPIENT_ID))
)
)
}
results
} ?: emptyList()
}
/**
* @return The id of the list if successful, otherwise null. If not successful, you can assume it was a name conflict.
*/
fun createList(name: String, members: List<RecipientId>): DistributionListId? {
val db = writableDatabase
db.beginTransaction()
try {
val values = ContentValues().apply {
put(ListTable.NAME, name)
put(ListTable.DISTRIBUTION_ID, UUID.randomUUID().toString())
putNull(ListTable.RECIPIENT_ID)
}
val id = writableDatabase.insert(ListTable.TABLE_NAME, null, values)
if (id < 0) {
return null
}
val recipientId = SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.from(id))
writableDatabase.update(
ListTable.TABLE_NAME,
ContentValues().apply { put(ListTable.RECIPIENT_ID, recipientId.serialize()) },
"${ListTable.ID} = ?",
SqlUtil.buildArgs(id)
)
members.forEach { addMemberToList(DistributionListId.from(id), it) }
db.setTransactionSuccessful()
return DistributionListId.from(id)
} finally {
db.endTransaction()
}
}
fun getList(listId: DistributionListId): DistributionListRecord? {
readableDatabase.query(ListTable.TABLE_NAME, null, "${ListTable.ID} = ?", SqlUtil.buildArgs(listId), null, null, null).use { cursor ->
return if (cursor.moveToFirst()) {
val id: DistributionListId = DistributionListId.from(cursor.requireLong(ListTable.ID))
DistributionListRecord(
id = id,
name = cursor.requireNonNullString(ListTable.NAME),
distributionId = DistributionId.from(cursor.requireNonNullString(ListTable.DISTRIBUTION_ID)),
members = getMembers(id)
)
} else {
null
}
}
}
fun getDistributionId(listId: DistributionListId): DistributionId? {
readableDatabase.query(ListTable.TABLE_NAME, arrayOf(ListTable.DISTRIBUTION_ID), "${ListTable.ID} = ?", SqlUtil.buildArgs(listId), null, null, null).use { cursor ->
return if (cursor.moveToFirst()) {
DistributionId.from(cursor.requireString(ListTable.DISTRIBUTION_ID))
} else {
null
}
}
}
fun getMembers(listId: DistributionListId): List<RecipientId> {
if (listId == DistributionListId.MY_STORY) {
val blockedMembers = getRawMembers(listId).toSet()
return SignalDatabase.recipients.getSignalContacts(false)?.use {
val result = mutableListOf<RecipientId>()
while (it.moveToNext()) {
val id = RecipientId.from(CursorUtil.requireLong(it, RecipientDatabase.ID))
if (!blockedMembers.contains(id)) {
result.add(id)
}
}
result
} ?: emptyList()
} else {
return getRawMembers(listId)
}
}
fun getRawMembers(listId: DistributionListId): List<RecipientId> {
val members = mutableListOf<RecipientId>()
readableDatabase.query(MembershipTable.TABLE_NAME, null, "${MembershipTable.LIST_ID} = ?", SqlUtil.buildArgs(listId), null, null, null).use { cursor ->
while (cursor.moveToNext()) {
members.add(RecipientId.from(cursor.requireLong(MembershipTable.RECIPIENT_ID)))
}
}
return members
}
fun getMemberCount(listId: DistributionListId): Int {
return if (listId == DistributionListId.MY_STORY) {
SignalDatabase.recipients.getSignalContacts(false)?.count?.let { it - getRawMemberCount(listId) } ?: 0
} else {
getRawMemberCount(listId)
}
}
fun getRawMemberCount(listId: DistributionListId): Int {
readableDatabase.query(MembershipTable.TABLE_NAME, SqlUtil.buildArgs("COUNT(*)"), "${MembershipTable.LIST_ID} = ?", SqlUtil.buildArgs(listId), null, null, null).use { cursor ->
return if (cursor.moveToFirst()) {
cursor.getInt(0)
} else {
0
}
}
}
fun removeMemberFromList(listId: DistributionListId, member: RecipientId) {
writableDatabase.delete(MembershipTable.TABLE_NAME, "${MembershipTable.LIST_ID} = ? AND ${MembershipTable.RECIPIENT_ID} = ?", SqlUtil.buildArgs(listId, member))
}
fun addMemberToList(listId: DistributionListId, member: RecipientId) {
val values = ContentValues().apply {
put(MembershipTable.LIST_ID, listId.serialize())
put(MembershipTable.RECIPIENT_ID, member.serialize())
}
writableDatabase.insert(MembershipTable.TABLE_NAME, null, values)
}
fun remapRecipient(oldId: RecipientId, newId: RecipientId) {
val values = ContentValues().apply {
put(MembershipTable.RECIPIENT_ID, newId.serialize())
}
writableDatabase.update(MembershipTable.TABLE_NAME, values, "${MembershipTable.RECIPIENT_ID} = ?", SqlUtil.buildArgs(oldId))
}
fun deleteList(distributionListId: DistributionListId) {
writableDatabase.delete(ListTable.TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(distributionListId))
}
}

View File

@@ -67,7 +67,7 @@ public class GroupDatabase extends Database {
static final String TABLE_NAME = "groups";
private static final String ID = "_id";
static final String GROUP_ID = "group_id";
static final String RECIPIENT_ID = "recipient_id";
public static final String RECIPIENT_ID = "recipient_id";
private static final String TITLE = "title";
static final String MEMBERS = "members";
private static final String AVATAR_ID = "avatar_id";
@@ -737,7 +737,7 @@ private static final String[] GROUP_PROJECTION = {
if (removed.size() > 0) {
Log.i(TAG, removed.size() + " members were removed from group " + groupId + ". Rotating the DistributionId " + distributionId);
SenderKeyUtil.rotateOurKey(context, distributionId);
SenderKeyUtil.rotateOurKey(distributionId);
}
}
@@ -961,7 +961,7 @@ private static final String[] GROUP_PROJECTION = {
public static class Reader implements Closeable {
private final Cursor cursor;
public final Cursor cursor;
public Reader(Cursor cursor) {
this.cursor = cursor;

View File

@@ -180,6 +180,20 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
public abstract void ensureMigration();
public abstract boolean isStory(long messageId);
public abstract @NonNull Reader getOutgoingStoriesTo(@NonNull RecipientId recipientId);
public abstract @NonNull Reader getAllOutgoingStories();
public abstract @NonNull Reader getAllStories();
public abstract @NonNull List<RecipientId> getAllStoriesRecipientsList();
public abstract @NonNull Reader getAllStoriesFor(@NonNull RecipientId recipientId);
public abstract @NonNull MessageId getStoryId(@NonNull RecipientId authorId, long sentTimestamp) throws NoSuchMessageException;
public abstract int getNumberOfStoryReplies(long parentStoryId);
public abstract boolean hasSelfReplyInStory(long parentStoryId);
public abstract @NonNull Cursor getStoryReplies(long parentStoryId);
public abstract long getUnreadStoryCount();
public abstract @Nullable Long getOldestStorySendTimestamp();
public abstract int deleteStoriesOlderThan(long timestamp);
final @NonNull String getOutgoingTypeClause() {
List<String> segments = new ArrayList<>(Types.OUTGOING_MESSAGE_TYPES.length);
for (long outgoingMessageType : Types.OUTGOING_MESSAGE_TYPES) {

View File

@@ -129,6 +129,8 @@ public class MmsDatabase extends MessageDatabase {
static final String MESSAGE_RANGES = "ranges";
public static final String VIEW_ONCE = "reveal_duration";
static final String IS_STORY = "is_story";
static final String PARENT_STORY_ID = "parent_story_id";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
THREAD_ID + " INTEGER, " +
@@ -171,9 +173,11 @@ public class MmsDatabase extends MessageDatabase {
MENTIONS_SELF + " INTEGER DEFAULT 0, " +
NOTIFIED_TIMESTAMP + " INTEGER DEFAULT 0, " +
VIEWED_RECEIPT_COUNT + " INTEGER DEFAULT 0, " +
SERVER_GUID + " TEXT DEFAULT NULL, "+
SERVER_GUID + " TEXT DEFAULT NULL, " +
RECEIPT_TIMESTAMP + " INTEGER DEFAULT -1, " +
MESSAGE_RANGES + " BLOB DEFAULT NULL);";
MESSAGE_RANGES + " BLOB DEFAULT NULL, " +
IS_STORY + " INTEGER DEFAULT 0, " +
PARENT_STORY_ID + " INTEGER DEFAULT 0);";
public static final String[] CREATE_INDEXS = {
"CREATE INDEX IF NOT EXISTS mms_read_and_notified_and_thread_id_index ON " + TABLE_NAME + "(" + READ + "," + NOTIFIED + "," + THREAD_ID + ");",
@@ -181,7 +185,9 @@ public class MmsDatabase extends MessageDatabase {
"CREATE INDEX IF NOT EXISTS mms_date_sent_index ON " + TABLE_NAME + " (" + DATE_SENT + ", " + RECIPIENT_ID + ", " + THREAD_ID + ");",
"CREATE INDEX IF NOT EXISTS mms_date_server_index ON " + TABLE_NAME + " (" + DATE_SERVER + ");",
"CREATE INDEX IF NOT EXISTS mms_thread_date_index ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + ");",
"CREATE INDEX IF NOT EXISTS mms_reactions_unread_index ON " + TABLE_NAME + " (" + REACTIONS_UNREAD + ");"
"CREATE INDEX IF NOT EXISTS mms_reactions_unread_index ON " + TABLE_NAME + " (" + REACTIONS_UNREAD + ");",
"CREATE INDEX IF NOT EXISTS mms_is_story_index ON " + TABLE_NAME + " (" + IS_STORY + ");",
"CREATE INDEX IF NOT EXISTS mms_parent_story_id_index ON " + TABLE_NAME + " (" + PARENT_STORY_ID + ");"
};
private static final String[] MMS_PROJECTION = new String[] {
@@ -197,6 +203,7 @@ public class MmsDatabase extends MessageDatabase {
EXPIRES_IN, EXPIRE_STARTED, NOTIFIED, QUOTE_ID, QUOTE_AUTHOR, QUOTE_BODY, QUOTE_ATTACHMENT, QUOTE_MISSING, QUOTE_MENTIONS,
SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, VIEW_ONCE, REACTIONS_UNREAD, REACTIONS_LAST_SEEN,
REMOTE_DELETED, MENTIONS_SELF, NOTIFIED_TIMESTAMP, VIEWED_RECEIPT_COUNT, RECEIPT_TIMESTAMP, MESSAGE_RANGES,
IS_STORY, PARENT_STORY_ID,
"json_group_array(json_object(" +
"'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " +
"'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " +
@@ -229,6 +236,8 @@ public class MmsDatabase extends MessageDatabase {
")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
};
private static final String IS_STORY_CLAUSE = IS_STORY + " = ? AND " + REMOTE_DELETED + " = ?";
private static final String RAW_ID_WHERE = TABLE_NAME + "._id = ?";
private static final String OUTGOING_INSECURE_MESSAGES_CLAUSE = "(" + MESSAGE_BOX + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND NOT (" + MESSAGE_BOX + " & " + Types.SECURE_MESSAGE_BIT + ")";
@@ -521,6 +530,205 @@ public class MmsDatabase extends MessageDatabase {
databaseHelper.getSignalWritableDatabase();
}
@Override
public boolean isStory(long messageId) {
SQLiteDatabase database = databaseHelper.getSignalReadableDatabase();
String[] projection = new String[]{"1"};
String where = IS_STORY_CLAUSE + " AND " + ID + " = ?";
String[] whereArgs = SqlUtil.buildArgs(1, 0, messageId);
try (Cursor cursor = database.query(TABLE_NAME, projection, where, whereArgs, null, null, null, "1")) {
return cursor != null && cursor.moveToFirst();
}
}
@Override
public @NonNull MessageDatabase.Reader getOutgoingStoriesTo(@NonNull RecipientId recipientId) {
Recipient recipient = Recipient.resolved(recipientId);
Long threadId = null;
if (recipient.isGroup()) {
threadId = SignalDatabase.threads().getThreadIdFor(recipientId);
}
String where = IS_STORY_CLAUSE + " AND (" + getOutgoingTypeClause() + ")";
final String[] whereArgs;
if (threadId == null) {
where += " AND " + RECIPIENT_ID + " = ?";
whereArgs = SqlUtil.buildArgs(1, 0, recipientId);
} else {
where += " AND " + THREAD_ID_WHERE;
whereArgs = SqlUtil.buildArgs(1, 0, threadId);
}
return new Reader(rawQuery(where, whereArgs));
}
@Override
public @NonNull MessageDatabase.Reader getAllOutgoingStories() {
String where = IS_STORY_CLAUSE + " AND (" + getOutgoingTypeClause() + ")";
String[] whereArgs = SqlUtil.buildArgs(1, 0);
return new Reader(rawQuery(where, whereArgs, true, -1L));
}
@Override
public @NonNull MessageDatabase.Reader getAllStories() {
String where = IS_STORY_CLAUSE;
String[] whereArgs = SqlUtil.buildArgs(1, 0);
Cursor cursor = rawQuery(where, whereArgs, true, -1L);
return new Reader(cursor);
}
@Override
public @NonNull MessageDatabase.Reader getAllStoriesFor(@NonNull RecipientId recipientId) {
long threadId = SignalDatabase.threads().getThreadIdIfExistsFor(recipientId);
String where = IS_STORY_CLAUSE + " AND " + THREAD_ID_WHERE;
String[] whereArgs = SqlUtil.buildArgs(1, 0, threadId);
Cursor cursor = rawQuery(where, whereArgs, true, -1L);
return new Reader(cursor);
}
@Override
public @NonNull MessageId getStoryId(@NonNull RecipientId authorId, long sentTimestamp) throws NoSuchMessageException {
SQLiteDatabase database = databaseHelper.getSignalReadableDatabase();
String[] projection = new String[]{ID, RECIPIENT_ID};
String where = IS_STORY_CLAUSE + " AND " + DATE_SENT + " = ?";
String[] whereArgs = SqlUtil.buildArgs(1, 0, sentTimestamp);
try (Cursor cursor = database.query(TABLE_NAME, projection, where, whereArgs, null, null, null, "1")) {
if (cursor != null && cursor.moveToFirst()) {
RecipientId rowRecipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID)));
if (Recipient.self().getId().equals(authorId) || rowRecipientId.equals(authorId)) {
return new MessageId(CursorUtil.requireLong(cursor, ID), true);
}
}
}
throw new NoSuchMessageException("No story sent at " + sentTimestamp);
}
@Override
public @NonNull List<RecipientId> getAllStoriesRecipientsList() {
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
String query = "SELECT " +
"DISTINCT " + ThreadDatabase.RECIPIENT_ID + " " +
"FROM " + TABLE_NAME + " JOIN " + ThreadDatabase.TABLE_NAME + " " +
"ON " + TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
"WHERE " + IS_STORY_CLAUSE + " " +
"ORDER BY " + ThreadDatabase.RECIPIENT_ID + " DESC";
String[] args = SqlUtil.buildArgs(1, 0);
List<RecipientId> recipientIds;
try (Cursor cursor = db.rawQuery(query, args)) {
if (cursor != null) {
recipientIds = new ArrayList<>(cursor.getCount());
while (cursor.moveToNext()) {
recipientIds.add(RecipientId.from(CursorUtil.requireLong(cursor, ThreadDatabase.RECIPIENT_ID)));
}
return recipientIds;
}
}
return Collections.emptyList();
}
@Override
public @NonNull Cursor getStoryReplies(long parentStoryId) {
String where = PARENT_STORY_ID + " = ?";
String[] whereArgs = SqlUtil.buildArgs(parentStoryId);
return rawQuery(where, whereArgs, true, 0);
}
@Override
public long getUnreadStoryCount() {
String[] columns = new String[]{"COUNT(*)"};
String where = IS_STORY_CLAUSE + " AND NOT (" + getOutgoingTypeClause() + ") AND " + READ_RECEIPT_COUNT + " = ?";
String[] whereArgs = SqlUtil.buildArgs(1, 0, 0);
try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, columns, where, whereArgs, null, null, null, null)) {
return cursor != null && cursor.moveToFirst() ? cursor.getInt(0) : 0;
}
}
@Override
public int getNumberOfStoryReplies(long parentStoryId) {
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
String[] columns = new String[]{"COUNT(*)"};
String where = PARENT_STORY_ID + " = ?";
String[] whereArgs = SqlUtil.buildArgs(parentStoryId);
try (Cursor cursor = db.query(TABLE_NAME, columns, where, whereArgs, null, null, null, null)) {
return cursor != null && cursor.moveToNext() ? cursor.getInt(0) : 0;
}
}
@Override
public boolean hasSelfReplyInStory(long parentStoryId) {
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
String[] columns = new String[]{"COUNT(*)"};
String where = PARENT_STORY_ID + " = ? AND " + RECIPIENT_ID + " = ?";
String[] whereArgs = SqlUtil.buildArgs(parentStoryId, Recipient.self().getId());
try (Cursor cursor = db.query(TABLE_NAME, columns, where, whereArgs, null, null, null, null)) {
return cursor != null && cursor.moveToNext() && cursor.getInt(0) > 0;
}
}
@Override
public @Nullable Long getOldestStorySendTimestamp() {
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
String[] columns = new String[]{DATE_SENT};
String where = IS_STORY_CLAUSE;
String[] whereArgs = SqlUtil.buildArgs(1, 0);
String orderBy = DATE_SENT + " ASC";
String limit = "1";
try (Cursor cursor = db.query(TABLE_NAME, columns, where, whereArgs, null, null, orderBy, limit)) {
return cursor != null && cursor.moveToNext() ? cursor.getLong(0) : null;
}
}
@Override
public int deleteStoriesOlderThan(long timestamp) {
SQLiteDatabase db = databaseHelper.getSignalWritableDatabase();
db.beginTransaction();
try {
String storiesBeforeTimestampWhere = IS_STORY_CLAUSE + " AND " + DATE_SENT + " < ?";
String[] sharedArgs = SqlUtil.buildArgs(1, 0, timestamp);
String deleteStoryRepliesQuery = "DELETE FROM " + TABLE_NAME + " " +
"WHERE " + PARENT_STORY_ID + " IN (" +
"SELECT " + ID + " " +
"FROM " + TABLE_NAME + " " +
"WHERE " + storiesBeforeTimestampWhere +
")";
db.rawQuery(deleteStoryRepliesQuery, sharedArgs);
try (Cursor cursor = db.query(TABLE_NAME, new String[]{RECIPIENT_ID}, storiesBeforeTimestampWhere, sharedArgs, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID)));
ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(recipientId);
}
}
int deletedStories = db.delete(TABLE_NAME, storiesBeforeTimestampWhere, sharedArgs);
db.setTransactionSuccessful();
return deletedStories;
} finally {
db.endTransaction();
}
}
@Override
public boolean isGroupQuitMessage(long messageId) {
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
@@ -563,7 +771,10 @@ public class MmsDatabase extends MessageDatabase {
public int getMessageCountForThread(long threadId) {
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
try (Cursor cursor = db.query(TABLE_NAME, COUNT, THREAD_ID_WHERE, SqlUtil.buildArgs(threadId), null, null, null)) {
String query = THREAD_ID + " = ? AND " + IS_STORY + " = ? AND " + PARENT_STORY_ID + " = ?";
String[] args = SqlUtil.buildArgs(threadId, 0, 0);
try (Cursor cursor = db.query(TABLE_NAME, COUNT, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
}
@@ -576,11 +787,10 @@ public class MmsDatabase extends MessageDatabase {
public int getMessageCountForThread(long threadId, long beforeTime) {
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
String[] cols = new String[] {"COUNT(*)"};
String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < ?";
String[] args = new String[]{String.valueOf(threadId), String.valueOf(beforeTime)};
String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < ? AND " + IS_STORY + " = ? AND " + PARENT_STORY_ID + " = ?";
String[] args = SqlUtil.buildArgs(threadId, beforeTime, 0, 0);
try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null)) {
try (Cursor cursor = db.query(TABLE_NAME, COUNT, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
}
@@ -1166,6 +1376,8 @@ public class MmsDatabase extends MessageDatabase {
int distributionType = SignalDatabase.threads().getDistributionType(threadId);
String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.MISMATCHED_IDENTITIES));
String networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.NETWORK_FAILURE));
boolean isStory = CursorUtil.requireBoolean(cursor, IS_STORY);
MessageId parentStoryId = MessageId.fromNullable(CursorUtil.requireLong(cursor, PARENT_STORY_ID), true);
long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID));
long quoteAuthor = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR));
@@ -1214,7 +1426,7 @@ public class MmsDatabase extends MessageDatabase {
return new OutgoingExpirationUpdateMessage(recipient, timestamp, expiresIn);
}
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient, body, attachments, timestamp, subscriptionId, expiresIn, viewOnce, distributionType, quote, contacts, previews, mentions, networkFailures, mismatches);
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient, body, attachments, timestamp, subscriptionId, expiresIn, viewOnce, distributionType, isStory, parentStoryId, quote, contacts, previews, mentions, networkFailures, mismatches);
if (Types.isSecureType(outboxType)) {
return new OutgoingSecureMediaMessage(message);
@@ -1335,6 +1547,8 @@ public class MmsDatabase extends MessageDatabase {
contentValues.put(SUBSCRIPTION_ID, retrieved.getSubscriptionId());
contentValues.put(EXPIRES_IN, retrieved.getExpiresIn());
contentValues.put(VIEW_ONCE, retrieved.isViewOnce() ? 1 : 0);
contentValues.put(IS_STORY, retrieved.isStory() ? 1 : 0);
contentValues.put(PARENT_STORY_ID, retrieved.getParentStoryId() != null ? retrieved.getParentStoryId().getId() : 0);
contentValues.put(READ, retrieved.isExpirationUpdate() ? 1 : 0);
contentValues.put(UNIDENTIFIED, retrieved.isUnidentified());
contentValues.put(SERVER_GUID, retrieved.getServerGuid());
@@ -1366,7 +1580,7 @@ public class MmsDatabase extends MessageDatabase {
long messageId = insertMediaMessage(threadId, retrieved.getBody(), retrieved.getAttachments(), quoteAttachments, retrieved.getSharedContacts(), retrieved.getLinkPreviews(), retrieved.getMentions(), retrieved.getMessageRanges(), contentValues, null, true);
if (!Types.isExpirationTimerUpdate(mailbox)) {
if (!Types.isExpirationTimerUpdate(mailbox) && !retrieved.isStory() && retrieved.getParentStoryId() == null) {
SignalDatabase.threads().incrementUnread(threadId, 1);
SignalDatabase.threads().update(threadId, true);
}
@@ -1528,6 +1742,8 @@ public class MmsDatabase extends MessageDatabase {
contentValues.put(RECIPIENT_ID, message.getRecipient().getId().serialize());
contentValues.put(DELIVERY_RECEIPT_COUNT, Stream.of(earlyDeliveryReceipts.values()).mapToLong(EarlyReceiptCache.Receipt::getCount).sum());
contentValues.put(RECEIPT_TIMESTAMP, Stream.of(earlyDeliveryReceipts.values()).mapToLong(EarlyReceiptCache.Receipt::getTimestamp).max().orElse(-1));
contentValues.put(IS_STORY, message.isStory() ? 1 : 0);
contentValues.put(PARENT_STORY_ID, message.getParentStoryId() != null ? message.getParentStoryId().getId() : 0);
if (message.getRecipient().isSelf() && hasAudioAttachment(message.getAttachments())) {
contentValues.put(VIEWED_RECEIPT_COUNT, 1L);
@@ -1581,7 +1797,12 @@ public class MmsDatabase extends MessageDatabase {
SignalDatabase.threads().updateLastSeenAndMarkSentAndLastScrolledSilenty(threadId);
ApplicationDependencies.getDatabaseObserver().notifyMessageInsertObservers(threadId, new MessageId(messageId, true));
if (!message.isStory()) {
ApplicationDependencies.getDatabaseObserver().notifyMessageInsertObservers(threadId, new MessageId(messageId, true));
} else {
ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(message.getRecipient().getId());
}
notifyConversationListListeners();
TrimThreadJob.enqueueAsync(threadId);

View File

@@ -28,8 +28,8 @@ import com.annimon.stream.Stream;
import net.zetetic.database.sqlcipher.SQLiteQueryBuilder;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId;
import org.thoughtcrime.securesms.database.MessageDatabase.MessageUpdate;
import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.notifications.v2.MessageNotifierV2;
@@ -110,13 +110,15 @@ public class MmsSmsDatabase extends Database {
MmsSmsColumns.NOTIFIED_TIMESTAMP,
MmsSmsColumns.VIEWED_RECEIPT_COUNT,
MmsSmsColumns.RECEIPT_TIMESTAMP,
MmsDatabase.MESSAGE_RANGES};
MmsDatabase.MESSAGE_RANGES,
MmsDatabase.IS_STORY,
MmsDatabase.PARENT_STORY_ID};
private static final String SNIPPET_QUERY = "SELECT " + MmsSmsColumns.ID + ", 0 AS " + TRANSPORT + ", " + SmsDatabase.TYPE + " AS " + MmsSmsColumns.NORMALIZED_TYPE + ", " + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " FROM " + SmsDatabase.TABLE_NAME + " " +
"WHERE " + MmsSmsColumns.THREAD_ID + " = ? AND " + SmsDatabase.TYPE + " NOT IN (" + SmsDatabase.Types.PROFILE_CHANGE_TYPE + ", " + SmsDatabase.Types.GV1_MIGRATION_TYPE + ", " + SmsDatabase.Types.CHANGE_NUMBER_TYPE + ", " + SmsDatabase.Types.BOOST_REQUEST_TYPE + ") AND " + SmsDatabase.TYPE + " & " + GROUP_V2_LEAVE_BITS + " != " + GROUP_V2_LEAVE_BITS + " " +
"UNION ALL " +
"SELECT " + MmsSmsColumns.ID + ", 1 AS " + TRANSPORT + ", " + MmsDatabase.MESSAGE_BOX + " AS " + MmsSmsColumns.NORMALIZED_TYPE + ", " + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " FROM " + MmsDatabase.TABLE_NAME + " " +
"WHERE " + MmsSmsColumns.THREAD_ID + " = ? AND " + MmsDatabase.MESSAGE_BOX + " & " + GROUP_V2_LEAVE_BITS + " != " + GROUP_V2_LEAVE_BITS + " " +
"WHERE " + MmsSmsColumns.THREAD_ID + " = ? AND " + MmsDatabase.MESSAGE_BOX + " & " + GROUP_V2_LEAVE_BITS + " != " + GROUP_V2_LEAVE_BITS + " AND " + MmsDatabase.IS_STORY + " = 0 AND " + MmsDatabase.PARENT_STORY_ID + " = 0 " +
"ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " +
"LIMIT 1";
@@ -200,7 +202,7 @@ public class MmsSmsDatabase extends Database {
public Cursor getConversation(long threadId, long offset, long limit) {
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsDatabase.IS_STORY + " = 0 AND " + MmsDatabase.PARENT_STORY_ID + " = 0";
String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null;
String query = buildQuery(PROJECTION, selection, order, limitStr, false);
@@ -687,15 +689,29 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AS " + MmsSmsColumns.ID,
"'MMS::' || " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " || '::' || " + MmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.UNIQUE_ROW_ID,
attachmentJsonJoin + " AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID,
SmsDatabase.TYPE, SmsDatabase.RECIPIENT_ID, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE,
MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT,
MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID,
MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY, MmsDatabase.STATUS,
SmsDatabase.BODY,
MmsSmsColumns.READ,
MmsSmsColumns.THREAD_ID,
SmsDatabase.TYPE,
SmsDatabase.RECIPIENT_ID,
SmsDatabase.ADDRESS_DEVICE_ID,
SmsDatabase.SUBJECT,
MmsDatabase.MESSAGE_TYPE,
MmsDatabase.MESSAGE_BOX,
SmsDatabase.STATUS,
MmsDatabase.PART_COUNT,
MmsDatabase.CONTENT_LOCATION,
MmsDatabase.TRANSACTION_ID,
MmsDatabase.MESSAGE_SIZE,
MmsDatabase.EXPIRY,
MmsDatabase.STATUS,
MmsDatabase.UNIDENTIFIED,
MmsSmsColumns.DELIVERY_RECEIPT_COUNT, MmsSmsColumns.READ_RECEIPT_COUNT,
MmsSmsColumns.DELIVERY_RECEIPT_COUNT,
MmsSmsColumns.READ_RECEIPT_COUNT,
MmsSmsColumns.MISMATCHED_IDENTITIES,
MmsSmsColumns.SUBSCRIPTION_ID, MmsSmsColumns.EXPIRES_IN, MmsSmsColumns.EXPIRE_STARTED,
MmsSmsColumns.SUBSCRIPTION_ID,
MmsSmsColumns.EXPIRES_IN,
MmsSmsColumns.EXPIRE_STARTED,
MmsSmsColumns.NOTIFIED,
MmsDatabase.NETWORK_FAILURE, TRANSPORT,
MmsDatabase.QUOTE_ID,
@@ -715,7 +731,9 @@ public class MmsSmsDatabase extends Database {
MmsSmsColumns.NOTIFIED_TIMESTAMP,
MmsSmsColumns.VIEWED_RECEIPT_COUNT,
MmsSmsColumns.RECEIPT_TIMESTAMP,
MmsDatabase.MESSAGE_RANGES};
MmsDatabase.MESSAGE_RANGES,
MmsDatabase.IS_STORY,
MmsDatabase.PARENT_STORY_ID};
String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT,
SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED,
@@ -749,7 +767,9 @@ public class MmsSmsDatabase extends Database {
MmsSmsColumns.NOTIFIED_TIMESTAMP,
MmsSmsColumns.VIEWED_RECEIPT_COUNT,
MmsSmsColumns.RECEIPT_TIMESTAMP,
MmsDatabase.MESSAGE_RANGES};
MmsDatabase.MESSAGE_RANGES,
"0 AS " + MmsDatabase.IS_STORY,
"0 AS " + MmsDatabase.PARENT_STORY_ID };
SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder();
SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder();
@@ -812,6 +832,8 @@ public class MmsSmsDatabase extends Database {
mmsColumnsPresent.add(MmsSmsColumns.VIEWED_RECEIPT_COUNT);
mmsColumnsPresent.add(MmsSmsColumns.RECEIPT_TIMESTAMP);
mmsColumnsPresent.add(MmsDatabase.MESSAGE_RANGES);
mmsColumnsPresent.add(MmsDatabase.IS_STORY);
mmsColumnsPresent.add(MmsDatabase.PARENT_STORY_ID);
Set<String> smsColumnsPresent = new HashSet<>();
smsColumnsPresent.add(MmsSmsColumns.ID);
@@ -836,9 +858,11 @@ public class MmsSmsDatabase extends Database {
smsColumnsPresent.add(SmsDatabase.UNIDENTIFIED);
smsColumnsPresent.add(SmsDatabase.REACTIONS_UNREAD);
smsColumnsPresent.add(SmsDatabase.REACTIONS_LAST_SEEN);
smsColumnsPresent.add(MmsDatabase.REMOTE_DELETED);
smsColumnsPresent.add(MmsSmsColumns.REMOTE_DELETED);
smsColumnsPresent.add(MmsSmsColumns.NOTIFIED_TIMESTAMP);
smsColumnsPresent.add(MmsSmsColumns.RECEIPT_TIMESTAMP);
smsColumnsPresent.add("0 AS " + MmsDatabase.IS_STORY);
smsColumnsPresent.add("0 AS " + MmsDatabase.PARENT_STORY_ID);
String mmsGroupBy = includeAttachments ? MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID : null;

View File

@@ -72,7 +72,7 @@ class ReactionDatabase(context: Context, databaseHelper: SignalDatabase) : Datab
val reactions: MutableList<ReactionRecord> = mutableListOf()
databaseHelper.signalReadableDatabase.query(TABLE_NAME, null, query, args, null, null, null).use { cursor ->
readableDatabase.query(TABLE_NAME, null, query, args, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
reactions += readReaction(cursor)
}
@@ -91,7 +91,7 @@ class ReactionDatabase(context: Context, databaseHelper: SignalDatabase) : Datab
val args: List<Array<String>> = messageIds.map { SqlUtil.buildArgs(it.id, if (it.mms) 1 else 0) }
for (query: SqlUtil.Query in SqlUtil.buildCustomCollectionQuery("$MESSAGE_ID = ? AND $IS_MMS = ?", args)) {
databaseHelper.signalReadableDatabase.query(TABLE_NAME, null, query.where, query.whereArgs, null, null, null).use { cursor ->
readableDatabase.query(TABLE_NAME, null, query.where, query.whereArgs, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
val reaction: ReactionRecord = readReaction(cursor)
val messageId = MessageId(
@@ -115,9 +115,8 @@ class ReactionDatabase(context: Context, databaseHelper: SignalDatabase) : Datab
}
fun addReaction(messageId: MessageId, reaction: ReactionRecord) {
val db: SQLiteDatabase = databaseHelper.signalWritableDatabase
db.beginTransaction()
writableDatabase.beginTransaction()
try {
val values = ContentValues().apply {
put(MESSAGE_ID, messageId.id)
@@ -128,41 +127,40 @@ class ReactionDatabase(context: Context, databaseHelper: SignalDatabase) : Datab
put(DATE_RECEIVED, reaction.dateReceived)
}
db.insert(TABLE_NAME, null, values)
writableDatabase.insert(TABLE_NAME, null, values)
if (messageId.mms) {
SignalDatabase.mms.updateReactionsUnread(db, messageId.id, hasReactions(messageId), false)
SignalDatabase.mms.updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), false)
} else {
SignalDatabase.sms.updateReactionsUnread(db, messageId.id, hasReactions(messageId), false)
SignalDatabase.sms.updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), false)
}
db.setTransactionSuccessful()
writableDatabase.setTransactionSuccessful()
} finally {
db.endTransaction()
writableDatabase.endTransaction()
}
ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(messageId)
}
fun deleteReaction(messageId: MessageId, recipientId: RecipientId) {
val db: SQLiteDatabase = databaseHelper.signalWritableDatabase
db.beginTransaction()
writableDatabase.beginTransaction()
try {
val query = "$MESSAGE_ID = ? AND $IS_MMS = ? AND $AUTHOR_ID = ?"
val args = SqlUtil.buildArgs(messageId.id, if (messageId.mms) 1 else 0, recipientId)
db.delete(TABLE_NAME, query, args)
writableDatabase.delete(TABLE_NAME, query, args)
if (messageId.mms) {
SignalDatabase.mms.updateReactionsUnread(db, messageId.id, hasReactions(messageId), true)
SignalDatabase.mms.updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), true)
} else {
SignalDatabase.sms.updateReactionsUnread(db, messageId.id, hasReactions(messageId), true)
SignalDatabase.sms.updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), true)
}
db.setTransactionSuccessful()
writableDatabase.setTransactionSuccessful()
} finally {
db.endTransaction()
writableDatabase.endTransaction()
}
ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(messageId)
@@ -176,7 +174,7 @@ class ReactionDatabase(context: Context, databaseHelper: SignalDatabase) : Datab
val query = "$MESSAGE_ID = ? AND $IS_MMS = ? AND $AUTHOR_ID = ? AND $EMOJI = ?"
val args = SqlUtil.buildArgs(messageId.id, if (messageId.mms) 1 else 0, reaction.author, reaction.emoji)
databaseHelper.signalReadableDatabase.query(TABLE_NAME, arrayOf(MESSAGE_ID), query, args, null, null, null).use { cursor ->
readableDatabase.query(TABLE_NAME, arrayOf(MESSAGE_ID), query, args, null, null, null).use { cursor ->
return cursor.moveToFirst()
}
}
@@ -185,7 +183,7 @@ class ReactionDatabase(context: Context, databaseHelper: SignalDatabase) : Datab
val query = "$MESSAGE_ID = ? AND $IS_MMS = ?"
val args = SqlUtil.buildArgs(messageId.id, if (messageId.mms) 1 else 0)
databaseHelper.signalReadableDatabase.query(TABLE_NAME, arrayOf(MESSAGE_ID), query, args, null, null, null).use { cursor ->
readableDatabase.query(TABLE_NAME, arrayOf(MESSAGE_ID), query, args, null, null, null).use { cursor ->
return cursor.moveToFirst()
}
}
@@ -197,7 +195,7 @@ class ReactionDatabase(context: Context, databaseHelper: SignalDatabase) : Datab
put(AUTHOR_ID, newAuthorId.serialize())
}
databaseHelper.signalWritableDatabase.update(TABLE_NAME, values, query, args)
readableDatabase.update(TABLE_NAME, values, query, args)
}
fun deleteAbandonedReactions() {

View File

@@ -6,6 +6,7 @@ import android.database.Cursor
import android.net.Uri
import android.text.TextUtils
import androidx.annotation.VisibleForTesting
import androidx.core.content.contentValuesOf
import com.google.protobuf.ByteString
import com.google.protobuf.InvalidProtocolBufferException
import net.zetetic.database.sqlcipher.SQLiteConstraintException
@@ -28,6 +29,7 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.GroupDatabase.LegacyGroupInsertException
import org.thoughtcrime.securesms.database.GroupDatabase.MissedGroupMigrationInsertException
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.distributionLists
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groups
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.identities
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messageLog
@@ -35,6 +37,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.notification
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.reactions
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.sessions
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.RecipientRecord
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
@@ -88,19 +91,11 @@ import org.whispersystems.signalservice.api.storage.SignalGroupV2Record
import org.whispersystems.signalservice.api.storage.StorageId
import java.io.Closeable
import java.io.IOException
import java.lang.AssertionError
import java.lang.IllegalStateException
import java.lang.StringBuilder
import java.util.ArrayList
import java.util.Arrays
import java.util.Collections
import java.util.HashMap
import java.util.HashSet
import java.util.LinkedHashSet
import java.util.LinkedList
import java.util.Objects
import java.util.concurrent.TimeUnit
import kotlin.jvm.Throws
import kotlin.math.max
open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : Database(context, databaseHelper) {
@@ -117,6 +112,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
const val PHONE = "phone"
const val EMAIL = "email"
const val GROUP_ID = "group_id"
const val DISTRIBUTION_LIST_ID = "distribution_list_id"
const val GROUP_TYPE = "group_type"
private const val BLOCKED = "blocked"
private const val MESSAGE_RINGTONE = "message_ringtone"
@@ -141,12 +137,12 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
private const val PROFILE_KEY = "profile_key"
private const val PROFILE_KEY_CREDENTIAL = "profile_key_credential"
private const val SIGNAL_PROFILE_AVATAR = "signal_profile_avatar"
private const val PROFILE_SHARING = "profile_sharing"
const val PROFILE_SHARING = "profile_sharing"
private const val LAST_PROFILE_FETCH = "last_profile_fetch"
private const val UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode"
const val FORCE_SMS_SELECTION = "force_sms_selection"
private const val CAPABILITIES = "capabilities"
private const val STORAGE_SERVICE_ID = "storage_service_key"
const val STORAGE_SERVICE_ID = "storage_service_key"
private const val PROFILE_GIVEN_NAME = "signal_profile_name"
private const val PROFILE_FAMILY_NAME = "profile_family_name"
private const val PROFILE_JOINED_NAME = "profile_joined_name"
@@ -222,7 +218,8 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
$CHAT_COLORS BLOB DEFAULT NULL,
$CUSTOM_CHAT_COLORS_ID INTEGER DEFAULT 0,
$BADGES BLOB DEFAULT NULL,
$PNI_COLUMN TEXT DEFAULT NULL
$PNI_COLUMN TEXT DEFAULT NULL,
$DISTRIBUTION_LIST_ID INTEGER DEFAULT NULL
)
""".trimIndent()
@@ -280,7 +277,8 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
GROUPS_IN_COMMON,
CHAT_COLORS,
CUSTOM_CHAT_COLORS_ID,
BADGES
BADGES,
DISTRIBUTION_LIST_ID
)
private val ID_PROJECTION = arrayOf(ID)
@@ -578,6 +576,18 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
return getOrInsertByColumn(EMAIL, email).recipientId
}
fun getOrInsertFromDistributionListId(distributionListId: DistributionListId): RecipientId {
return getOrInsertByColumn(
DISTRIBUTION_LIST_ID,
distributionListId.serialize(),
ContentValues().apply {
put(DISTRIBUTION_LIST_ID, distributionListId.serialize())
put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey()))
put(PROFILE_SHARING, 1)
}
).recipientId
}
fun getOrInsertFromGroupId(groupId: GroupId): RecipientId {
var existing = getByGroupId(groupId)
@@ -783,7 +793,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
val values = getValuesForStorageContact(insert, true)
val id = db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE)
val recipientId: RecipientId?
val recipientId: RecipientId
if (id < 0) {
Log.w(TAG, "[applyStorageSyncContactInsert] Failed to insert. Possibly merging.")
recipientId = getAndPossiblyMerge(if (insert.address.hasValidServiceId()) insert.address.serviceId else null, insert.address.number.orNull(), true)
@@ -795,13 +805,17 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
if (insert.identityKey.isPresent && insert.address.hasValidServiceId()) {
try {
val identityKey = IdentityKey(insert.identityKey.get(), 0)
identities.updateIdentityAfterSync(insert.address.identifier, recipientId!!, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(insert.identityState))
identities.updateIdentityAfterSync(insert.address.identifier, recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(insert.identityState))
} catch (e: InvalidKeyException) {
Log.w(TAG, "Failed to process identity key during insert! Skipping.", e)
}
}
threadDatabase.applyStorageSyncUpdate(recipientId!!, insert)
updateExtras(recipientId) {
it.setHideStory(insert.shouldHideStory())
}
threadDatabase.applyStorageSyncUpdate(recipientId, insert)
}
fun applyStorageSyncContactUpdate(update: StorageRecordUpdate<SignalContactRecord>) {
@@ -850,6 +864,10 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
Log.w(TAG, "Failed to process identity key during update! Skipping.", e)
}
updateExtras(recipientId) {
it.setHideStory(update.new.shouldHideStory())
}
threads.applyStorageSyncUpdate(recipientId, update.new)
ApplicationDependencies.getDatabaseObserver().notifyRecipientChanged(recipientId)
}
@@ -891,6 +909,10 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
.build()
)
updateExtras(recipient.id) {
it.setHideStory(insert.shouldHideStory())
}
Log.i(TAG, "Scheduling request for latest group info for $groupId")
ApplicationDependencies.getJobManager().add(RequestGroupV2InfoJob(groupId))
threads.applyStorageSyncUpdate(recipient.id, insert)
@@ -908,6 +930,10 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
val masterKey = update.old.masterKeyOrThrow
val recipient = Recipient.externalGroupExact(context, GroupId.v2(masterKey))
updateExtras(recipient.id) {
it.setHideStory(update.new.shouldHideStory())
}
threads.applyStorageSyncUpdate(recipient.id, update.new)
recipient.live().refresh()
}
@@ -1015,7 +1041,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
}
/**
* @return All storage IDs for ContactRecords, excluding the ones that need to be deleted.
* @return All storage IDs for synced records, excluding the ones that need to be deleted.
*/
fun getContactStorageSyncIdsMap(): Map<RecipientId, StorageId> {
val query = """
@@ -1374,6 +1400,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
value = Bitmask.update(value, Capabilities.SENDER_KEY, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isSenderKey).serialize().toLong())
value = Bitmask.update(value, Capabilities.ANNOUNCEMENT_GROUPS, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isAnnouncementGroup).serialize().toLong())
value = Bitmask.update(value, Capabilities.CHANGE_NUMBER, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isChangeNumber).serialize().toLong())
value = Bitmask.update(value, Capabilities.STORIES, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isStories).serialize().toLong())
val values = ContentValues(1).apply {
put(CAPABILITIES, value)
@@ -1846,6 +1873,11 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
}
}
fun setHideStory(id: RecipientId, hideStory: Boolean) {
updateExtras(id) { it.setHideStory(hideStory) }
StorageSyncHelper.scheduleSyncForDataChange()
}
fun clearUsernameIfExists(username: String) {
val existingUsername = getByUsername(username)
if (existingUsername.isPresent) {
@@ -2444,8 +2476,8 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey()))
}
val query = "$ID = ? AND ($GROUP_TYPE IN (?, ?) OR $REGISTERED = ?)"
val args = SqlUtil.buildArgs(recipientId, GroupType.SIGNAL_V1.id, GroupType.SIGNAL_V2.id, RegisteredState.REGISTERED.id)
val query = "$ID = ? AND ($GROUP_TYPE IN (?, ?, ?) OR $REGISTERED = ?)"
val args = SqlUtil.buildArgs(recipientId, GroupType.SIGNAL_V1.id, GroupType.SIGNAL_V2.id, GroupType.DISTRIBUTION_LIST, RegisteredState.REGISTERED.id)
writableDatabase.update(TABLE_NAME, values, query, args)
}
@@ -2512,7 +2544,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
}
}
private fun getOrInsertByColumn(column: String, value: String): GetOrInsertResult {
private fun getOrInsertByColumn(column: String, value: String, contentValues: ContentValues = contentValuesOf(column to value)): GetOrInsertResult {
if (TextUtils.isEmpty(value)) {
throw AssertionError("$column cannot be empty.")
}
@@ -2522,12 +2554,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
if (existing.isPresent) {
return GetOrInsertResult(existing.get(), false)
} else {
val values = ContentValues().apply {
put(column, value)
put(AVATAR_COLOR, AvatarColor.random().serialize())
}
val id = writableDatabase.insert(TABLE_NAME, null, values)
val id = writableDatabase.insert(TABLE_NAME, null, contentValues)
if (id < 0) {
existing = getByColumn(column, value)
if (existing.isPresent) {
@@ -2650,6 +2677,9 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
// Notification Profiles
notificationProfiles.remapRecipient(byE164, byAci)
// DistributionLists
distributionLists.remapRecipient(byE164, byAci)
// Recipient
Log.w(TAG, "Deleting recipient $byE164", true)
db.delete(TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(byE164))
@@ -2853,6 +2883,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
e164 = cursor.requireString(PHONE),
email = cursor.requireString(EMAIL),
groupId = GroupId.parseNullableOrThrow(cursor.requireString(GROUP_ID)),
distributionListId = DistributionListId.fromNullable(cursor.requireLong(DISTRIBUTION_LIST_ID)),
groupType = GroupType.fromId(cursor.requireInt(GROUP_TYPE)),
isBlocked = cursor.requireBoolean(BLOCKED),
muteUntil = cursor.requireLong(MUTE_UNTIL),
@@ -2884,6 +2915,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
senderKeyCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.SENDER_KEY, Capabilities.BIT_LENGTH).toInt()),
announcementGroupCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.ANNOUNCEMENT_GROUPS, Capabilities.BIT_LENGTH).toInt()),
changeNumberCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.CHANGE_NUMBER, Capabilities.BIT_LENGTH).toInt()),
storiesCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.STORIES, Capabilities.BIT_LENGTH).toInt()),
insightsBannerTier = InsightsBannerTier.fromId(cursor.requireInt(SEEN_INVITE_REMINDER)),
storageId = Base64.decodeNullableOrThrow(cursor.requireString(STORAGE_SERVICE_ID)),
mentionSetting = MentionSetting.fromId(cursor.requireInt(MENTION_SETTING)),
@@ -3270,6 +3302,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
const val SENDER_KEY = 2
const val ANNOUNCEMENT_GROUPS = 3
const val CHANGE_NUMBER = 4
const val STORIES = 5
}
enum class VibrateState(val id: Int) {
@@ -3321,7 +3354,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
}
enum class GroupType(val id: Int) {
NONE(0), MMS(1), SIGNAL_V1(2), SIGNAL_V2(3);
NONE(0), MMS(1), SIGNAL_V1(2), SIGNAL_V2(3), DISTRIBUTION_LIST(4);
companion object {
fun fromId(id: Int): GroupType {

View File

@@ -24,7 +24,6 @@ import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.SqlUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import java.io.File
import java.lang.UnsupportedOperationException
open class SignalDatabase(private val context: Application, databaseSecret: DatabaseSecret, attachmentSecret: AttachmentSecret) :
SQLiteOpenHelper(
@@ -72,6 +71,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
val reactionDatabase: ReactionDatabase = ReactionDatabase(context, this)
val notificationProfileDatabase: NotificationProfileDatabase = NotificationProfileDatabase(context, this)
val donationReceiptDatabase: DonationReceiptDatabase = DonationReceiptDatabase(context, this)
val distributionListDatabase: DistributionListDatabase = DistributionListDatabase(context, this)
override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
db.enableWriteAheadLogging()
@@ -109,6 +109,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE)
executeStatements(db, MessageSendLogDatabase.CREATE_TABLE)
executeStatements(db, NotificationProfileDatabase.CREATE_TABLE)
executeStatements(db, DistributionListDatabase.CREATE_TABLE)
executeStatements(db, RecipientDatabase.CREATE_INDEXS)
executeStatements(db, SmsDatabase.CREATE_INDEXS)
@@ -130,6 +131,8 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
executeStatements(db, MessageSendLogDatabase.CREATE_TRIGGERS)
executeStatements(db, ReactionDatabase.CREATE_TRIGGERS)
DistributionListDatabase.insertInitialDistributionListAtCreationTime(db)
if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) {
val legacyHelper = ClassicOpenHelper(context)
val legacyDb = legacyHelper.writableDatabase
@@ -329,6 +332,11 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
val contacts: ContactsDatabase
get() = instance!!.contactsDatabase
@get:JvmStatic
@get:JvmName("distributionLists")
val distributionLists: DistributionListDatabase
get() = instance!!.distributionListDatabase
@get:JvmStatic
@get:JvmName("drafts")
val drafts: DraftDatabase
@@ -389,6 +397,11 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
val mmsSms: MmsSmsDatabase
get() = instance!!.mmsSmsDatabase
@get:JvmStatic
@get:JvmName("notificationProfiles")
val notificationProfiles: NotificationProfileDatabase
get() = instance!!.notificationProfileDatabase
@get:JvmStatic
@get:JvmName("payments")
val payments: PaymentDatabase
@@ -465,11 +478,6 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
val unknownStorageIds: UnknownStorageIdDatabase
get() = instance!!.storageIdDatabase
@get:JvmStatic
@get:JvmName("notificationProfiles")
val notificationProfiles: NotificationProfileDatabase
get() = instance!!.notificationProfileDatabase
@get:JvmStatic
@get:JvmName("donationReceipts")
val donationReceipts: DonationReceiptDatabase

View File

@@ -1371,6 +1371,71 @@ public class SmsDatabase extends MessageDatabase {
databaseHelper.getSignalWritableDatabase();
}
@Override
public boolean isStory(long messageId) {
throw new UnsupportedOperationException();
}
@Override
public @NonNull MessageDatabase.Reader getOutgoingStoriesTo(@NonNull RecipientId recipientId) {
throw new UnsupportedOperationException();
}
@Override
public @NonNull MessageDatabase.Reader getAllOutgoingStories() {
throw new UnsupportedOperationException();
}
@Override
public @NonNull MessageDatabase.Reader getAllStories() {
throw new UnsupportedOperationException();
}
@Override
public @NonNull List<RecipientId> getAllStoriesRecipientsList() {
throw new UnsupportedOperationException();
}
@Override
public @NonNull MessageDatabase.Reader getAllStoriesFor(@NonNull RecipientId recipientId) {
throw new UnsupportedOperationException();
}
@Override
public @NonNull MessageId getStoryId(@NonNull RecipientId authorId, long sentTimestamp) throws NoSuchMessageException {
throw new UnsupportedOperationException();
}
@Override
public int getNumberOfStoryReplies(long parentStoryId) {
throw new UnsupportedOperationException();
}
@Override
public boolean hasSelfReplyInStory(long parentStoryId) {
throw new UnsupportedOperationException();
}
@Override
public @NonNull Cursor getStoryReplies(long parentStoryId) {
throw new UnsupportedOperationException();
}
@Override
public long getUnreadStoryCount() {
throw new UnsupportedOperationException();
}
@Override
public @Nullable Long getOldestStorySendTimestamp() {
throw new UnsupportedOperationException();
}
@Override
public int deleteStoriesOlderThan(long timestamp) {
throw new UnsupportedOperationException();
}
@Override
public MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException {
return getSmsMessage(messageId);

View File

@@ -495,6 +495,19 @@ public class ThreadDatabase extends Database {
}
}
public long getUnreadThreadCount() {
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
String[] projection = SqlUtil.buildArgs("COUNT(*)");
String where = READ + " != 1";
try (Cursor cursor = db.query(TABLE_NAME, projection, where, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getLong(0);
} else {
return 0;
}
}
}
public void incrementUnread(long threadId, int amount) {
SQLiteDatabase db = databaseHelper.getSignalWritableDatabase();
@@ -1607,6 +1620,7 @@ public class ThreadDatabase extends Database {
recipientSettings,
null,
false);
recipient = new Recipient(recipientId, details, false);
} else {
recipient = Recipient.live(recipientId).get();

View File

@@ -10,6 +10,7 @@ import android.os.Build
import android.os.SystemClock
import android.preference.PreferenceManager
import android.text.TextUtils
import androidx.core.content.contentValuesOf
import com.annimon.stream.Stream
import com.google.protobuf.InvalidProtocolBufferException
import net.zetetic.database.sqlcipher.SQLiteDatabase
@@ -21,6 +22,7 @@ import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ChatColorsMapper.entrySet
import org.thoughtcrime.securesms.database.KeyValueDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.databaseprotos.ReactionList
import org.thoughtcrime.securesms.database.requireString
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@@ -50,6 +52,7 @@ import java.io.FileInputStream
import java.io.IOException
import java.util.LinkedList
import java.util.Locale
import java.util.UUID
/**
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
@@ -188,8 +191,9 @@ object SignalDatabaseMigrations {
private const val REACTION_TRIGGER_FIX = 129
private const val PNI_STORES = 130
private const val DONATION_RECEIPTS = 131
private const val STORIES = 132
const val DATABASE_VERSION = 131
const val DATABASE_VERSION = 132
@JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@@ -2414,6 +2418,59 @@ object SignalDatabaseMigrations {
db.execSQL("CREATE INDEX IF NOT EXISTS donation_receipt_type_index ON donation_receipt (receipt_type);")
db.execSQL("CREATE INDEX IF NOT EXISTS donation_receipt_date_index ON donation_receipt (receipt_date);")
}
if (oldVersion < STORIES) {
db.execSQL("ALTER TABLE mms ADD COLUMN is_story INTEGER DEFAULT 0")
db.execSQL("ALTER TABLE mms ADD COLUMN parent_story_id INTEGER DEFAULT 0")
db.execSQL("CREATE INDEX IF NOT EXISTS mms_is_story_index ON mms (is_story)")
db.execSQL("CREATE INDEX IF NOT EXISTS mms_parent_story_id_index ON mms (parent_story_id)")
db.execSQL("ALTER TABLE recipient ADD COLUMN distribution_list_id INTEGER DEFAULT NULL")
db.execSQL(
// language=sql
"""
CREATE TABLE distribution_list (
_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
distribution_id TEXT UNIQUE NOT NULL,
recipient_id INTEGER UNIQUE REFERENCES recipient (_id) ON DELETE CASCADE
)
""".trimIndent()
)
db.execSQL(
// language=sql
"""
CREATE TABLE distribution_list_member (
_id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL REFERENCES distribution_list (_id) ON DELETE CASCADE,
recipient_id INTEGER NOT NULL,
UNIQUE(list_id, recipient_id) ON CONFLICT IGNORE
)
""".trimIndent()
)
val recipientId = db.insert(
"recipient", null,
contentValuesOf(
"distribution_list_id" to DistributionListId.MY_STORY_ID,
"storage_service_key" to Base64.encodeBytes(StorageSyncHelper.generateKey()),
"profile_sharing" to 1
)
)
val listUUID = UUID.randomUUID().toString()
db.insert(
"distribution_list", null,
contentValuesOf(
"_id" to DistributionListId.MY_STORY_ID,
"name" to listUUID,
"distribution_id" to listUUID,
"recipient_id" to recipientId
)
)
}
}
@JvmStatic

View File

@@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.database.model;
import androidx.annotation.NonNull;
public interface DatabaseId {
@NonNull String serialize();
}

View File

@@ -0,0 +1,84 @@
package org.thoughtcrime.securesms.database.model;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Objects;
/**
* A wrapper around the primary key of the distribution list database to provide strong typing.
*/
public final class DistributionListId implements DatabaseId, Parcelable {
public static final long MY_STORY_ID = 1L;
public static final DistributionListId MY_STORY = DistributionListId.from(MY_STORY_ID);
private final long id;
public static @NonNull DistributionListId from(long id) {
if (id <= 0) {
throw new IllegalArgumentException("Invalid ID! " + id);
}
return new DistributionListId(id);
}
public static @Nullable DistributionListId fromNullable(long id) {
if (id > 0) {
return new DistributionListId(id);
} else {
return null;
}
}
private DistributionListId(long id) {
this.id = id;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeLong(id);
}
@Override
public int describeContents() {
return 0;
}
@Override
public @NonNull String serialize() {
return String.valueOf(id);
}
@Override
public @NonNull String toString() {
return "DistributionListId::" + id;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final DistributionListId that = (DistributionListId) o;
return id == that.id;
}
@Override
public int hashCode() {
return Objects.hash(id);
}
public static final Creator<DistributionListId> CREATOR = new Creator<DistributionListId>() {
@Override
public DistributionListId createFromParcel(Parcel in) {
return new DistributionListId(in.readLong());
}
@Override
public DistributionListId[] newArray(int size) {
return new DistributionListId[size];
}
};
}

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.database.model
import org.thoughtcrime.securesms.recipients.RecipientId
data class DistributionListPartialRecord(
val id: DistributionListId,
val name: CharSequence,
val recipientId: RecipientId
)

View File

@@ -0,0 +1,14 @@
package org.thoughtcrime.securesms.database.model
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.DistributionId
/**
* Represents an entry in the [org.thoughtcrime.securesms.database.DistributionListDatabase].
*/
data class DistributionListRecord(
val id: DistributionListId,
val name: String,
val distributionId: DistributionId,
val members: List<RecipientId>
)

View File

@@ -13,6 +13,18 @@ data class MessageId(
}
companion object {
/**
* Returns null for invalid IDs. Useful when pulling a possibly-unset ID from a database, or something like that.
*/
@JvmStatic
fun fromNullable(id: Long, mms: Boolean): MessageId? {
return if (id > 0) {
MessageId(id, mms)
} else {
null
}
}
@JvmStatic
fun deserialize(serialized: String): MessageId {
val parts: List<String> = serialized.split("|")

View File

@@ -34,6 +34,7 @@ data class RecipientRecord(
val e164: String?,
val email: String?,
val groupId: GroupId?,
val distributionListId: DistributionListId?,
val groupType: RecipientDatabase.GroupType,
val isBlocked: Boolean,
val muteUntil: Long,
@@ -70,6 +71,7 @@ data class RecipientRecord(
val senderKeyCapability: Recipient.Capability,
val announcementGroupCapability: Recipient.Capability,
val changeNumberCapability: Recipient.Capability,
val storiesCapability: Recipient.Capability,
val insightsBannerTier: InsightsBannerTier,
val storageId: ByteArray?,
val mentionSetting: MentionSetting,

View File

@@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.payments.Payments;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.LiveRecipientCache;
import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager;
import org.thoughtcrime.securesms.service.ExpiringStoriesManager;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.service.PendingRetryReceiptManager;
import org.thoughtcrime.securesms.service.TrimThreadsByDateManager;
@@ -90,6 +91,7 @@ public class ApplicationDependencies {
private static volatile DatabaseObserver databaseObserver;
private static volatile TrimThreadsByDateManager trimThreadsByDateManager;
private static volatile ViewOnceMessageManager viewOnceMessageManager;
private static volatile ExpiringStoriesManager expiringStoriesManager;
private static volatile ExpiringMessageManager expiringMessageManager;
private static volatile Payments payments;
private static volatile SignalCallManager signalCallManager;
@@ -382,6 +384,18 @@ public class ApplicationDependencies {
return viewOnceMessageManager;
}
public static @NonNull ExpiringStoriesManager getExpireStoriesManager() {
if (expiringStoriesManager == null) {
synchronized (LOCK) {
if (expiringStoriesManager == null) {
expiringStoriesManager = provider.provideExpiringStoriesManager();
}
}
}
return expiringStoriesManager;
}
public static @NonNull PendingRetryReceiptManager getPendingRetryReceiptManager() {
if (pendingRetryReceiptManager == null) {
synchronized (LOCK) {
@@ -615,6 +629,7 @@ public class ApplicationDependencies {
@NonNull IncomingMessageObserver provideIncomingMessageObserver();
@NonNull TrimThreadsByDateManager provideTrimThreadsByDateManager();
@NonNull ViewOnceMessageManager provideViewOnceMessageManager();
@NonNull ExpiringStoriesManager provideExpiringStoriesManager();
@NonNull ExpiringMessageManager provideExpiringMessageManager();
@NonNull TypingStatusRepository provideTypingStatusRepository();
@NonNull TypingStatusSender provideTypingStatusSender();

View File

@@ -53,6 +53,7 @@ import org.thoughtcrime.securesms.push.SecurityEventListener;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.LiveRecipientCache;
import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager;
import org.thoughtcrime.securesms.service.ExpiringStoriesManager;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.service.PendingRetryReceiptManager;
import org.thoughtcrime.securesms.service.TrimThreadsByDateManager;
@@ -209,6 +210,11 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr
return new ViewOnceMessageManager(context);
}
@Override
public @NonNull ExpiringStoriesManager provideExpiringStoriesManager() {
return new ExpiringStoriesManager(context);
}
@Override
public @NonNull ExpiringMessageManager provideExpiringMessageManager() {
return new ExpiringMessageManager(context);

View File

@@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.fonts
import android.content.Context
import androidx.annotation.WorkerThread
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.util.EncryptedStreamUtils
import java.io.File
/**
* FontFileMap links a network font name (e.g. Inter-Bold.ttf) to a UUID used as an on-disk filename.
* These mappings are encoded into JSON and stored on disk in a file called .map
*/
data class FontFileMap(val map: Map<String, String>) {
companion object {
private val TAG = Log.tag(FontFileMap::class.java)
private const val PATH = ".map"
private val objectMapper = ObjectMapper().registerKotlinModule()
/**
* Adds the given mapping to the .map file.
*
* @param context A context
* @param fontVersion The font version from which to get the parent directory
* @param nameOnDisk The name written to disk
* @param nameOnNetwork The network name from the manifest
*/
@WorkerThread
fun put(context: Context, fontVersion: FontVersion, nameOnDisk: String, nameOnNetwork: String) {
val fontFileMap = getMap(context, fontVersion)
@Suppress("IfThenToElvis")
val newMap = if (fontFileMap == null) {
Log.d(TAG, "Creating a new font file map.")
FontFileMap(mapOf(nameOnNetwork to nameOnDisk))
} else {
Log.d(TAG, "Modifying existing font file map.")
fontFileMap.copy(map = fontFileMap.map.plus(nameOnNetwork to nameOnDisk))
}
setMap(context, fontVersion, newMap)
}
/**
* Retrieves the on-disk name for a given network name
*
* @param context a Context
* @param fontVersion The version from which to get the parent directory
* @param nameOnNetwork The name of the font from the manifest
* @return The name of the file on disk, or null
*/
@WorkerThread
fun getNameOnDisk(context: Context, fontVersion: FontVersion, nameOnNetwork: String): String? {
val fontFileMap = getMap(context, fontVersion) ?: return null
return fontFileMap.map[nameOnNetwork]
}
@WorkerThread
private fun getMap(context: Context, fontVersion: FontVersion): FontFileMap? {
return try {
EncryptedStreamUtils.getInputStream(context, File(Fonts.getDirectory(context), "${fontVersion.path}/$PATH")).use {
objectMapper.readValue(it, FontFileMap::class.java)
}
} catch (e: Exception) {
Log.w(TAG, "Couldn't read names file.")
return null
}
}
@WorkerThread
private fun setMap(context: Context, fontVersion: FontVersion, fontFileMap: FontFileMap) {
try {
EncryptedStreamUtils.getOutputStream(context, File(Fonts.getDirectory(context), "${fontVersion.path}/$PATH")).use {
objectMapper.writeValue(it, fontFileMap)
}
} catch (e: Exception) {
Log.w(TAG, "Couldn't write names file.")
}
}
}
}

View File

@@ -0,0 +1,97 @@
package org.thoughtcrime.securesms.fonts
import android.content.Context
import androidx.annotation.WorkerThread
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.util.EncryptedStreamUtils
import java.io.File
/**
* Description of available scripts and fonts for different locales.
*
* @param scripts A collection of supported scripts
*/
data class FontManifest(
val scripts: FontScripts
) {
/**
* A collection of supported scripts
*
* @param latinExtended LATN Script fonts
* @param cyrillicExtended CYRL Script fonts
* @param devanagari DEVA Script fonts
* @param chineseTraditionalHk Hans / HK Script Fonts
* @param chineseTraditional Hant Script Fonts
* @param chineseSimplified Hans Script Fonts
*/
data class FontScripts(
@JsonProperty("latin-extended") val latinExtended: FontScript,
@JsonProperty("cyrillic-extended") val cyrillicExtended: FontScript,
val devanagari: FontScript,
@JsonProperty("chinese-traditional-hk") val chineseTraditionalHk: FontScript,
@JsonProperty("chinese-traditional") val chineseTraditional: FontScript,
@JsonProperty("chinese-simplified") val chineseSimplified: FontScript,
val arabic: FontScript,
val japanese: FontScript,
)
/**
* A collection of fonts for a specific script
*/
data class FontScript(
val regular: String,
val bold: String,
val serif: String,
val script: String,
val condensed: String
)
companion object {
private val TAG = Log.tag(FontManifest::class.java)
private const val PATH = ".manifest"
private val objectMapper = ObjectMapper().registerKotlinModule()
/**
* Gets the latest manifest object for the given version. This may hit the network, disk, or both, depending on whether we have
* a cached manifest available for the given version.
*/
@WorkerThread
fun get(context: Context, fontVersion: FontVersion): FontManifest? {
return fromDisk(context, fontVersion) ?: fromNetwork(context, fontVersion)
}
@WorkerThread
private fun fromDisk(context: Context, fontVersion: FontVersion): FontManifest? {
if (fontVersion.path.isEmpty()) {
throw AssertionError()
}
return try {
EncryptedStreamUtils.getInputStream(context, File(Fonts.getDirectory(context), fontVersion.manifestPath())).use {
objectMapper.readValue(it, FontManifest::class.java)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to load manifest from disk")
return null
}
}
@WorkerThread
private fun fromNetwork(context: Context, fontVersion: FontVersion): FontManifest? {
return if (Fonts.downloadAndVerifyLatestManifest(context, fontVersion, fontVersion.manifestPath())) {
fromDisk(context, fontVersion)
} else {
null
}
}
private fun FontVersion.manifestPath(): String {
return "$path/$PATH"
}
}
}

View File

@@ -0,0 +1,129 @@
package org.thoughtcrime.securesms.fonts
import android.content.Context
import androidx.annotation.WorkerThread
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.EncryptedStreamUtils
import java.io.File
import java.util.UUID
import java.util.concurrent.TimeUnit
/**
* Represents a single version of fonts.
*
* @param id The numeric ID of this version, retrieved from the server
* @param path The UUID path of this version on disk, where supporting files will be stored.
*/
data class FontVersion(val id: Long, val path: String) {
companion object {
val NONE = FontVersion(-1, "")
private val TAG = Log.tag(FontVersion::class.java)
private val VERSION_CHECK_INTERVAL = TimeUnit.DAYS.toMillis(7)
private const val PATH = ".version"
private val objectMapper = ObjectMapper().registerKotlinModule()
/**
* Retrieves the latest font version. This may hit the disk, network, or both, depending on when we last checked for a font version.
*/
@WorkerThread
fun get(context: Context): FontVersion {
val fromDisk = fromDisk(context)
val version: FontVersion = if (System.currentTimeMillis() - SignalStore.storyValues().lastFontVersionCheck > VERSION_CHECK_INTERVAL) {
Log.i(TAG, "Timeout interval exceeded, checking network for new font version.")
val fromNetwork = fromNetwork()
if (fromDisk == null && fromNetwork == null) {
Log.i(TAG, "Couldn't download font version and none present on disk.")
return NONE
} else if (fromDisk == null && fromNetwork != null) {
Log.i(TAG, "Found initial font version.")
return writeVersionToDisk(context, fromNetwork) ?: NONE
} else if (fromDisk != null && fromNetwork != null) {
if (fromDisk.id < fromNetwork.id) {
Log.i(TAG, "Found a new font version. Replacing old version")
writeVersionToDisk(context, fromNetwork) ?: NONE
} else {
Log.i(TAG, "Network version is the same as our local version.")
fromDisk
}
} else {
Log.i(TAG, "Couldn't download font version, using what we have.")
fromDisk ?: NONE
}
} else {
Log.i(TAG, "Timeout interval not exceeded, using what we have.")
fromDisk ?: NONE
}
cleanOldVersions(context, version.path)
return version
}
@WorkerThread
private fun writeVersionToDisk(context: Context, fontVersion: FontVersion): FontVersion? {
return try {
val versionPath = File(Fonts.getDirectory(context), PATH)
if (versionPath.exists()) {
versionPath.delete()
}
EncryptedStreamUtils.getOutputStream(context, versionPath).use {
objectMapper.writeValue(it, fontVersion)
}
File(Fonts.getDirectory(context), fontVersion.path).mkdir()
Log.i(TAG, "Wrote version ${fontVersion.id} to disk.")
SignalStore.storyValues().lastFontVersionCheck = System.currentTimeMillis()
fontVersion
} catch (e: Exception) {
Log.e(TAG, "Failed to write new font version to disk", e)
null
}
}
@WorkerThread
private fun fromDisk(context: Context): FontVersion? {
return try {
EncryptedStreamUtils.getInputStream(context, File(Fonts.getDirectory(context), PATH)).use {
objectMapper.readValue(it, FontVersion::class.java)
}
} catch (e: Exception) {
Log.w(TAG, "Could not read font version from disk.")
null
}
}
@WorkerThread
private fun fromNetwork(): FontVersion? {
return try {
FontVersion(Fonts.downloadLatestVersionLong(), UUID.randomUUID().toString()).apply {
Log.i(TAG, "Downloaded version $id")
}
} catch (e: Exception) {
Log.w(TAG, "Could not read font version from network.", e)
null
}
}
@WorkerThread
private fun cleanOldVersions(context: Context, path: String) {
if (path.isEmpty()) {
Log.i(TAG, "No versions downloaded. Skipping cleanup.")
return
}
Fonts.getDirectory(context)
.listFiles { _, name -> name != path && name != PATH }
?.apply { Log.i(TAG, "Deleting $size files") }
?.forEach { it.delete() }
}
}
}

View File

@@ -0,0 +1,207 @@
package org.thoughtcrime.securesms.fonts
import android.content.Context
import android.graphics.Typeface
import androidx.annotation.WorkerThread
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.s3.S3
import org.thoughtcrime.securesms.util.ListenableFutureTask
import java.io.File
import java.util.Collections
import java.util.Locale
import java.util.UUID
/**
* Text Story Fonts management
*
* Fonts are stored on S3 in a bucket called story-fonts, and are backed by a version number.
* At that version, there is a manifest.json that contains information about which fonts are available for which script
*
* This utilizes a file structure like so:
*
* .version ( long -> UUID )
* uuid/
* .manifest (manifest JSON)
* .map ( object name -> UUID )
* uuid1
* uuid2
* ...
*/
object Fonts {
private val TAG = Log.tag(Fonts::class.java)
private const val VERSION_URL = "https://updates.signal.org/dynamic/story-fonts/version.txt"
private const val BASE_STATIC_BUCKET_URL = "https://updates.signal.org/static/story-fonts"
private const val MANIFEST = "manifest.json"
private val taskCache = Collections.synchronizedMap(mutableMapOf<FontDownloadKey, ListenableFutureTask<Typeface>>())
/**
* Returns a File which font data should be written to.
*/
fun getDirectory(context: Context): File {
return context.getDir("story-fonts", Context.MODE_PRIVATE)
}
/**
* Attempts to retrieve a Typeface for the given font / locale combination
*
* @param context An application context
* @param locale The locale the content will be displayed in
* @param font The desired font
* @return a FontResult that represents either a Typeface or a task retrieving a Typeface.
*/
@WorkerThread
fun resolveFont(context: Context, locale: Locale, font: TextFont): FontResult {
synchronized(this) {
val errorFallback = FontResult.Immediate(Typeface.create(font.fallbackFamily, font.fallbackStyle))
val version = FontVersion.get(context)
if (version == FontVersion.NONE) {
return errorFallback
}
val manifest = FontManifest.get(context, version) ?: return errorFallback
Log.d(TAG, "Loaded manifest.")
val fontScript = resolveScriptNameFromLocale(locale, manifest) ?: return errorFallback
Log.d(TAG, "Loaded script for locale.")
val fontNetworkPath = getScriptPath(font, fontScript)
val fontLocalPath = FontFileMap.getNameOnDisk(context, version, fontNetworkPath)
if (fontLocalPath != null) {
Log.d(TAG, "Local font version found, returning immediate.")
return FontResult.Immediate(loadFontIntoTypeface(context, version, fontLocalPath) ?: errorFallback.typeface)
}
val fontDownloadKey = FontDownloadKey(
version, locale, font
)
val taskInProgress = taskCache[fontDownloadKey]
return if (taskInProgress != null) {
Log.d(TAG, "Found a task in progress. Returning in-progress async.")
FontResult.Async(
future = taskInProgress,
placeholder = errorFallback.typeface
)
} else {
Log.d(TAG, "Could not find a task in progress. Returning new async.")
val newTask = ListenableFutureTask {
val newLocalPath = downloadFont(context, locale, font, version, manifest)
Log.d(TAG, "Finished download, $newLocalPath")
val typeface = newLocalPath?.let { loadFontIntoTypeface(context, version, it) } ?: errorFallback.typeface
taskCache.remove(fontDownloadKey)
typeface
}
taskCache[fontDownloadKey] = newTask
SignalExecutors.BOUNDED.execute(newTask::run)
FontResult.Async(
future = newTask,
placeholder = errorFallback.typeface
)
}
}
}
@WorkerThread
private fun loadFontIntoTypeface(context: Context, fontVersion: FontVersion, fontLocalPath: String): Typeface? {
return try {
Typeface.createFromFile(File(getDirectory(context), "${fontVersion.path}/$fontLocalPath"))
} catch (e: Exception) {
Log.w(TAG, "Could not load typeface from disk.")
null
}
}
/**
* Downloads the latest version code.
*/
@WorkerThread
fun downloadLatestVersionLong(): Long {
return S3.getLong(VERSION_URL)
}
/**
* Downloads and verifies the latest manifest.
*/
@WorkerThread
fun downloadAndVerifyLatestManifest(context: Context, version: FontVersion, manifestPath: String): Boolean {
return S3.verifyAndWriteToDisk(
context,
"$BASE_STATIC_BUCKET_URL/${version.id}/$MANIFEST",
File(getDirectory(context), manifestPath)
)
}
/**
* Downloads the given font file from S3
*/
@WorkerThread
private fun downloadFont(context: Context, locale: Locale, font: TextFont, fontVersion: FontVersion, fontManifest: FontManifest): String? {
val script: FontManifest.FontScript = resolveScriptNameFromLocale(locale, fontManifest) ?: return null
val path = getScriptPath(font, script)
val networkPath = "$BASE_STATIC_BUCKET_URL/${fontVersion.id}/$path"
val localUUID = UUID.randomUUID().toString()
val localPath = "${fontVersion.path}/" + localUUID
return if (S3.verifyAndWriteToDisk(context, networkPath, File(getDirectory(context), localPath), doNotEncrypt = true)) {
FontFileMap.put(context, fontVersion, localUUID, path)
localUUID
} else {
Log.w(TAG, "Failed to download and verify font.")
null
}
}
private fun getScriptPath(font: TextFont, script: FontManifest.FontScript): String {
return when (font) {
TextFont.REGULAR -> script.regular
TextFont.BOLD -> script.bold
TextFont.SERIF -> script.serif
TextFont.SCRIPT -> script.script
TextFont.CONDENSED -> script.condensed
}
}
private fun resolveScriptNameFromLocale(locale: Locale, fontManifest: FontManifest): FontManifest.FontScript? {
val fontScript: FontManifest.FontScript = when (ScriptUtil.getScript(locale).apply { Log.d(TAG, "Getting Script for $this") }) {
ScriptUtil.LATIN -> fontManifest.scripts.latinExtended
ScriptUtil.ARABIC -> fontManifest.scripts.arabic
ScriptUtil.CHINESE_SIMPLIFIED -> fontManifest.scripts.chineseSimplified
ScriptUtil.CHINESE_TRADITIONAL -> fontManifest.scripts.chineseTraditional
ScriptUtil.CYRILLIC -> fontManifest.scripts.cyrillicExtended
ScriptUtil.DEVANAGARI -> fontManifest.scripts.devanagari
ScriptUtil.JAPANESE -> fontManifest.scripts.japanese
else -> return null
}
return if (fontScript == fontManifest.scripts.chineseSimplified && locale.isO3Country == "HKG") {
fontManifest.scripts.chineseTraditionalHk
} else {
fontScript
}
}
/**
* A Typeface or an Async future retrieving a typeface with a placeholder.
*/
sealed class FontResult {
data class Immediate(val typeface: Typeface) : FontResult()
data class Async(val future: ListenableFutureTask<Typeface>, val placeholder: Typeface) : FontResult()
}
private data class FontDownloadKey(
val version: FontVersion,
val locale: Locale,
val font: TextFont
)
}

View File

@@ -0,0 +1,755 @@
package org.thoughtcrime.securesms.fonts;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
/*
* Copyright 2013 Phil Brown
*
* 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.
*
*
* Get Script name by Locale
* <br>
* @author Phil Brown
* @since 9:47:09 AM Dec 20, 2013
*
*/
class ScriptUtil {
static final String LATIN = "Latn";
static final String CYRILLIC = "Cyrl";
static final String DEVANAGARI = "Deva";
static final String CHINESE_TRADITIONAL = "Hant";
static final String CHINESE_SIMPLIFIED = "Hans";
static final String ARABIC = "Arab";
static final String JAPANESE = "Jpan";
public static Map<String, Map<String, String>> SCRIPTS_BY_LOCALE = new HashMap<>();
public static Map<String, String> getScriptsMap(String... keyValuePairs)
{
Map<String, String> languages = new HashMap<String, String>();
for (int i = 0; i < keyValuePairs.length; i += 2) {
languages.put(keyValuePairs[i], keyValuePairs[i + 1]);
}
return languages;
}
static {
SCRIPTS_BY_LOCALE.put("aa", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ab", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("abq", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("abr", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("ace", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ach", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ada", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ady", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("ae", getScriptsMap("", "Avst"));
SCRIPTS_BY_LOCALE.put("af", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("agq", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("aii", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("ain", getScriptsMap("", "Kana"));
SCRIPTS_BY_LOCALE.put("ak", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("akk", getScriptsMap("", "Xsux"));
SCRIPTS_BY_LOCALE.put("ale", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("alt", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("am", getScriptsMap("", "Ethi"));
SCRIPTS_BY_LOCALE.put("amo", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("an", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("anp", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("aoz", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("ar", getScriptsMap("", "Arab", "IR", "Syrc"));
SCRIPTS_BY_LOCALE.put("arc", getScriptsMap("", "Armi"));
SCRIPTS_BY_LOCALE.put("arn", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("arp", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("arw", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("as", getScriptsMap("", "Beng"));
SCRIPTS_BY_LOCALE.put("asa", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ast", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("atj", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("av", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("awa", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("ay", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("az", getScriptsMap("", "Latn", "AZ", "Cyrl", "IR", "Arab"));
SCRIPTS_BY_LOCALE.put("ba", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("bal", getScriptsMap("", "Arab", "IR", "Latn", "PK", "Latn"));
SCRIPTS_BY_LOCALE.put("ban", getScriptsMap("", "Latn", "ID", "Bali"));
SCRIPTS_BY_LOCALE.put("bap", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bas", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("bax", getScriptsMap("", "Bamu"));
SCRIPTS_BY_LOCALE.put("bbc", getScriptsMap("", "Latn", "ID", "Batk"));
SCRIPTS_BY_LOCALE.put("bbj", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bci", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("be", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("bej", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("bem", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("bew", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bez", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("bfd", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bfq", getScriptsMap("", "Taml"));
SCRIPTS_BY_LOCALE.put("bft", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("bfy", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("bg", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("bgc", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bgx", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bh", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("bhb", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("bhi", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bhk", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bho", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("bi", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("bik", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("bin", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("bjj", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("bjn", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bkm", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bku", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("bla", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("blt", getScriptsMap("", "Tavt"));
SCRIPTS_BY_LOCALE.put("bm", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("bmq", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bn", getScriptsMap("", "Beng"));
SCRIPTS_BY_LOCALE.put("bo", getScriptsMap("", "Tibt"));
SCRIPTS_BY_LOCALE.put("bqi", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bqv", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("br", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("bra", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("brh", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("brx", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("bs", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("bss", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bto", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("btv", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("bua", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("buc", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("bug", getScriptsMap("", "Latn", "ID", "Bugi"));
SCRIPTS_BY_LOCALE.put("bum", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bvb", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bya", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("byn", getScriptsMap("", "Ethi"));
SCRIPTS_BY_LOCALE.put("byv", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bze", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("bzx", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("ca", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("cad", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("car", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("cay", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("cch", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ccp", getScriptsMap("", "Beng"));
SCRIPTS_BY_LOCALE.put("ce", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("ceb", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("cgg", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ch", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("chk", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("chm", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("chn", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("cho", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("chp", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("chr", getScriptsMap("", "Cher"));
SCRIPTS_BY_LOCALE.put("chy", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("cja", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("cjm", getScriptsMap("", "Cham"));
SCRIPTS_BY_LOCALE.put("cjs", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("ckb", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("ckt", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("co", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("cop", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("cpe", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("cr", getScriptsMap("", "Cans"));
SCRIPTS_BY_LOCALE.put("crh", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("crj", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("crk", getScriptsMap("", "Cans"));
SCRIPTS_BY_LOCALE.put("crl", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("crm", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("crs", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("cs", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("csb", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("csw", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("cu", getScriptsMap("", "Glag"));
SCRIPTS_BY_LOCALE.put("cv", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("cy", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("da", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("daf", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("dak", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("dar", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("dav", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("dcc", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("de", getScriptsMap("", "Latn", "BR", "Runr", "KZ", "Runr", "US", "Runr"));
SCRIPTS_BY_LOCALE.put("del", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("den", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("dgr", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("din", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("dje", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("dng", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("doi", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("dsb", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("dtm", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("dua", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("dv", getScriptsMap("", "Thaa"));
SCRIPTS_BY_LOCALE.put("dyo", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("dyu", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("dz", getScriptsMap("", "Tibt"));
SCRIPTS_BY_LOCALE.put("ebu", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ee", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("efi", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("egy", getScriptsMap("", "Egyp"));
SCRIPTS_BY_LOCALE.put("eka", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("eky", getScriptsMap("", "Kali"));
SCRIPTS_BY_LOCALE.put("el", getScriptsMap("", "Grek"));
SCRIPTS_BY_LOCALE.put("en", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("eo", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("es", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("et", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ett", getScriptsMap("", "Ital"));
SCRIPTS_BY_LOCALE.put("eu", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("evn", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("ewo", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("fa", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("fan", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ff", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ffm", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("fi", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("fil", getScriptsMap("", "Latn", "US", "Tglg"));
SCRIPTS_BY_LOCALE.put("fiu", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("fj", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("fo", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("fon", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("fr", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("frr", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("frs", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("fud", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("fuq", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("fur", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("fuv", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("fy", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ga", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("gaa", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("gag", getScriptsMap("", "Latn", "MD", "Cyrl"));
SCRIPTS_BY_LOCALE.put("gay", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("gba", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("gbm", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("gcr", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("gd", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("gez", getScriptsMap("", "Ethi"));
SCRIPTS_BY_LOCALE.put("ggn", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("gil", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("gjk", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("gju", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("gl", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("gld", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("glk", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("gn", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("gon", getScriptsMap("", "Telu"));
SCRIPTS_BY_LOCALE.put("gor", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("gos", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("got", getScriptsMap("", "Goth"));
SCRIPTS_BY_LOCALE.put("grb", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("grc", getScriptsMap("", "Cprt"));
SCRIPTS_BY_LOCALE.put("grt", getScriptsMap("", "Beng"));
SCRIPTS_BY_LOCALE.put("gsw", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("gu", getScriptsMap("", "Gujr"));
SCRIPTS_BY_LOCALE.put("gub", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("guz", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("gv", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("gvr", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("gwi", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ha", getScriptsMap("", "Arab", "NE", "Latn", "GH", "Latn"));
SCRIPTS_BY_LOCALE.put("hai", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("haw", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("haz", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("he", getScriptsMap("", "Hebr"));
SCRIPTS_BY_LOCALE.put("hi", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("hil", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("hit", getScriptsMap("", "Xsux"));
SCRIPTS_BY_LOCALE.put("hmn", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("hnd", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("hne", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("hnn", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("hno", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("ho", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("hoc", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("hoj", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("hop", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("hr", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("hsb", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ht", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("hu", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("hup", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("hy", getScriptsMap("", "Armn"));
SCRIPTS_BY_LOCALE.put("hz", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ia", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("iba", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ibb", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("id", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ig", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ii", getScriptsMap("", "Yiii", "CN", "Latn"));
SCRIPTS_BY_LOCALE.put("ik", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ikt", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("ilo", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("inh", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("is", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("it", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("iu", getScriptsMap("", "Cans", "CA", "Latn"));
SCRIPTS_BY_LOCALE.put("ja", getScriptsMap("", "Jpan"));
SCRIPTS_BY_LOCALE.put("jmc", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("jml", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("jpr", getScriptsMap("", "Hebr"));
SCRIPTS_BY_LOCALE.put("jrb", getScriptsMap("", "Hebr"));
SCRIPTS_BY_LOCALE.put("jv", getScriptsMap("", "Latn", "ID", "Java"));
SCRIPTS_BY_LOCALE.put("ka", getScriptsMap("", "Geor"));
SCRIPTS_BY_LOCALE.put("kaa", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("kab", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kac", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kaj", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kam", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kao", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("kbd", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("kca", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("kcg", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kck", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("kde", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kdt", getScriptsMap("", "Thai"));
SCRIPTS_BY_LOCALE.put("kea", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kfo", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kfr", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("kfy", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("kg", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kge", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("kgp", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("kha", getScriptsMap("", "Latn", "IN", "Beng"));
SCRIPTS_BY_LOCALE.put("khb", getScriptsMap("", "Talu"));
SCRIPTS_BY_LOCALE.put("khn", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("khq", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kht", getScriptsMap("", "Mymr"));
SCRIPTS_BY_LOCALE.put("khw", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("ki", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kj", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kjg", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("kjh", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("kk", getScriptsMap("", "Arab", "KZ", "Cyrl", "TR", "Cyrl"));
SCRIPTS_BY_LOCALE.put("kkj", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("kl", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kln", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("km", getScriptsMap("", "Khmr"));
SCRIPTS_BY_LOCALE.put("kmb", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kn", getScriptsMap("", "Knda"));
SCRIPTS_BY_LOCALE.put("ko", getScriptsMap("", "Kore"));
SCRIPTS_BY_LOCALE.put("koi", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("kok", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("kos", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kpe", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kpy", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("kr", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("krc", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("kri", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("krl", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kru", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("ks", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("ksb", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ksf", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ksh", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ku", getScriptsMap("", "Latn", "LB", "Arab"));
SCRIPTS_BY_LOCALE.put("kum", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("kut", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kv", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("kvr", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("kvx", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("kw", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("kxm", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("kxp", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("ky", getScriptsMap("", "Cyrl", "CN", "Arab", "TR", "Latn"));
SCRIPTS_BY_LOCALE.put("kyu", getScriptsMap("", "Kali"));
SCRIPTS_BY_LOCALE.put("la", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("lad", getScriptsMap("", "Hebr"));
SCRIPTS_BY_LOCALE.put("lag", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("lah", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("laj", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("lam", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("lb", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("lbe", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("lbw", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("lcp", getScriptsMap("", "Thai"));
SCRIPTS_BY_LOCALE.put("lep", getScriptsMap("", "Lepc"));
SCRIPTS_BY_LOCALE.put("lez", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("lg", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("li", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("lif", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("lis", getScriptsMap("", "Lisu"));
SCRIPTS_BY_LOCALE.put("ljp", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("lki", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("lkt", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("lmn", getScriptsMap("", "Telu"));
SCRIPTS_BY_LOCALE.put("lmo", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("ln", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("lo", getScriptsMap("", "Laoo"));
SCRIPTS_BY_LOCALE.put("lol", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("loz", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("lrc", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("lt", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("lu", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("lua", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("lui", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("lun", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("luo", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("lus", getScriptsMap("", "Beng"));
SCRIPTS_BY_LOCALE.put("lut", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("luy", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("luz", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("lv", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("lwl", getScriptsMap("", "Thai"));
SCRIPTS_BY_LOCALE.put("mad", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("maf", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("mag", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("mai", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("mak", getScriptsMap("", "Latn", "ID", "Bugi"));
SCRIPTS_BY_LOCALE.put("man", getScriptsMap("", "Latn", "GN", "Nkoo"));
SCRIPTS_BY_LOCALE.put("mas", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("maz", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("mdf", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("mdh", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mdr", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mdt", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("men", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mer", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mfa", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("mfe", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mg", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mgh", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mgp", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("mgy", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("mh", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mi", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mic", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("min", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mk", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("ml", getScriptsMap("", "Mlym"));
SCRIPTS_BY_LOCALE.put("mn", getScriptsMap("", "Cyrl", "CN", "Mong"));
SCRIPTS_BY_LOCALE.put("mnc", getScriptsMap("", "Mong"));
SCRIPTS_BY_LOCALE.put("mni", getScriptsMap("", "Beng", "IN", "Mtei"));
SCRIPTS_BY_LOCALE.put("mns", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("mnw", getScriptsMap("", "Mymr"));
SCRIPTS_BY_LOCALE.put("moe", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("moh", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mos", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mr", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("mrd", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("mrj", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("ms", getScriptsMap("", "Arab", "MY", "Latn", "SG", "Latn"));
SCRIPTS_BY_LOCALE.put("mt", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mtr", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("mua", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mus", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mvy", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("mwk", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("mwl", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("mwr", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("mxc", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("my", getScriptsMap("", "Mymr"));
SCRIPTS_BY_LOCALE.put("myv", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("myx", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("myz", getScriptsMap("", "Mand"));
SCRIPTS_BY_LOCALE.put("na", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("nap", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("naq", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("nb", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("nbf", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("nch", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("nd", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ndc", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("nds", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ne", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("new", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("ng", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ngl", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("nhe", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("nhw", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("nia", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("nij", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("niu", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("nl", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("nmg", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("nn", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("nnh", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("nod", getScriptsMap("", "Lana"));
SCRIPTS_BY_LOCALE.put("noe", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("nog", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("nqo", getScriptsMap("", "Nkoo"));
SCRIPTS_BY_LOCALE.put("nr", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("nsk", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("nso", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("nus", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("nv", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ny", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("nym", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("nyn", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("nyo", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("nzi", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("oc", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("oj", getScriptsMap("", "Cans"));
SCRIPTS_BY_LOCALE.put("om", getScriptsMap("", "Latn", "ET", "Ethi"));
SCRIPTS_BY_LOCALE.put("or", getScriptsMap("", "Orya"));
SCRIPTS_BY_LOCALE.put("os", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("osa", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("osc", getScriptsMap("", "Ital"));
SCRIPTS_BY_LOCALE.put("otk", getScriptsMap("", "Orkh"));
SCRIPTS_BY_LOCALE.put("pa", getScriptsMap("", "Guru", "PK", "Arab"));
SCRIPTS_BY_LOCALE.put("pag", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("pal", getScriptsMap("", "Phli"));
SCRIPTS_BY_LOCALE.put("pam", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("pap", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("pau", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("peo", getScriptsMap("", "Xpeo"));
SCRIPTS_BY_LOCALE.put("phn", getScriptsMap("", "Phnx"));
SCRIPTS_BY_LOCALE.put("pi", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("pko", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("pl", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("pon", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("pra", getScriptsMap("", "Brah"));
SCRIPTS_BY_LOCALE.put("prd", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("prg", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("prs", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("ps", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("pt", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("puu", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("qu", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("raj", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("rap", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("rar", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("rcf", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("rej", getScriptsMap("", "Latn", "ID", "Rjng"));
SCRIPTS_BY_LOCALE.put("ria", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("rif", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("rjs", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("rkt", getScriptsMap("", "Beng"));
SCRIPTS_BY_LOCALE.put("rm", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("rmf", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("rmo", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("rmt", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("rn", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("rng", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("ro", getScriptsMap("", "Latn", "RS", "Cyrl"));
SCRIPTS_BY_LOCALE.put("rob", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("rof", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("rom", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("ru", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("rue", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("rup", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("rw", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("rwk", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ryu", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("sa", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("sad", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("saf", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sah", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("sam", getScriptsMap("", "Hebr"));
SCRIPTS_BY_LOCALE.put("saq", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sas", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sat", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("saz", getScriptsMap("", "Saur"));
SCRIPTS_BY_LOCALE.put("sbp", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sc", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sck", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("scn", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sco", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("scs", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("sd", getScriptsMap("", "Arab", "IN", "Deva"));
SCRIPTS_BY_LOCALE.put("sdh", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("se", getScriptsMap("", "Latn", "NO", "Cyrl"));
SCRIPTS_BY_LOCALE.put("see", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sef", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("seh", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sel", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("ses", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sg", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sga", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("shi", getScriptsMap("", "Tfng"));
SCRIPTS_BY_LOCALE.put("shn", getScriptsMap("", "Mymr"));
SCRIPTS_BY_LOCALE.put("si", getScriptsMap("", "Sinh"));
SCRIPTS_BY_LOCALE.put("sid", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sk", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("skr", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("sl", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sm", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sma", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("smi", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("smj", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("smn", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sms", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sn", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("snk", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("so", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("son", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sou", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("sq", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sr", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("srn", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("srr", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("srx", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("ss", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ssy", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("st", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("su", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("suk", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sus", getScriptsMap("", "Latn", "GN", "Arab"));
SCRIPTS_BY_LOCALE.put("sv", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("sw", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("swb", getScriptsMap("", "Arab", "YT", "Latn"));
SCRIPTS_BY_LOCALE.put("swc", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("swv", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("sxn", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("syi", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("syl", getScriptsMap("", "Beng", "BD", "Sylo"));
SCRIPTS_BY_LOCALE.put("syr", getScriptsMap("", "Syrc"));
SCRIPTS_BY_LOCALE.put("ta", getScriptsMap("", "Taml"));
SCRIPTS_BY_LOCALE.put("tab", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("taj", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("tbw", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tcy", getScriptsMap("", "Knda"));
SCRIPTS_BY_LOCALE.put("tdd", getScriptsMap("", "Tale"));
SCRIPTS_BY_LOCALE.put("tdg", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("tdh", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("te", getScriptsMap("", "Telu"));
SCRIPTS_BY_LOCALE.put("tem", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("teo", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ter", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tet", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tg", getScriptsMap("", "Cyrl", "PK", "Arab"));
SCRIPTS_BY_LOCALE.put("th", getScriptsMap("", "Thai"));
SCRIPTS_BY_LOCALE.put("thl", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("thq", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("thr", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("ti", getScriptsMap("", "Ethi"));
SCRIPTS_BY_LOCALE.put("tig", getScriptsMap("", "Ethi"));
SCRIPTS_BY_LOCALE.put("tiv", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tk", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tkl", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tkt", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("tli", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tmh", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tn", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("to", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tog", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tpi", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tr", getScriptsMap("", "Latn", "DE", "Arab", "MK", "Arab"));
SCRIPTS_BY_LOCALE.put("tru", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("trv", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ts", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tsf", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("tsg", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tsi", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tsj", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("tt", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("ttj", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("tts", getScriptsMap("", "Thai"));
SCRIPTS_BY_LOCALE.put("tum", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tut", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("tvl", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("twq", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ty", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("tyv", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("tzm", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ude", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("udm", getScriptsMap("", "Cyrl", "RU", "Latn"));
SCRIPTS_BY_LOCALE.put("ug", getScriptsMap("", "Arab", "KZ", "Cyrl", "MN", "Cyrl"));
SCRIPTS_BY_LOCALE.put("uga", getScriptsMap("", "Ugar"));
SCRIPTS_BY_LOCALE.put("uk", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("uli", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("umb", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("und", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("unr", getScriptsMap("", "Beng", "NP", "Deva"));
SCRIPTS_BY_LOCALE.put("unx", getScriptsMap("", "Beng"));
SCRIPTS_BY_LOCALE.put("ur", getScriptsMap("", "Arab"));
SCRIPTS_BY_LOCALE.put("uz", getScriptsMap("", "Latn", "AF", "Arab", "CN", "Cyrl"));
SCRIPTS_BY_LOCALE.put("vai", getScriptsMap("", "Vaii"));
SCRIPTS_BY_LOCALE.put("ve", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("vi", getScriptsMap("", "Latn", "US", "Hani"));
SCRIPTS_BY_LOCALE.put("vic", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("vmw", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("vo", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("vot", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("vun", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("wa", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("wae", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("wak", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("wal", getScriptsMap("", "Ethi"));
SCRIPTS_BY_LOCALE.put("war", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("was", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("wbq", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("wbr", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("wls", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("wo", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("wtm", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("xal", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("xav", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("xcr", getScriptsMap("", "Cari"));
SCRIPTS_BY_LOCALE.put("xh", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("xnr", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("xog", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("xpr", getScriptsMap("", "Prti"));
SCRIPTS_BY_LOCALE.put("xsa", getScriptsMap("", "Sarb"));
SCRIPTS_BY_LOCALE.put("xsr", getScriptsMap("", "Deva"));
SCRIPTS_BY_LOCALE.put("xum", getScriptsMap("", "Ital"));
SCRIPTS_BY_LOCALE.put("yao", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("yap", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("yav", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("ybb", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("yi", getScriptsMap("", "Hebr"));
SCRIPTS_BY_LOCALE.put("yo", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("yrk", getScriptsMap("", "Cyrl"));
SCRIPTS_BY_LOCALE.put("yua", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("yue", getScriptsMap("", "Hans"));
SCRIPTS_BY_LOCALE.put("za", getScriptsMap("", "Latn", "CN", "Hans"));
SCRIPTS_BY_LOCALE.put("zap", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("zdj", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("zea", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("zen", getScriptsMap("", "Tfng"));
SCRIPTS_BY_LOCALE.put("zh", getScriptsMap("", "Hant", "CN", "Hans", "HK", "Hans", "MO", "Hans", "SG", "Hans", "MN", "Hans"));
SCRIPTS_BY_LOCALE.put("zmi", getScriptsMap("", ""));
SCRIPTS_BY_LOCALE.put("zu", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("zun", getScriptsMap("", "Latn"));
SCRIPTS_BY_LOCALE.put("zza", getScriptsMap("", "Arab"));
}
/**
* Gets the script for the given locale. For example, if a US citizen uses German Locale,
* and calls this method with Locale.getDefault(), the result would be "Runr"
*
* @param locale
* @return
*/
public static String getScript(Locale locale)
{
String localeString = locale.toString();
String language = "";
String country = "";
if (localeString.contains("_")) {
String[] split = localeString.split("_");
language = split[0];
country = split[1];
} else
language = localeString;
Map<String, String> scripts = SCRIPTS_BY_LOCALE.get(language);
if (scripts == null) {
return null;
} else {
if (scripts.containsKey(country)) {
return scripts.get(country);
} else {
return scripts.get("");
}
}
}
}

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.fonts
import android.graphics.Typeface
import androidx.annotation.DrawableRes
import org.thoughtcrime.securesms.R
/**
* Describes which font the user wishes to render content in.
*/
enum class TextFont(@DrawableRes val icon: Int, val fallbackFamily: String, val fallbackStyle: Int, val isAllCaps: Boolean) {
REGULAR(R.drawable.ic_font_regular, "sans-serif", Typeface.NORMAL, false),
BOLD(R.drawable.ic_font_bold, "sans-serif", Typeface.BOLD, false),
SERIF(R.drawable.ic_font_serif, "serif", Typeface.NORMAL, false),
SCRIPT(R.drawable.ic_font_script, "serif", Typeface.BOLD, false),
CONDENSED(R.drawable.ic_font_condensed, "sans-serif", Typeface.BOLD, true);
}

View File

@@ -79,7 +79,7 @@ public class AddMembersActivity extends PushContactSelectionActivity {
}
@Override
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
if (getGroupId().isV1() && recipientId.isPresent() && !Recipient.resolved(recipientId.get()).hasE164()) {
Toast.makeText(this, R.string.AddMembersActivity__this_person_cant_be_added_to_legacy_groups, Toast.LENGTH_SHORT).show();
callback.accept(false);
@@ -96,7 +96,7 @@ public class AddMembersActivity extends PushContactSelectionActivity {
}
@Override
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
if (contactsFragment.hasQueryFilter()) {
getContactFilterView().clear();
}

View File

@@ -113,7 +113,7 @@ public final class AddToGroupsActivity extends ContactSelectionActivity {
}
@Override
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
if (contactsFragment.isMulti()) {
throw new UnsupportedOperationException("Not yet built to handle multi-select.");
// if (contactsFragment.hasQueryFilter()) {
@@ -133,7 +133,7 @@ public final class AddToGroupsActivity extends ContactSelectionActivity {
}
@Override
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
if (contactsFragment.hasQueryFilter()) {
getContactFilterView().clear();
}

View File

@@ -100,7 +100,7 @@ public class CreateGroupActivity extends ContactSelectionActivity {
}
@Override
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
if (contactsFragment.hasQueryFilter()) {
getContactFilterView().clear();
}
@@ -111,7 +111,7 @@ public class CreateGroupActivity extends ContactSelectionActivity {
}
@Override
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
if (contactsFragment.hasQueryFilter()) {
getContactFilterView().clear();
}

View File

@@ -117,7 +117,10 @@ public final class AttachmentDownloadJob extends BaseJob {
@Override
public void onRun() throws Exception {
doWork();
ApplicationDependencies.getMessageNotifier().updateNotification(context, 0);
if (!SignalDatabase.mms().isStory(messageId)) {
ApplicationDependencies.getMessageNotifier().updateNotification(context, 0);
}
}
public void doWork() throws IOException, RetryLaterException {

View File

@@ -0,0 +1,86 @@
package org.thoughtcrime.securesms.jobs
import android.graphics.Typeface
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.fonts.Fonts
import org.thoughtcrime.securesms.fonts.TextFont
import org.thoughtcrime.securesms.jobmanager.Data
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.util.FutureTaskListener
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/**
* Job that downloads all of the fonts for a user's locale.
*/
class FontDownloaderJob private constructor(parameters: Parameters) : BaseJob(parameters) {
companion object {
private val TAG = Log.tag(FontDownloaderJob::class.java)
const val KEY = "FontDownloaderJob"
}
constructor() : this(
Parameters.Builder()
.addConstraint(NetworkConstraint.KEY)
.setLifespan(TimeUnit.DAYS.toMillis(30))
.setMaxAttempts(Parameters.UNLIMITED)
.setMaxInstancesForFactory(1)
.build()
)
override fun serialize(): Data = Data.EMPTY
override fun getFactoryKey(): String = KEY
override fun onFailure() = Unit
override fun onRun() {
val locale = Locale.getDefault()
val asyncResults = TextFont.values()
.map { Fonts.resolveFont(context, locale, it) }
.filterIsInstance(Fonts.FontResult.Async::class.java)
if (asyncResults.isEmpty()) {
Log.i(TAG, "Already downloaded fonts for locale.")
return
}
val countDownLatch = CountDownLatch(asyncResults.size)
val failure = AtomicInteger(0)
val listener = object : FutureTaskListener<Typeface> {
override fun onSuccess(result: Typeface?) {
countDownLatch.countDown()
}
override fun onFailure(exception: ExecutionException?) {
failure.getAndIncrement()
countDownLatch.countDown()
}
}
asyncResults.forEach {
it.future.addListener(listener)
}
countDownLatch.await()
if (failure.get() > 0) {
throw Exception("Failed to download ${failure.get()} fonts. Scheduling a retry.")
}
}
override fun onShouldRetry(e: Exception): Boolean = true
class Factory : Job.Factory<FontDownloaderJob> {
override fun create(parameters: Parameters, data: Data): FontDownloaderJob {
return FontDownloaderJob(parameters)
}
}
}

View File

@@ -92,6 +92,7 @@ public final class JobManagerFactories {
put(DownloadLatestEmojiDataJob.KEY, new DownloadLatestEmojiDataJob.Factory());
put(EmojiSearchIndexDownloadJob.KEY, new EmojiSearchIndexDownloadJob.Factory());
put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory());
put(FontDownloaderJob.KEY, new FontDownloaderJob.Factory());
put(GroupV1MigrationJob.KEY, new GroupV1MigrationJob.Factory());
put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory());
put(GroupCallPeekJob.KEY, new GroupCallPeekJob.Factory());
@@ -132,12 +133,13 @@ public final class JobManagerFactories {
put(ProfileUploadJob.KEY, new ProfileUploadJob.Factory());
put(PushDecryptMessageJob.KEY, new PushDecryptMessageJob.Factory());
put(PushDecryptDrainedJob.KEY, new PushDecryptDrainedJob.Factory());
put(PushProcessMessageJob.KEY, new PushProcessMessageJob.Factory());
put(PushDistributionListSendJob.KEY, new PushDistributionListSendJob.Factory());
put(PushGroupSendJob.KEY, new PushGroupSendJob.Factory());
put(PushGroupSilentUpdateSendJob.KEY, new PushGroupSilentUpdateSendJob.Factory());
put(PushGroupUpdateJob.KEY, new PushGroupUpdateJob.Factory());
put(PushMediaSendJob.KEY, new PushMediaSendJob.Factory());
put(PushNotificationReceiveJob.KEY, new PushNotificationReceiveJob.Factory());
put(PushProcessMessageJob.KEY, new PushProcessMessageJob.Factory());
put(PushTextSendJob.KEY, new PushTextSendJob.Factory());
put(ReactionSendJob.KEY, new ReactionSendJob.Factory());
put(RecipientChangedNumberJob.KEY, new RecipientChangedNumberJob.Factory());

View File

@@ -0,0 +1,209 @@
package org.thoughtcrime.securesms.jobs;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase.GroupReceiptInfo;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.database.model.DistributionListId;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobLogger;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.messages.GroupSendUtil;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* A job that lets us send a message to a distribution list. Currently the only supported message type is a story.
*/
public final class PushDistributionListSendJob extends PushSendJob {
public static final String KEY = "PushDistributionListSendJob";
private static final String TAG = Log.tag(PushDistributionListSendJob.class);
private static final String KEY_MESSAGE_ID = "message_id";
private final long messageId;
public PushDistributionListSendJob(long messageId, @NonNull RecipientId destination, boolean hasMedia) {
this(new Parameters.Builder()
.setQueue(destination.toQueueKey(hasMedia))
.addConstraint(NetworkConstraint.KEY)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
messageId);
}
private PushDistributionListSendJob(@NonNull Parameters parameters, long messageId) {
super(parameters);
this.messageId = messageId;
}
@WorkerThread
public static void enqueue(@NonNull Context context,
@NonNull JobManager jobManager,
long messageId,
@NonNull RecipientId destination)
{
try {
Recipient listRecipient = Recipient.resolved(destination);
if (!listRecipient.isDistributionList()) {
throw new AssertionError("Not a distribution list! MessageId: " + messageId);
}
OutgoingMediaMessage message = SignalDatabase.mms().getOutgoingMessage(messageId);
if (!message.isStory()) {
throw new AssertionError("Only story messages are currently supported! MessageId: " + messageId);
}
Set<String> attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
jobManager.add(new PushDistributionListSendJob(messageId, destination, !attachmentUploadIds.isEmpty()), attachmentUploadIds, attachmentUploadIds.isEmpty() ? null : destination.toQueueKey());
} catch (NoSuchMessageException | MmsException e) {
Log.w(TAG, "Failed to enqueue message.", e);
SignalDatabase.mms().markAsSentFailed(messageId);
notifyMediaMessageDeliveryFailed(context, messageId);
}
}
@Override
public @NonNull Data serialize() {
return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId).build();
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public void onAdded() {
SignalDatabase.mms().markAsSending(messageId);
}
@Override
public void onPushSend()
throws IOException, MmsException, NoSuchMessageException, RetryLaterException
{
MessageDatabase database = SignalDatabase.mms();
OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
Set<NetworkFailure> existingNetworkFailures = message.getNetworkFailures();
Set<IdentityKeyMismatch> existingIdentityMismatches = message.getIdentityKeyMismatches();
if (!message.isStory()) {
throw new MmsException("Only story sends are currently supported!");
}
if (database.isSent(messageId)) {
log(TAG, String.valueOf(message.getSentTimeMillis()), "Message " + messageId + " was already sent. Ignoring.");
return;
}
Recipient listRecipient = message.getRecipient().resolve();
if (!listRecipient.isDistributionList()) {
throw new MmsException("Message recipient isn't a distribution list!");
}
try {
log(TAG, String.valueOf(message.getSentTimeMillis()), "Sending message: " + messageId + ", Recipient: " + message.getRecipient().getId() + ", Attachments: " + buildAttachmentString(message.getAttachments()));
List<Recipient> target;
if (!existingNetworkFailures.isEmpty()) target = Stream.of(existingNetworkFailures).map(nf -> nf.getRecipientId(context)).distinct().map(Recipient::resolved).toList();
else target = Stream.of(getFullRecipients(listRecipient.requireDistributionListId(), messageId)).distinctBy(Recipient::getId).toList();
List<SendMessageResult> results = deliver(message, target);
Log.i(TAG, JobLogger.format(this, "Finished send."));
PushGroupSendJob.processGroupMessageResults(context, messageId, -1, null, message, results, target, existingNetworkFailures, existingIdentityMismatches);
} catch (UntrustedIdentityException | UndeliverableMessageException e) {
warn(TAG, String.valueOf(message.getSentTimeMillis()), e);
database.markAsSentFailed(messageId);
notifyMediaMessageDeliveryFailed(context, messageId);
}
}
@Override
public void onFailure() {
SignalDatabase.mms().markAsSentFailed(messageId);
}
private List<SendMessageResult> deliver(@NonNull OutgoingMediaMessage message, @NonNull List<Recipient> destinations)
throws IOException, UntrustedIdentityException, UndeliverableMessageException
{
// TODO [stories] Filter based off of stories capability
try {
rotateSenderCertificateIfNecessary();
List<Attachment> attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList();
List<SignalServiceAttachment> attachmentPointers = getAttachmentPointersFor(attachments);
boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId))
.anyMatch(info -> info.getStatus() > GroupReceiptDatabase.STATUS_UNDELIVERED);
SignalServiceStoryMessage storyMessage = SignalServiceStoryMessage.forFileAttachment(Recipient.self().getProfileKey(), null, attachmentPointers.get(0));
return GroupSendUtil.sendStoryMessage(context, message.getRecipient().requireDistributionListId(), destinations, isRecipientUpdate, new MessageId(messageId, true), message.getSentTimeMillis(), storyMessage);
} catch (ServerRejectedException e) {
throw new UndeliverableMessageException(e);
}
}
private static List<Recipient> getFullRecipients(@NonNull DistributionListId distributionListId, long messageId) {
List<GroupReceiptInfo> destinations = SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId);
if (!destinations.isEmpty()) {
return RecipientUtil.getEligibleForSending(destinations.stream()
.map(GroupReceiptInfo::getRecipientId)
.map(Recipient::resolved)
.collect(Collectors.toList()));
} else {
return RecipientUtil.getEligibleForSending(SignalDatabase.distributionLists()
.getMembers(distributionListId)
.stream()
.map(Recipient::resolved)
.collect(Collectors.toList()));
}
}
public static class Factory implements Job.Factory<PushDistributionListSendJob> {
@Override
public @NonNull PushDistributionListSendJob create(@NonNull Parameters parameters, @NonNull Data data) {
return new PushDistributionListSendJob(parameters, data.getLong(KEY_MESSAGE_ID));
}
}
}

View File

@@ -16,12 +16,14 @@ import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase.GroupReceiptInfo;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.jobmanager.Data;
@@ -49,9 +51,10 @@ import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Preview;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Quote;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
@@ -187,73 +190,10 @@ public final class PushGroupSendJob extends PushSendJob {
else if (!existingNetworkFailures.isEmpty()) target = Stream.of(existingNetworkFailures).map(nf -> nf.getRecipientId(context)).distinct().map(Recipient::resolved).toList();
else target = Stream.of(getGroupMessageRecipients(groupRecipient.requireGroupId(), messageId)).distinctBy(Recipient::getId).toList();
RecipientAccessList accessList = new RecipientAccessList(target);
List<SendMessageResult> results = deliver(message, groupRecipient, target);
processGroupMessageResults(context, messageId, threadId, groupRecipient, message, results, target, existingNetworkFailures, existingIdentityMismatches);
Log.i(TAG, JobLogger.format(this, "Finished send."));
List<NetworkFailure> networkFailures = Stream.of(results).filter(SendMessageResult::isNetworkFailure).map(result -> new NetworkFailure(accessList.requireIdByAddress(result.getAddress()))).toList();
List<IdentityKeyMismatch> identityMismatches = Stream.of(results).filter(result -> result.getIdentityFailure() != null).map(result -> new IdentityKeyMismatch(accessList.requireIdByAddress(result.getAddress()), result.getIdentityFailure().getIdentityKey())).toList();
ProofRequiredException proofRequired = Stream.of(results).filter(r -> r.getProofRequiredFailure() != null).findLast().map(SendMessageResult::getProofRequiredFailure).orElse(null);
List<SendMessageResult> successes = Stream.of(results).filter(result -> result.getSuccess() != null).toList();
List<Pair<RecipientId, Boolean>> successUnidentifiedStatus = Stream.of(successes).map(result -> new Pair<>(accessList.requireIdByAddress(result.getAddress()), result.getSuccess().isUnidentified())).toList();
Set<RecipientId> successIds = Stream.of(successUnidentifiedStatus).map(Pair::first).collect(Collectors.toSet());
List<NetworkFailure> resolvedNetworkFailures = Stream.of(existingNetworkFailures).filter(failure -> successIds.contains(failure.getRecipientId(context))).toList();
List<IdentityKeyMismatch> resolvedIdentityFailures = Stream.of(existingIdentityMismatches).filter(failure -> successIds.contains(failure.getRecipientId(context))).toList();
List<RecipientId> unregisteredRecipients = Stream.of(results).filter(SendMessageResult::isUnregisteredFailure).map(result -> RecipientId.from(result.getAddress())).toList();
if (networkFailures.size() > 0 || identityMismatches.size() > 0 || proofRequired != null || unregisteredRecipients.size() > 0) {
Log.w(TAG, String.format(Locale.US, "Failed to send to some recipients. Network: %d, Identity: %d, ProofRequired: %s, Unregistered: %d",
networkFailures.size(), identityMismatches.size(), proofRequired != null, unregisteredRecipients.size()));
}
RecipientDatabase recipientDatabase = SignalDatabase.recipients();
for (RecipientId unregistered : unregisteredRecipients) {
recipientDatabase.markUnregistered(unregistered);
}
existingNetworkFailures.removeAll(resolvedNetworkFailures);
existingNetworkFailures.addAll(networkFailures);
database.setNetworkFailures(messageId, existingNetworkFailures);
existingIdentityMismatches.removeAll(resolvedIdentityFailures);
existingIdentityMismatches.addAll(identityMismatches);
database.setMismatchedIdentities(messageId, existingIdentityMismatches);
SignalDatabase.groupReceipts().setUnidentified(successUnidentifiedStatus, messageId);
if (proofRequired != null) {
handleProofRequiredException(proofRequired, groupRecipient, threadId, messageId, true);
}
if (existingNetworkFailures.isEmpty() && networkFailures.isEmpty() && identityMismatches.isEmpty() && existingIdentityMismatches.isEmpty()) {
database.markAsSent(messageId, true);
markAttachmentsUploaded(messageId, message);
if (message.getExpiresIn() > 0 && !message.isExpirationUpdate()) {
database.markExpireStarted(messageId);
ApplicationDependencies.getExpiringMessageManager()
.scheduleDeletion(messageId, true, message.getExpiresIn());
}
if (message.isViewOnce()) {
SignalDatabase.attachments().deleteAttachmentFilesForViewOnceMessage(messageId);
}
} else if (!identityMismatches.isEmpty()) {
Log.w(TAG, "Failing because there were " + identityMismatches.size() + " identity mismatches.");
database.markAsSentFailed(messageId);
notifyMediaMessageDeliveryFailed(context, messageId);
Set<RecipientId> mismatchRecipientIds = Stream.of(identityMismatches)
.map(mismatch -> mismatch.getRecipientId(context))
.collect(Collectors.toSet());
RetrieveProfileJob.enqueue(mismatchRecipientIds);
} else if (!networkFailures.isEmpty()) {
Log.w(TAG, "Retrying because there were " + networkFailures.size() + " network failures.");
throw new RetryLaterException();
}
} catch (UntrustedIdentityException | UndeliverableMessageException e) {
warn(TAG, String.valueOf(message.getSentTimeMillis()), e);
database.markAsSentFailed(messageId);
@@ -285,14 +225,30 @@ public final class PushGroupSendJob extends PushSendJob {
Optional<Quote> quote = getQuoteFor(message);
Optional<SignalServiceDataMessage.Sticker> sticker = getStickerFor(message);
List<SharedContact> sharedContacts = getSharedContactsFor(message);
List<Preview> previews = getPreviewsFor(message);
List<SignalServicePreview> previews = getPreviewsFor(message);
List<SignalServiceDataMessage.Mention> mentions = getMentionsFor(message.getMentions());
List<Attachment> attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList();
List<SignalServiceAttachment> attachmentPointers = getAttachmentPointersFor(attachments);
boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId))
.anyMatch(info -> info.getStatus() > GroupReceiptDatabase.STATUS_UNDELIVERED);
if (message.isGroup()) {
if (message.isStory()) {
// TODO [stories] Filter based off of stories capability
Optional<GroupDatabase.GroupRecord> groupRecord = SignalDatabase.groups().getGroup(groupId);
if (groupRecord.isPresent()) {
GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.get().requireV2GroupProperties();
SignalServiceGroupV2 groupContext = SignalServiceGroupV2.newBuilder(v2GroupProperties.getGroupMasterKey())
.withRevision(v2GroupProperties.getGroupRevision())
.build();
SignalServiceStoryMessage storyMessage = SignalServiceStoryMessage.forFileAttachment(Recipient.self().getProfileKey(), groupContext, attachmentPointers.get(0));
return GroupSendUtil.sendGroupStoryMessage(context, groupId.requireV2(), destinations, isRecipientUpdate, new MessageId(messageId, true), message.getSentTimeMillis(), storyMessage);
} else {
throw new UndeliverableMessageException("No group found! " + groupId);
}
} else if (message.isGroup()) {
OutgoingGroupUpdateMessage groupMessage = (OutgoingGroupUpdateMessage) message;
if (groupMessage.isV2Group()) {
@@ -328,18 +284,30 @@ public final class PushGroupSendJob extends PushSendJob {
GroupUtil.setDataMessageGroupContext(context, builder, groupId);
SignalServiceDataMessage groupMessage = builder.withAttachments(attachmentPointers)
.withBody(message.getBody())
.withExpiration((int)(message.getExpiresIn() / 1000))
.withViewOnce(message.isViewOnce())
.asExpirationUpdate(message.isExpirationUpdate())
.withProfileKey(profileKey.orNull())
.withQuote(quote.orNull())
.withSticker(sticker.orNull())
.withSharedContacts(sharedContacts)
.withPreviews(previews)
.withMentions(mentions)
.build();
SignalServiceDataMessage.Builder groupMessageBuilder = builder.withAttachments(attachmentPointers)
.withBody(message.getBody())
.withExpiration((int)(message.getExpiresIn() / 1000))
.withViewOnce(message.isViewOnce())
.asExpirationUpdate(message.isExpirationUpdate())
.withProfileKey(profileKey.orNull())
.withQuote(quote.orNull())
.withSticker(sticker.orNull())
.withSharedContacts(sharedContacts)
.withPreviews(previews)
.withMentions(mentions);
if (message.getParentStoryId() != null) {
try {
MessageRecord storyRecord = SignalDatabase.mms().getMessageRecord(message.getParentStoryId().getId());
Recipient recipient = storyRecord.isOutgoing() ? Recipient.self() : storyRecord.getIndividualRecipient();
groupMessageBuilder.withStoryContext(new SignalServiceDataMessage.StoryContext(recipient.requireServiceId(), storyRecord.getDateSent()));
} catch (NoSuchMessageException e) {
// The story has probably expired
// TODO [stories] check what should happen in this case
throw new UndeliverableMessageException(e);
}
}
Log.i(TAG, JobLogger.format(this, "Beginning message send."));
@@ -349,21 +317,107 @@ public final class PushGroupSendJob extends PushSendJob {
isRecipientUpdate,
ContentHint.RESENDABLE,
new MessageId(messageId, true),
groupMessage);
groupMessageBuilder.build());
}
} catch (ServerRejectedException e) {
throw new UndeliverableMessageException(e);
}
}
private @NonNull List<Recipient> getGroupMessageRecipients(@NonNull GroupId groupId, long messageId) {
public static long getMessageId(@NonNull Data data) {
return data.getLong(KEY_MESSAGE_ID);
}
static void processGroupMessageResults(@NonNull Context context,
long messageId,
long threadId,
@Nullable Recipient groupRecipient,
@NonNull OutgoingMediaMessage message,
@NonNull List<SendMessageResult> results,
@NonNull List<Recipient> target,
@NonNull Set<NetworkFailure> existingNetworkFailures,
@NonNull Set<IdentityKeyMismatch> existingIdentityMismatches)
throws RetryLaterException, ProofRequiredException
{
MmsDatabase database = SignalDatabase.mms();
RecipientAccessList accessList = new RecipientAccessList(target);
List<NetworkFailure> networkFailures = Stream.of(results).filter(SendMessageResult::isNetworkFailure).map(result -> new NetworkFailure(accessList.requireIdByAddress(result.getAddress()))).toList();
List<IdentityKeyMismatch> identityMismatches = Stream.of(results).filter(result -> result.getIdentityFailure() != null).map(result -> new IdentityKeyMismatch(accessList.requireIdByAddress(result.getAddress()), result.getIdentityFailure().getIdentityKey())).toList();
ProofRequiredException proofRequired = Stream.of(results).filter(r -> r.getProofRequiredFailure() != null).findLast().map(SendMessageResult::getProofRequiredFailure).orElse(null);
List<SendMessageResult> successes = Stream.of(results).filter(result -> result.getSuccess() != null).toList();
List<Pair<RecipientId, Boolean>> successUnidentifiedStatus = Stream.of(successes).map(result -> new Pair<>(accessList.requireIdByAddress(result.getAddress()), result.getSuccess().isUnidentified())).toList();
Set<RecipientId> successIds = Stream.of(successUnidentifiedStatus).map(Pair::first).collect(Collectors.toSet());
List<NetworkFailure> resolvedNetworkFailures = Stream.of(existingNetworkFailures).filter(failure -> successIds.contains(failure.getRecipientId(context))).toList();
List<IdentityKeyMismatch> resolvedIdentityFailures = Stream.of(existingIdentityMismatches).filter(failure -> successIds.contains(failure.getRecipientId(context))).toList();
List<RecipientId> unregisteredRecipients = Stream.of(results).filter(SendMessageResult::isUnregisteredFailure).map(result -> RecipientId.from(result.getAddress())).toList();
if (networkFailures.size() > 0 || identityMismatches.size() > 0 || proofRequired != null || unregisteredRecipients.size() > 0) {
Log.w(TAG, String.format(Locale.US, "Failed to send to some recipients. Network: %d, Identity: %d, ProofRequired: %s, Unregistered: %d",
networkFailures.size(), identityMismatches.size(), proofRequired != null, unregisteredRecipients.size()));
}
RecipientDatabase recipientDatabase = SignalDatabase.recipients();
for (RecipientId unregistered : unregisteredRecipients) {
recipientDatabase.markUnregistered(unregistered);
}
existingNetworkFailures.removeAll(resolvedNetworkFailures);
existingNetworkFailures.addAll(networkFailures);
database.setNetworkFailures(messageId, existingNetworkFailures);
existingIdentityMismatches.removeAll(resolvedIdentityFailures);
existingIdentityMismatches.addAll(identityMismatches);
database.setMismatchedIdentities(messageId, existingIdentityMismatches);
SignalDatabase.groupReceipts().setUnidentified(successUnidentifiedStatus, messageId);
if (proofRequired != null) {
handleProofRequiredException(context, proofRequired, groupRecipient, threadId, messageId, true);
}
if (existingNetworkFailures.isEmpty() && networkFailures.isEmpty() && identityMismatches.isEmpty() && existingIdentityMismatches.isEmpty()) {
database.markAsSent(messageId, true);
markAttachmentsUploaded(messageId, message);
if (message.getExpiresIn() > 0 && !message.isExpirationUpdate()) {
database.markExpireStarted(messageId);
ApplicationDependencies.getExpiringMessageManager()
.scheduleDeletion(messageId, true, message.getExpiresIn());
}
if (message.isViewOnce()) {
SignalDatabase.attachments().deleteAttachmentFilesForViewOnceMessage(messageId);
}
if (message.isStory()) {
ApplicationDependencies.getExpireStoriesManager().scheduleIfNecessary();
}
} else if (!identityMismatches.isEmpty()) {
Log.w(TAG, "Failing because there were " + identityMismatches.size() + " identity mismatches.");
database.markAsSentFailed(messageId);
notifyMediaMessageDeliveryFailed(context, messageId);
Set<RecipientId> mismatchRecipientIds = Stream.of(identityMismatches)
.map(mismatch -> mismatch.getRecipientId(context))
.collect(Collectors.toSet());
RetrieveProfileJob.enqueue(mismatchRecipientIds);
} else if (!networkFailures.isEmpty()) {
Log.w(TAG, "Retrying because there were " + networkFailures.size() + " network failures.");
throw new RetryLaterException();
}
}
private static @NonNull List<Recipient> getGroupMessageRecipients(@NonNull GroupId groupId, long messageId) {
List<GroupReceiptInfo> destinations = SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId);
if (!destinations.isEmpty()) {
return RecipientUtil.getEligibleForSending(Stream.of(destinations)
.map(GroupReceiptInfo::getRecipientId)
.map(Recipient::resolved)
.toList());
return RecipientUtil.getEligibleForSending(Stream.of(destinations)
.map(GroupReceiptInfo::getRecipientId)
.map(Recipient::resolved)
.toList());
}
List<Recipient> members = Stream.of(SignalDatabase.groups().getGroupMembers(groupId, GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF))
@@ -377,10 +431,6 @@ public final class PushGroupSendJob extends PushSendJob {
return RecipientUtil.getEligibleForSending(members);
}
public static long getMessageId(@NonNull Data data) {
return data.getLong(KEY_MESSAGE_ID);
}
public static class Factory implements Job.Factory<PushGroupSendJob> {
@Override
public @NonNull PushGroupSendJob create(@NonNull Parameters parameters, @NonNull org.thoughtcrime.securesms.jobmanager.Data data) {

View File

@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
@@ -40,7 +41,7 @@ import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Preview;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
@@ -176,7 +177,7 @@ public class PushMediaSendJob extends PushSendJob {
database.markAsSentFailed(messageId);
RetrieveProfileJob.enqueue(recipientId);
} catch (ProofRequiredException e) {
handleProofRequiredException(e, SignalDatabase.threads().getRecipientForThreadId(threadId), threadId, messageId, true);
handleProofRequiredException(context, e, SignalDatabase.threads().getRecipientForThreadId(threadId), threadId, messageId, true);
}
}
@@ -202,28 +203,40 @@ public class PushMediaSendJob extends PushSendJob {
throw new UndeliverableMessageException(messageRecipient.getId() + " not registered!");
}
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
SignalServiceAddress address = RecipientUtil.toSignalServiceAddress(context, messageRecipient);
List<Attachment> attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList();
List<SignalServiceAttachment> serviceAttachments = getAttachmentPointersFor(attachments);
Optional<byte[]> profileKey = getProfileKey(messageRecipient);
Optional<SignalServiceDataMessage.Quote> quote = getQuoteFor(message);
Optional<SignalServiceDataMessage.Sticker> sticker = getStickerFor(message);
List<SharedContact> sharedContacts = getSharedContactsFor(message);
List<Preview> previews = getPreviewsFor(message);
SignalServiceDataMessage mediaMessage = SignalServiceDataMessage.newBuilder()
.withBody(message.getBody())
.withAttachments(serviceAttachments)
.withTimestamp(message.getSentTimeMillis())
.withExpiration((int)(message.getExpiresIn() / 1000))
.withViewOnce(message.isViewOnce())
.withProfileKey(profileKey.orNull())
.withQuote(quote.orNull())
.withSticker(sticker.orNull())
.withSharedContacts(sharedContacts)
.withPreviews(previews)
.asExpirationUpdate(message.isExpirationUpdate())
.build();
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
SignalServiceAddress address = RecipientUtil.toSignalServiceAddress(context, messageRecipient);
List<Attachment> attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList();
List<SignalServiceAttachment> serviceAttachments = getAttachmentPointersFor(attachments);
Optional<byte[]> profileKey = getProfileKey(messageRecipient);
Optional<SignalServiceDataMessage.Quote> quote = getQuoteFor(message);
Optional<SignalServiceDataMessage.Sticker> sticker = getStickerFor(message);
List<SharedContact> sharedContacts = getSharedContactsFor(message);
List<SignalServicePreview> previews = getPreviewsFor(message);
SignalServiceDataMessage.Builder mediaMessageBuilder = SignalServiceDataMessage.newBuilder()
.withBody(message.getBody())
.withAttachments(serviceAttachments)
.withTimestamp(message.getSentTimeMillis())
.withExpiration((int)(message.getExpiresIn() / 1000))
.withViewOnce(message.isViewOnce())
.withProfileKey(profileKey.orNull())
.withQuote(quote.orNull())
.withSticker(sticker.orNull())
.withSharedContacts(sharedContacts)
.withPreviews(previews)
.asExpirationUpdate(message.isExpirationUpdate());
if (message.getParentStoryId() != null) {
try {
MessageRecord storyRecord = SignalDatabase.mms().getMessageRecord(message.getParentStoryId().getId());
mediaMessageBuilder.withStoryContext(new SignalServiceDataMessage.StoryContext(address.getServiceId(), storyRecord.getDateSent()));
} catch (NoSuchMessageException e) {
// The story has probably expired
// TODO [stories] check what should happen in this case
throw new UndeliverableMessageException(e);
}
}
SignalServiceDataMessage mediaMessage = mediaMessageBuilder.build();
if (Util.equals(SignalStore.account().getAci(), address.getServiceId())) {
Optional<UnidentifiedAccessPair> syncAccess = UnidentifiedAccessUtil.getAccessForSync(context);

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