mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-24 02:39:55 +01:00
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,4 +20,5 @@ data class InternalSettingsState(
|
||||
val removeSenderKeyMinimium: Boolean,
|
||||
val delayResends: Boolean,
|
||||
val disableStorageService: Boolean,
|
||||
val disableStories: Boolean
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -195,6 +195,8 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
|
||||
colorize("SenderKey", recipient.senderKeyCapability),
|
||||
", ",
|
||||
colorize("ChangeNumber", recipient.changeNumberCapability),
|
||||
", ",
|
||||
colorize("Stories", recipient.storiesCapability),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
data class ContactSearchSelectionResult(val key: ContactSearchKey, val isSelectable: Boolean)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) } },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public interface DatabaseId {
|
||||
@NonNull String serialize();
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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("|")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
207
app/src/main/java/org/thoughtcrime/securesms/fonts/Fonts.kt
Normal file
207
app/src/main/java/org/thoughtcrime/securesms/fonts/Fonts.kt
Normal 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
|
||||
)
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user