diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/DistributionListDatabaseTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/DistributionListDatabaseTest.kt new file mode 100644 index 0000000000..5407da2b66 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/DistributionListDatabaseTest.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.database + +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.database.model.DistributionListRecord +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.push.ACI +import java.util.UUID + +class DistributionListDatabaseTest { + + private lateinit var distributionDatabase: DistributionListDatabase + + @Before + fun setup() { + distributionDatabase = SignalDatabase.distributionLists + } + + @Test + fun createList_whenNoConflict_insertSuccessfully() { + val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3)) + Assert.assertNotNull(id) + } + + @Test + fun createList_whenNameConflict_failToInsert() { + val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3)) + Assert.assertNotNull(id) + + val id2: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3)) + Assert.assertNull(id2) + } + + @Test + fun getList_returnCorrectList() { + createRecipients(3) + val members: List = recipientList(1, 2, 3) + + val id: DistributionListId? = distributionDatabase.createList("test", members) + Assert.assertNotNull(id) + + val record: DistributionListRecord? = distributionDatabase.getList(id!!) + Assert.assertNotNull(record) + Assert.assertEquals(id, record!!.id) + Assert.assertEquals("test", record.name) + Assert.assertEquals(members, record.members) + } + + @Test + fun getMembers_returnsCorrectMembers() { + createRecipients(3) + val members: List = recipientList(1, 2, 3) + + val id: DistributionListId? = distributionDatabase.createList("test", members) + Assert.assertNotNull(id) + + val foundMembers: List = distributionDatabase.getMembers(id!!) + Assert.assertEquals(members, foundMembers) + } + + fun createRecipients(count: Int) { + for (i in 0 until count) { + SignalDatabase.recipients.getOrInsertFromAci(ACI.from(UUID.randomUUID())) + } + } + + private fun recipientList(vararg ids: Long): List { + return ids.map { RecipientId.from(it) } + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_merges.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_merges.kt index c9ec6bea02..082ef17a28 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_merges.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_merges.kt @@ -17,6 +17,8 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroup import org.signal.storageservice.protos.groups.local.DecryptedMember import org.signal.zkgroup.groups.GroupMasterKey import org.thoughtcrime.securesms.conversation.colors.AvatarColor +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.database.model.DistributionListRecord import org.thoughtcrime.securesms.database.model.Mention import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord @@ -52,6 +54,7 @@ class RecipientDatabaseTest_merges { private lateinit var mentionDatabase: MentionDatabase private lateinit var reactionDatabase: ReactionDatabase private lateinit var notificationProfileDatabase: NotificationProfileDatabase + private lateinit var distributionListDatabase: DistributionListDatabase private val localAci = ACI.from(UUID.randomUUID()) private val localPni = PNI.from(UUID.randomUUID()) @@ -69,6 +72,7 @@ class RecipientDatabaseTest_merges { mentionDatabase = SignalDatabase.mentions reactionDatabase = SignalDatabase.reactions notificationProfileDatabase = SignalDatabase.notificationProfiles + distributionListDatabase = SignalDatabase.distributionLists SignalStore.account().setAci(localAci) SignalStore.account().setPni(localPni) @@ -120,6 +124,8 @@ class RecipientDatabaseTest_merges { notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdE164) notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdAciB) + val distributionListId: DistributionListId = distributionListDatabase.createList("testlist", listOf(recipientIdE164, recipientIdAciB))!! + // Merge val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true) val retrievedThreadId: Long = threadDatabase.getThreadIdFor(retrievedId)!! @@ -201,6 +207,11 @@ class RecipientDatabaseTest_merges { assertThat("Notification Profile 1 should now only contain ACI $recipientIdAci", updatedProfile1.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci)) assertThat("Notification Profile 2 should now contain ACI A ($recipientIdAci) and ACI B ($recipientIdAciB)", updatedProfile2.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB)) + + // Distribution List validation + val updatedList: DistributionListRecord = distributionListDatabase.getList(distributionListId)!! + + assertThat("Distribution list should have updated $recipientIdE164 to $recipientIdAci", updatedList.members, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB)) } private val context: Application diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a04dd3c83c..19720185e8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -388,6 +388,24 @@ + + + + + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.java b/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.java index cf78973beb..6c9ba9ab7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.java +++ b/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.java @@ -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()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 0f35c4c9a9..d21fc6103d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -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"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java index d12adfd8b6..32f9ecefbf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java @@ -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, String number, Consumer callback) { + public void onBeforeContactSelected(@NonNull Optional recipientId, String number, @NonNull Consumer callback) { callback.accept(true); } @Override - public void onContactDeselected(Optional recipientId, String number) {} + public void onContactDeselected(@NonNull Optional recipientId, String number) {} @Override public void onBeginScroll() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index 5ceacad3d7..0b7d0f9b4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -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 + implements LoaderManager.LoaderCallbacks { @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 currentSelection; - private boolean isMulti; - private boolean hideCount; - private boolean canSelectSelf; + private GlideRequests glideRequests; + private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS; + private Set 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 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 contacts) { + if (contacts.isEmpty()) { + return; + } + + Set 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, @Nullable String number, Consumer callback); - void onContactDeselected(Optional 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, @Nullable String number, @NonNull Consumer callback); + + void onContactDeselected(@NonNull Optional 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(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java b/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java index eea6cae2c9..a59b635a48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java @@ -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, String number, Consumer callback) { + public void onBeforeContactSelected(@NonNull Optional recipientId, String number, @NonNull Consumer callback) { updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1); callback.accept(true); } @Override - public void onContactDeselected(Optional recipientId, String number) { + public void onContactDeselected(@NonNull Optional recipientId, String number) { updateSmsButtonText(contactsFragment.getSelectedContacts().size()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java index b51a84237e..c2445fb7d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java @@ -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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java b/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java index 58dd8b6e70..a6082a23d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java @@ -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)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java index 9ac1bdfbbf..4c0aa3e4a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java @@ -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, String number, Consumer callback) { + public void onBeforeContactSelected(@NonNull Optional recipientId, String number, @NonNull Consumer callback) { if (recipientId.isPresent()) { launch(Recipient.resolved(recipientId.get())); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/view/AvatarView.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/view/AvatarView.kt new file mode 100644 index 0000000000..feab95e3e4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/view/AvatarView.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java index ec4259a654..06f929e2a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java @@ -87,7 +87,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements } @Override - public void onBeforeContactSelected(Optional recipientId, String number, Consumer callback) { + public void onBeforeContactSelected(@NonNull Optional recipientId, String number, @NonNull Consumer 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, String number) { + public void onContactDeselected(@NonNull Optional recipientId, String number) { } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FixedRoundedCornerBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/FixedRoundedCornerBottomSheetDialogFragment.kt index 49223c267a..a6cb2ddd5c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/FixedRoundedCornerBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FixedRoundedCornerBottomSheetDialogFragment.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FragmentWrapperActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/FragmentWrapperActivity.kt new file mode 100644 index 0000000000..44bc86425d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FragmentWrapperActivity.kt @@ -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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardEntryDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardEntryDialogFragment.kt index ea1ffa206a..aeddc8d202 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardEntryDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardEntryDialogFragment.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java index 407222ae5c..54c4920d21 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java index 2046564b94..806b64f610 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java @@ -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){ diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java index 5639743eaf..396bc549cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java index a93f3ea68a..1b147fe1a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Segment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Segment.kt new file mode 100644 index 0000000000..1a7f607d89 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Segment.kt @@ -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++ +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBar.kt b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBar.kt new file mode 100644 index 0000000000..490cc6df17 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBar.kt @@ -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 = 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() + 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBarListener.kt b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBarListener.kt new file mode 100644 index 0000000000..167fd93ed4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/SegmentedProgressBarListener.kt @@ -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() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Utils.kt b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Utils.kt new file mode 100644 index 0000000000..c84c201e6d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/segmentedprogressbar/Utils.kt @@ -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> { + + val rectangles = mutableListOf() + val paints = mutableListOf() + 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) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt index ff74aea760..06b6388dd7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt @@ -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(R.id.recycler).apply { edgeEffectFactory = EdgeEffectFactory() layoutManager = layoutManagerProducer(requireContext()) adapter = settingsAdapter - addOnScrollListener(scrollAnimationHelper!!) + + val helper = scrollAnimationHelper + if (helper != null) { + addOnScrollListener(helper) + } } bindAdapter(settingsAdapter) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsIcon.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsIcon.kt index 81ba8622e5..fb0b2431ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsIcon.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsIcon.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index ea52fc6c25..e98a377540 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -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() + } + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt index 1a5d53d8c8..5287260358 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt @@ -20,4 +20,5 @@ data class InternalSettingsState( val removeSenderKeyMinimium: Boolean, val delayResends: Boolean, val disableStorageService: Boolean, + val disableStories: Boolean ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt index 495fd779bb..56e69af03f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt index 54e3e0c024..4cba2473a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsRepository.kt index a955ca8e9c..3c2986eabc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsRepository.kt @@ -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) -> Unit) { + SignalExecutors.BOUNDED.execute { + consumer(SignalDatabase.distributionLists.getCustomListsForUi()) + } + } + fun syncReadReceiptState() { SignalExecutors.BOUNDED.execute { SignalDatabase.recipients.markNeedsSync(Recipient.self().id) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt index 14f6e55913..0ecd655003 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt @@ -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, + val isStoriesEnabled: Boolean ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt index d68d0c148f..4bba7a023a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 7ecb83079c..406b89e752 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -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), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt index 25e054ba35..3d7ac8dc04 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt @@ -195,6 +195,8 @@ class InternalConversationSettingsFragment : DSLSettingsFragment( colorize("SenderKey", recipient.senderKeyCapability), ", ", colorize("ChangeNumber", recipient.changeNumberCapability), + ", ", + colorize("Stories", recipient.storiesCapability), ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt index 6eff46c027..56111a1275 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt @@ -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(itemView) { - private val avatar: AvatarImageView = itemView.findViewById(R.id.bio_preference_avatar).apply { + private val avatar: AvatarView = itemView.findViewById(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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/LargeIconClickPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/LargeIconClickPreference.kt index eb44a3346d..e00bd32f2f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/LargeIconClickPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/LargeIconClickPreference.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt index 3cc0d75ccf..7c29bd3835 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt @@ -17,9 +17,9 @@ fun configure(init: DSLConfiguration.() -> Unit): DSLConfiguration { } class DSLConfiguration { - private val children = arrayListOf>() + private val children = arrayListOf>() - fun customPref(customPreference: PreferenceModel<*>) { + fun customPref(customPreference: MappingModel<*>) { children.add(customPreference) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java index 84dc4a04e7..7cc5d3bff1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListItem.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java index 87da663e1d..70da81f053 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java @@ -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 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 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 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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorRows.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorRows.java index aaed145b6c..29fabb1372 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorRows.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorRows.java @@ -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)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/HeaderAction.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/HeaderAction.kt new file mode 100644 index 0000000000..1168e3f5ea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/HeaderAction.kt @@ -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) {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContact.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContact.java index 54d15c70b1..184ea2b653 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContact.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContact.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt new file mode 100644 index 0000000000..677b867db2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt @@ -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
+) { + 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 = 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
= 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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt new file mode 100644 index 0000000000..9a2932054e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt @@ -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)) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchItems.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchItems.kt new file mode 100644 index 0000000000..47f7db9b59 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchItems.kt @@ -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, selection: Set): 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 { + + 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(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 { + + 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(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(itemView: View, val onClick: (D, Boolean) -> Unit) : MappingViewHolder(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 { + 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(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 { + 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(itemView) { + override fun bind(model: ExpandModel) { + itemView.setOnClickListener { expandListener.invoke(model.expand) } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt new file mode 100644 index 0000000000..ea6febc452 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt new file mode 100644 index 0000000000..2839d43398 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt @@ -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() + recyclerView.adapter = adapter + + ContactSearchItems.register( + mappingAdapter = adapter, + recipientListener = this::toggleSelection, + storyListener = this::toggleSelection, + expandListener = { viewModel.expandSection(it.sectionKey) } + ) + + val dataAndSelection: LiveData, Set>> = 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) { + viewModel.setKeysSelected(keys) + } + + fun setKeysNotSelected(keys: Set) { + viewModel.setKeysNotSelected(keys) + } + + fun getSelectedContacts(): Set { + return viewModel.getSelectedContacts() + } + + fun getSelectionState(): LiveData> { + return viewModel.selectionState + } + + fun addToVisibleGroupStories(groupStories: Set) { + viewModel.addToVisibleGroupStories(groupStories) + } + + private fun toggleSelection(contactSearchData: ContactSearchData, isSelected: Boolean) { + if (isSelected) { + viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey)) + } else { + viewModel.setKeysSelected(setOf(contactSearchData.contactSearchKey)) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt new file mode 100644 index 0000000000..485c7f8c6d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt @@ -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 { + + override fun size(): Int { + return contactConfiguration.sections.sumBy { + getSectionSize(it, contactConfiguration.query) + } + } + + override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList { + val sizeMap: Map = 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> = 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, 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 = 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 { + return section.groupStories.filter { contactSearchPagedDataSourceRepository.recipientNameContainsQuery(it.recipient, query) } + } + + private fun getSectionData(section: ContactSearchConfiguration.Section, query: String?, startIndex: Int, endIndex: Int): List { + 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 = emptyList() + ): List { + val results = mutableListOf() + + 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 { + 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 { + 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 { + 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 { + 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, + 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 { + val results = mutableListOf() + 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 + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt new file mode 100644 index 0000000000..b9add44a22 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt @@ -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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchRepository.kt new file mode 100644 index 0000000000..1e270783a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchRepository.kt @@ -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): Single> { + 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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchSelectionResult.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchSelectionResult.kt new file mode 100644 index 0000000000..c6b1aacac3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchSelectionResult.kt @@ -0,0 +1,3 @@ +package org.thoughtcrime.securesms.contacts.paged + +data class ContactSearchSelectionResult(val key: ContactSearchKey, val isSelectable: Boolean) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchState.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchState.kt new file mode 100644 index 0000000000..bca11b2f8b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchState.kt @@ -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 = emptySet(), + val groupStories: Set = emptySet() +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt new file mode 100644 index 0000000000..24c957baea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt @@ -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>() + private val configurationStore = Store(ContactSearchState()) + private val selectionStore = Store>(emptySet()) + + val controller: LiveData> = Transformations.map(pagedData) { it.controller } + val data: LiveData> = Transformations.switchMap(pagedData) { it.data } + val configurationState: LiveData = configurationStore.stateLiveData + val selectionState: LiveData> = 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) { + 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) { + selectionStore.update { it - contactSearchKeys } + } + + fun getSelectedContacts(): Set { + return selectionStore.state + } + + fun addToVisibleGroupStories(groupStories: Set) { + 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 create(modelClass: Class): T { + return modelClass.cast(ContactSearchViewModel(selectionLimits, repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/RecipientSearchKey.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/RecipientSearchKey.kt new file mode 100644 index 0000000000..2df5f6bf5a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/RecipientSearchKey.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/selection/ContactSelectionArguments.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/selection/ContactSelectionArguments.kt new file mode 100644 index 0000000000..159368c347 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/selection/ContactSelectionArguments.kt @@ -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 = 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" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 3bbc159fcf..374f811c82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index 8f29311be4..141e4ee147 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -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 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 future = new SettableFuture<>(); final Context context = requireContext().getApplicationContext(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java index 056bda66d5..fc5f16e39d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java @@ -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()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColors.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColors.kt index 39d32d78f2..801ce310c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColors.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColors.kt @@ -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 { + override fun createFromParcel(parcel: Parcel): Id { + return forLongValue(parcel.readLong()) + } + + override fun newArray(size: Int): Array { + 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/NameColors.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/NameColors.kt new file mode 100644 index 0000000000..c499e4a2fc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/NameColors.kt @@ -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> { + return mutableMapOf() + } + + fun getNameColorsMapLiveData( + recipientId: LiveData, + sessionMemberCache: MutableMap> + ): LiveData> { + 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 -> + g.transform { groupId: GroupId -> this.getSessionGroupRecipients(groupId, sessionMemberCache) } + .or { DefaultValueLiveData(emptySet()) } + } + return Transformations.map(groupMembers) { members: Set? -> + val sorted = Stream.of(members) + .filter { member: Recipient? -> member != Recipient.self() } + .sortBy { obj: Recipient -> obj.requireStringId() } + .toList() + val names = all + val colors: MutableMap = HashMap() + for (i in sorted.indices) { + colors[sorted[i].id] = names[i % names.size] + } + colors + } + } + + private fun getSessionGroupRecipients(groupId: GroupId, sessionMemberCache: MutableMap>): LiveData> { + val fullMembers = Transformations.map( + LiveGroup(groupId).fullMembers + ) { members: List? -> + Stream.of(members) + .map { it.member } + .toList() + } + return Transformations.map(fullMembers) { currentMembership: List? -> + val cachedMembers: MutableSet = MapUtil.getOrDefault(sessionMemberCache, groupId, HashSet()).toMutableSet() + cachedMembers.addAll(currentMembership!!) + sessionMemberCache[groupId] = cachedMembers + cachedMembers + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardBottomSheet.kt new file mode 100644 index 0000000000..52fd2740ea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardBottomSheet.kt @@ -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() + + 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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt index 4455012ba5..db8d532146 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt @@ -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 = 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 = bundle.getParcelableArrayList(ChooseGroupStoryBottomSheet.RESULT_SET)?.toSet() ?: emptySet() + val keys: Set = 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) { - 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) { + 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, number: String?, callback: Consumer) { - 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, 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) { - 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() - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFullScreenDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFullScreenDialogFragment.kt new file mode 100644 index 0000000000..09af27f347 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFullScreenDialogFragment.kt @@ -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()?.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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt index 8f3e4646a5..b1b51c6964 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt @@ -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, consumer: Consumer>) { + fun checkForBadIdentityRecords(contactSearchKeys: Set, consumer: Consumer>) { SignalExecutors.BOUNDED.execute { - val recipients: List = shareContacts.map { Recipient.resolved(it.recipientId.get()) } + val recipients: List = contactSearchKeys + .filterIsInstance() + .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, - shareContacts: List, + shareContacts: Set, resultHandlers: MultiselectForwardResultHandlers ) { SignalExecutors.BOUNDED.execute { @@ -63,10 +65,13 @@ class MultiselectForwardRepository(context: Context) { val sharedContactsAndThreads: Set = 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.map { it.buildUpon(sharedContactsAndThreads).build() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt index 13c6ec9516..21f9ec0e5c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt @@ -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 = 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) : Stage() + data class SelectionConfirmed(val selectedContacts: Set) : Stage() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt index ce1f00a974..097f7ba731 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt @@ -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, @@ -22,31 +17,15 @@ class MultiselectForwardViewModel( val state: LiveData = store.stateLiveData - val shareContactMappingModels: LiveData> = Transformations.map(state) { s -> s.selectedContacts.mapIndexed { i, c -> ShareSelectionMappingModel(c, i == 0) } } - - fun addSelectedContact(recipientId: Optional, number: String?): Single { - return repository - .canSelectRecipient(recipientId) - .doOnSuccess { allowed -> - if (allowed) { - store.update { it.copy(selectedContacts = it.selectedContacts + ShareContact(recipientId, number)) } - } - } - } - - fun removeSelectedContact(recipientId: Optional, number: String?) { - store.update { it.copy(selectedContacts = it.selectedContacts - ShareContact(recipientId, number)) } - } - - fun send(additionalMessage: String) { + fun send(additionalMessage: String, selectedContacts: Set) { 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) { + send(additionalMessage, selectedContacts) } - fun confirmSafetySend(additionalMessage: String) { - send(additionalMessage) + fun confirmSafetySend(additionalMessage: String, selectedContacts: Set) { + 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) { 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) } }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/SenderKeyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/SenderKeyUtil.java index 67b5034074..d0d66a48ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/SenderKeyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/SenderKeyUtil.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java index fabd7a1830..d9786a9329 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java @@ -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 messageUpdateObservers; private final Map> messageInsertObservers; private final Set notificationProfileObservers; + private final Map> 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListDatabase.kt new file mode 100644 index 0000000000..363205e536 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListDatabase.kt @@ -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 = 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 { + return getAllListsForContactSelectionUiCursor(query, includeMyStory)?.use { + val results = mutableListOf() + 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 { + 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() + 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): 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 { + if (listId == DistributionListId.MY_STORY) { + val blockedMembers = getRawMembers(listId).toSet() + + return SignalDatabase.recipients.getSignalContacts(false)?.use { + val result = mutableListOf() + 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 { + val members = mutableListOf() + + 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)) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index a0f6560097..f57fdc59ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java index f211a2f2bd..4c1ef007de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -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 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 segments = new ArrayList<>(Types.OUTGOING_MESSAGE_TYPES.length); for (long outgoingMessageType : Types.OUTGOING_MESSAGE_TYPES) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index 013aecf796..a596705b7b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -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 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 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index d0bf86bb22..f86d74bc99 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -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 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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt index d80e6da5fc..2f01ac5e4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt @@ -72,7 +72,7 @@ class ReactionDatabase(context: Context, databaseHelper: SignalDatabase) : Datab val reactions: MutableList = 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> = 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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt index 0ac4af4a30..4fd394ab83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt @@ -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) { @@ -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 { 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt index 8a22a98095..da84ba551b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index d1bfb998b9..d5c4730534 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -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 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 8e00f0e806..19448e59e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index b3dece3f07..3f1ae5e2ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DatabaseId.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DatabaseId.java new file mode 100644 index 0000000000..c7056d2d29 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DatabaseId.java @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.database.model; + +import androidx.annotation.NonNull; + +public interface DatabaseId { + @NonNull String serialize(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListId.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListId.java new file mode 100644 index 0000000000..66961f9600 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListId.java @@ -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 CREATOR = new Creator() { + @Override + public DistributionListId createFromParcel(Parcel in) { + return new DistributionListId(in.readLong()); + } + + @Override + public DistributionListId[] newArray(int size) { + return new DistributionListId[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPartialRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPartialRecord.kt new file mode 100644 index 0000000000..5add270a56 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPartialRecord.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListRecord.kt new file mode 100644 index 0000000000..8323d0cbd8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListRecord.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt index c72f82b37f..e7155aa781 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt @@ -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 = serialized.split("|") diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt index aa50a1220a..a90c04aacb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java index 5004fae891..71df9d35c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 6588c59fcd..2f5323747d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/FontFileMap.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/FontFileMap.kt new file mode 100644 index 0000000000..10fa1efd70 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/FontFileMap.kt @@ -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) { + + 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.") + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/FontManifest.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/FontManifest.kt new file mode 100644 index 0000000000..14289e08e4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/FontManifest.kt @@ -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" + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/FontVersion.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/FontVersion.kt new file mode 100644 index 0000000000..c53bf8eed5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/FontVersion.kt @@ -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() } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/Fonts.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/Fonts.kt new file mode 100644 index 0000000000..9a9782e0a0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/Fonts.kt @@ -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>()) + + /** + * 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, val placeholder: Typeface) : FontResult() + } + + private data class FontDownloadKey( + val version: FontVersion, + val locale: Locale, + val font: TextFont + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/ScriptUtil.java b/app/src/main/java/org/thoughtcrime/securesms/fonts/ScriptUtil.java new file mode 100644 index 0000000000..7826c564a4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/ScriptUtil.java @@ -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 + *
+ * @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> SCRIPTS_BY_LOCALE = new HashMap<>(); + + public static Map getScriptsMap(String... keyValuePairs) + { + Map languages = new HashMap(); + 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 scripts = SCRIPTS_BY_LOCALE.get(language); + if (scripts == null) { + return null; + } else { + if (scripts.containsKey(country)) { + return scripts.get(country); + } else { + return scripts.get(""); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/TextFont.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/TextFont.kt new file mode 100644 index 0000000000..e1a0f30391 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/TextFont.kt @@ -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); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java index d3b18e8c71..afe9520176 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java @@ -79,7 +79,7 @@ public class AddMembersActivity extends PushContactSelectionActivity { } @Override - public void onBeforeContactSelected(Optional recipientId, String number, Consumer callback) { + public void onBeforeContactSelected(@NonNull Optional recipientId, String number, @NonNull Consumer 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, String number) { + public void onContactDeselected(@NonNull Optional recipientId, String number) { if (contactsFragment.hasQueryFilter()) { getContactFilterView().clear(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.java index f8907c052f..3aaf08a157 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.java @@ -113,7 +113,7 @@ public final class AddToGroupsActivity extends ContactSelectionActivity { } @Override - public void onBeforeContactSelected(Optional recipientId, String number, Consumer callback) { + public void onBeforeContactSelected(@NonNull Optional recipientId, String number, @NonNull Consumer 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, String number) { + public void onContactDeselected(@NonNull Optional recipientId, String number) { if (contactsFragment.hasQueryFilter()) { getContactFilterView().clear(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java index e3ce9a5fbd..6b03a455ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java @@ -100,7 +100,7 @@ public class CreateGroupActivity extends ContactSelectionActivity { } @Override - public void onBeforeContactSelected(Optional recipientId, String number, Consumer callback) { + public void onBeforeContactSelected(@NonNull Optional recipientId, String number, @NonNull Consumer callback) { if (contactsFragment.hasQueryFilter()) { getContactFilterView().clear(); } @@ -111,7 +111,7 @@ public class CreateGroupActivity extends ContactSelectionActivity { } @Override - public void onContactDeselected(Optional recipientId, String number) { + public void onContactDeselected(@NonNull Optional recipientId, String number) { if (contactsFragment.hasQueryFilter()) { getContactFilterView().clear(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java index 9f133791b3..2ff1bf3ca1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/FontDownloaderJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/FontDownloaderJob.kt new file mode 100644 index 0000000000..c6797ea6ce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/FontDownloaderJob.kt @@ -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 { + 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 { + override fun create(parameters: Parameters, data: Data): FontDownloaderJob { + return FontDownloaderJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index f4a4644e9b..d5207f986f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java new file mode 100644 index 0000000000..7d992ee291 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java @@ -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 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 existingNetworkFailures = message.getNetworkFailures(); + Set 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 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 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 deliver(@NonNull OutgoingMediaMessage message, @NonNull List destinations) + throws IOException, UntrustedIdentityException, UndeliverableMessageException + { + // TODO [stories] Filter based off of stories capability + try { + rotateSenderCertificateIfNecessary(); + + List attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); + List 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 getFullRecipients(@NonNull DistributionListId distributionListId, long messageId) { + List 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 { + @Override + public @NonNull PushDistributionListSendJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new PushDistributionListSendJob(parameters, data.getLong(KEY_MESSAGE_ID)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java index 71fe163cb5..f9c50e3a80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -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 results = deliver(message, groupRecipient, target); + processGroupMessageResults(context, messageId, threadId, groupRecipient, message, results, target, existingNetworkFailures, existingIdentityMismatches); Log.i(TAG, JobLogger.format(this, "Finished send.")); - List networkFailures = Stream.of(results).filter(SendMessageResult::isNetworkFailure).map(result -> new NetworkFailure(accessList.requireIdByAddress(result.getAddress()))).toList(); - List 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 successes = Stream.of(results).filter(result -> result.getSuccess() != null).toList(); - List> successUnidentifiedStatus = Stream.of(successes).map(result -> new Pair<>(accessList.requireIdByAddress(result.getAddress()), result.getSuccess().isUnidentified())).toList(); - Set successIds = Stream.of(successUnidentifiedStatus).map(Pair::first).collect(Collectors.toSet()); - List resolvedNetworkFailures = Stream.of(existingNetworkFailures).filter(failure -> successIds.contains(failure.getRecipientId(context))).toList(); - List resolvedIdentityFailures = Stream.of(existingIdentityMismatches).filter(failure -> successIds.contains(failure.getRecipientId(context))).toList(); - List 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 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 = getQuoteFor(message); Optional sticker = getStickerFor(message); List sharedContacts = getSharedContactsFor(message); - List previews = getPreviewsFor(message); + List previews = getPreviewsFor(message); List mentions = getMentionsFor(message.getMentions()); List attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); List 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 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 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 results, + @NonNull List target, + @NonNull Set existingNetworkFailures, + @NonNull Set existingIdentityMismatches) + throws RetryLaterException, ProofRequiredException + { + MmsDatabase database = SignalDatabase.mms(); + RecipientAccessList accessList = new RecipientAccessList(target); + + List networkFailures = Stream.of(results).filter(SendMessageResult::isNetworkFailure).map(result -> new NetworkFailure(accessList.requireIdByAddress(result.getAddress()))).toList(); + List 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 successes = Stream.of(results).filter(result -> result.getSuccess() != null).toList(); + List> successUnidentifiedStatus = Stream.of(successes).map(result -> new Pair<>(accessList.requireIdByAddress(result.getAddress()), result.getSuccess().isUnidentified())).toList(); + Set successIds = Stream.of(successUnidentifiedStatus).map(Pair::first).collect(Collectors.toSet()); + List resolvedNetworkFailures = Stream.of(existingNetworkFailures).filter(failure -> successIds.contains(failure.getRecipientId(context))).toList(); + List resolvedIdentityFailures = Stream.of(existingIdentityMismatches).filter(failure -> successIds.contains(failure.getRecipientId(context))).toList(); + List 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 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 getGroupMessageRecipients(@NonNull GroupId groupId, long messageId) { List 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 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 { @Override public @NonNull PushGroupSendJob create(@NonNull Parameters parameters, @NonNull org.thoughtcrime.securesms.jobmanager.Data data) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java index 72851c65e6..61beac1f3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java @@ -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 attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); - List serviceAttachments = getAttachmentPointersFor(attachments); - Optional profileKey = getProfileKey(messageRecipient); - Optional quote = getQuoteFor(message); - Optional sticker = getStickerFor(message); - List sharedContacts = getSharedContactsFor(message); - List 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 attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); + List serviceAttachments = getAttachmentPointersFor(attachments); + Optional profileKey = getProfileKey(messageRecipient); + Optional quote = getQuoteFor(message); + Optional sticker = getStickerFor(message); + List sharedContacts = getSharedContactsFor(message); + List 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 syncAccess = UnidentifiedAccessUtil.getAccessForSync(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java index 9818a2ff35..48fb5d10f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java @@ -55,7 +55,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId; 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.NonSuccessfulResponseCodeException; @@ -395,10 +395,10 @@ public abstract class PushSendJob extends SendJob { return sharedContacts; } - List getPreviewsFor(OutgoingMediaMessage mediaMessage) { + List getPreviewsFor(OutgoingMediaMessage mediaMessage) { return Stream.of(mediaMessage.getLinkPreviews()).map(lp -> { SignalServiceAttachment attachment = lp.getThumbnail().isPresent() ? getAttachmentPointerFor(lp.getThumbnail().get()) : null; - return new Preview(lp.getUrl(), lp.getTitle(), lp.getDescription(), lp.getDate(), Optional.fromNullable(attachment)); + return new SignalServicePreview(lp.getUrl(), lp.getTitle(), lp.getDescription(), lp.getDate(), Optional.fromNullable(attachment)); }).toList(); } @@ -441,7 +441,7 @@ public abstract class PushSendJob extends SendJob { } } - protected void handleProofRequiredException(@NonNull ProofRequiredException proofRequired, @Nullable Recipient recipient, long threadId, long messageId, boolean isMms) + protected static void handleProofRequiredException(@NonNull Context context, @NonNull ProofRequiredException proofRequired, @Nullable Recipient recipient, long threadId, long messageId, boolean isMms) throws ProofRequiredException, RetryLaterException { Log.w(TAG, "[Proof Required] Options: " + proofRequired.getOptions()); @@ -449,25 +449,25 @@ public abstract class PushSendJob extends SendJob { try { if (proofRequired.getOptions().contains(ProofRequiredException.Option.PUSH_CHALLENGE)) { ApplicationDependencies.getSignalServiceAccountManager().requestRateLimitPushChallenge(); - log(TAG, "[Proof Required] Successfully requested a challenge. Waiting up to " + PUSH_CHALLENGE_TIMEOUT + " ms."); + Log.i(TAG, "[Proof Required] Successfully requested a challenge. Waiting up to " + PUSH_CHALLENGE_TIMEOUT + " ms."); boolean success = new PushChallengeRequest(PUSH_CHALLENGE_TIMEOUT).blockUntilSuccess(); if (success) { - log(TAG, "Successfully responded to a push challenge. Retrying message send."); + Log.i(TAG, "Successfully responded to a push challenge. Retrying message send."); throw new RetryLaterException(1); } else { - warn(TAG, "Failed to respond to the push challenge in time. Falling back."); + Log.w(TAG, "Failed to respond to the push challenge in time. Falling back."); } } } catch (NonSuccessfulResponseCodeException e) { - warn(TAG, "[Proof Required] Could not request a push challenge (" + e.getCode() + "). Falling back.", e); + Log.w(TAG, "[Proof Required] Could not request a push challenge (" + e.getCode() + "). Falling back.", e); } catch (IOException e) { - warn(TAG, "[Proof Required] Network error when requesting push challenge. Retrying later."); + Log.w(TAG, "[Proof Required] Network error when requesting push challenge. Retrying later."); throw new RetryLaterException(e); } - warn(TAG, "[Proof Required] Marking message as rate-limited. (id: " + messageId + ", mms: " + isMms + ", thread: " + threadId + ")"); + Log.w(TAG, "[Proof Required] Marking message as rate-limited. (id: " + messageId + ", mms: " + isMms + ", thread: " + threadId + ")"); if (isMms) { SignalDatabase.mms().markAsRateLimited(messageId); } else { @@ -475,13 +475,13 @@ public abstract class PushSendJob extends SendJob { } if (proofRequired.getOptions().contains(ProofRequiredException.Option.RECAPTCHA)) { - log(TAG, "[Proof Required] ReCAPTCHA required."); + Log.i(TAG, "[Proof Required] ReCAPTCHA required."); SignalStore.rateLimit().markNeedsRecaptcha(proofRequired.getToken()); if (recipient != null) { ApplicationDependencies.getMessageNotifier().notifyProofRequired(context, recipient, threadId); } else { - warn(TAG, "[Proof Required] No recipient! Couldn't notify."); + Log.w(TAG, "[Proof Required] No recipient! Couldn't notify."); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java index d3d0ac658a..3cef087c83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java @@ -136,7 +136,7 @@ public class PushTextSendJob extends PushSendJob { database.markAsPush(record.getId()); RetrieveProfileJob.enqueue(recipientId); } catch (ProofRequiredException e) { - handleProofRequiredException(e, record.getRecipient(), record.getThreadId(), messageId, false); + handleProofRequiredException(context, e, record.getRecipient(), record.getThreadId(), messageId, false); } SignalLocalMetrics.IndividualMessageSend.onJobFinished(messageId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java index 5e1443e7c1..4f898c8d73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java @@ -111,6 +111,7 @@ public class RefreshAttributesJob extends BaseJob { "\n Sender Key? " + capabilities.isSenderKey() + "\n Announcement Groups? " + capabilities.isAnnouncementGroup() + "\n Change Number? " + capabilities.isChangeNumber() + + "\n Stories? " + capabilities.isChangeNumber() + "\n UUID? " + capabilities.isUuid()); SignalServiceAccountManager signalAccountManager = ApplicationDependencies.getSignalServiceAccountManager(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendJob.java index 793eb12219..76a2d65c6c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendJob.java @@ -45,7 +45,7 @@ public abstract class SendJob extends BaseJob { protected abstract void onSend() throws Exception; - protected void markAttachmentsUploaded(long messageId, @NonNull OutgoingMediaMessage message) { + protected static void markAttachmentsUploaded(long messageId, @NonNull OutgoingMediaMessage message) { List attachments = new LinkedList<>(); attachments.addAll(message.getAttachments()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SenderKeyDistributionSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SenderKeyDistributionSendJob.java index 99dd47275f..010c31b5d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SenderKeyDistributionSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SenderKeyDistributionSendJob.java @@ -101,7 +101,7 @@ public final class SenderKeyDistributionSendJob extends BaseJob { SenderKeyDistributionMessage message = messageSender.getOrCreateNewGroupSession(distributionId); List> access = UnidentifiedAccessUtil.getAccessFor(context, Collections.singletonList(recipient)); - SendMessageResult result = messageSender.sendSenderKeyDistributionMessage(distributionId, address, access, message, groupId.getDecodedId()).get(0); + SendMessageResult result = messageSender.sendSenderKeyDistributionMessage(distributionId, address, access, message, Optional.of(groupId.getDecodedId())).get(0); if (result.isSuccess()) { List addresses = result.getSuccess() diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerFragment.kt index 2c63b3a901..47748f04f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerFragment.kt @@ -1,8 +1,9 @@ - package org.thoughtcrime.securesms.keyboard import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import org.thoughtcrime.securesms.R @@ -10,11 +11,14 @@ import org.thoughtcrime.securesms.components.emoji.MediaKeyboard import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment import org.thoughtcrime.securesms.keyboard.gif.GifKeyboardPageFragment import org.thoughtcrime.securesms.keyboard.sticker.StickerKeyboardPageFragment +import org.thoughtcrime.securesms.util.ThemedFragment.themeResId +import org.thoughtcrime.securesms.util.ThemedFragment.themedInflate +import org.thoughtcrime.securesms.util.ThemedFragment.withTheme import org.thoughtcrime.securesms.util.fragments.findListener import org.thoughtcrime.securesms.util.visible import kotlin.reflect.KClass -class KeyboardPagerFragment : Fragment(R.layout.keyboard_pager_fragment) { +class KeyboardPagerFragment : Fragment() { private lateinit var emojiButton: View private lateinit var stickerButton: View @@ -24,6 +28,10 @@ class KeyboardPagerFragment : Fragment(R.layout.keyboard_pager_fragment) { private val fragments: MutableMap, Fragment> = mutableMapOf() private var currentFragment: Fragment? = null + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return themedInflate(R.layout.keyboard_pager_fragment, inflater, container) + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { emojiButton = view.findViewById(R.id.keyboard_pager_fragment_emoji) stickerButton = view.findViewById(R.id.keyboard_pager_fragment_sticker) @@ -80,7 +88,7 @@ class KeyboardPagerFragment : Fragment(R.layout.keyboard_pager_fragment) { var fragment = fragments[F::class] if (fragment == null) { - fragment = fragmentFactory() + fragment = fragmentFactory().withTheme(themeResId) transaction.add(R.id.fragment_container, fragment) fragments[F::class] = fragment } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardCallback.kt new file mode 100644 index 0000000000..870f950bcb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardCallback.kt @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.keyboard.emoji + +import org.thoughtcrime.securesms.components.emoji.EmojiEventListener +import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment + +interface EmojiKeyboardCallback : + EmojiEventListener, + EmojiKeyboardPageFragment.Callback, + EmojiSearchFragment.Callback diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageFragment.kt index 935e0ac678..d31aa492ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/EmojiKeyboardPageFragment.kt @@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.keyboard.emoji import android.os.Bundle import android.view.KeyEvent +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.LinearLayoutManager @@ -15,13 +17,14 @@ import org.thoughtcrime.securesms.components.emoji.EmojiPageView import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiHeader import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.ThemedFragment.themedInflate import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel import org.thoughtcrime.securesms.util.fragments.requireListener import java.util.Optional private val DELETE_KEY_EVENT: KeyEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL) -class EmojiKeyboardPageFragment : Fragment(R.layout.keyboard_pager_emoji_page_fragment), EmojiEventListener, EmojiPageViewGridAdapter.VariationSelectorListener { +class EmojiKeyboardPageFragment : Fragment(), EmojiEventListener, EmojiPageViewGridAdapter.VariationSelectorListener { private lateinit var viewModel: EmojiKeyboardPageViewModel private lateinit var emojiPageView: EmojiPageView @@ -36,6 +39,10 @@ class EmojiKeyboardPageFragment : Fragment(R.layout.keyboard_pager_emoji_page_fr private val categoryUpdateOnScroll = UpdateCategorySelectionOnScroll() + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return themedInflate(R.layout.keyboard_pager_emoji_page_fragment, inflater, container) + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { callback = requireNotNull(requireListener()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchFragment.kt index bdd2aae481..3f0734669f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchFragment.kt @@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.keyboard.emoji.search import android.content.Context import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.FrameLayout import android.widget.TextView import androidx.fragment.app.Fragment @@ -15,10 +17,11 @@ import org.thoughtcrime.securesms.components.emoji.EmojiEventListener import org.thoughtcrime.securesms.components.emoji.EmojiPageView import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView +import org.thoughtcrime.securesms.util.ThemedFragment.themedInflate import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.fragments.requireListener -class EmojiSearchFragment : Fragment(R.layout.emoji_search_fragment), EmojiPageViewGridAdapter.VariationSelectorListener { +class EmojiSearchFragment : Fragment(), EmojiPageViewGridAdapter.VariationSelectorListener { private lateinit var viewModel: EmojiSearchViewModel private lateinit var callback: Callback @@ -29,6 +32,10 @@ class EmojiSearchFragment : Fragment(R.layout.emoji_search_fragment), EmojiPageV callback = requireListener() } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return themedInflate(R.layout.emoji_search_fragment, inflater, container) + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val repository = EmojiSearchRepository(requireContext()) val factory = EmojiSearchViewModel.Factory(repository) diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java index 7e0b45c3e6..73a1dfc11c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -42,6 +42,7 @@ public final class SignalStore { private final ImageEditorValues imageEditorValues; private final NotificationProfileValues notificationProfileValues; private final ReleaseChannelValues releaseChannelValues; + private final StoryValues storyValues; private static volatile SignalStore instance; @@ -83,6 +84,7 @@ public final class SignalStore { this.imageEditorValues = new ImageEditorValues(store); this.notificationProfileValues = new NotificationProfileValues(store); this.releaseChannelValues = new ReleaseChannelValues(store); + this.storyValues = new StoryValues(store); } public static void onFirstEverAppLaunch() { @@ -110,6 +112,7 @@ public final class SignalStore { imageEditorValues().onFirstEverAppLaunch(); notificationProfileValues().onFirstEverAppLaunch(); releaseChannelValues().onFirstEverAppLaunch(); + storyValues().onFirstEverAppLaunch(); } public static List getKeysToIncludeInBackup() { @@ -138,6 +141,7 @@ public final class SignalStore { keys.addAll(imageEditorValues().getKeysToIncludeInBackup()); keys.addAll(notificationProfileValues().getKeysToIncludeInBackup()); keys.addAll(releaseChannelValues().getKeysToIncludeInBackup()); + keys.addAll(storyValues().getKeysToIncludeInBackup()); return keys; } @@ -253,6 +257,10 @@ public final class SignalStore { return getInstance().releaseChannelValues; } + public static @NonNull StoryValues storyValues() { + return getInstance().storyValues; + } + public static @NonNull GroupsV2AuthorizationSignalStoreCache groupsV2AuthorizationCache() { return new GroupsV2AuthorizationSignalStoreCache(getStore()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt new file mode 100644 index 0000000000..f5ebb110e0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.keyvalue + +internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) { + + companion object { + /* + * User option to completely disable stories + */ + private const val MANUAL_FEATURE_DISABLE = "stories.disable" + + private const val LAST_FONT_VERSION_CHECK = "stories.last.font.version.check" + + /** + * Used to check whether we should display certain dialogs. + */ + private const val USER_HAS_ADDED_TO_A_STORY = "user.has.added.to.a.story" + } + + override fun onFirstEverAppLaunch() = Unit + + override fun getKeysToIncludeInBackup(): MutableList = mutableListOf(MANUAL_FEATURE_DISABLE, USER_HAS_ADDED_TO_A_STORY) + + var isFeatureDisabled: Boolean by booleanValue(MANUAL_FEATURE_DISABLE, false) + + var lastFontVersionCheck: Long by longValue(LAST_FONT_VERSION_CHECK, 0) + + var userHasAddedToAStory: Boolean by booleanValue(USER_HAS_ADDED_TO_A_STORY, false) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionCapabilities.java b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionCapabilities.java index cd9cab1d02..7b2b875762 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionCapabilities.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionCapabilities.java @@ -43,6 +43,7 @@ public final class LogSectionCapabilities implements LogSection { .append("GV1 Migration : ").append(self.getGroupsV1MigrationCapability()).append("\n") .append("Sender Key : ").append(self.getSenderKeyCapability()).append("\n") .append("Announcement Groups: ").append(self.getAnnouncementGroupCapability()).append("\n") - .append("Change Number : ").append(self.getChangeNumberCapability()).append("\n"); + .append("Change Number : ").append(self.getChangeNumberCapability()).append("\n") + .append("Stories : ").append(self.getStoriesCapability()).append("\n"); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewFragment.java index c05dd211aa..90fd8ddfad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewFragment.java @@ -62,11 +62,14 @@ public abstract class MediaPreviewFragment extends Fragment { @Override public void onAttach(@NonNull Context context) { super.onAttach(context); - if (!(context instanceof Events)) { - throw new AssertionError("Activity must support " + Events.class); - } - events = (Events) context; + if (context instanceof Events) { + events = (Events) context; + } else if (getParentFragment() instanceof Events) { + events = (Events) getParentFragment(); + } else { + throw new AssertionError("Parent component must support " + Events.class); + } } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.java index 1d6dcc82b2..e0f1cc9093 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.java @@ -32,6 +32,7 @@ public class MediaSendActivityResult implements Parcelable { private final TransportOption transport; private final boolean viewOnce; private final Collection mentions; + private final boolean isStory; public static @NonNull MediaSendActivityResult fromData(@NonNull Intent data) { MediaSendActivityResult result = data.getParcelableExtra(MediaSendActivityResult.EXTRA_RESULT); @@ -47,10 +48,11 @@ public class MediaSendActivityResult implements Parcelable { @NonNull String body, @NonNull TransportOption transport, boolean viewOnce, - @NonNull List mentions) + @NonNull List mentions, + boolean isStory) { Preconditions.checkArgument(uploadResults.size() > 0, "Must supply uploadResults!"); - return new MediaSendActivityResult(recipientId, uploadResults, Collections.emptyList(), body, transport, viewOnce, mentions); + return new MediaSendActivityResult(recipientId, uploadResults, Collections.emptyList(), body, transport, viewOnce, mentions, isStory); } public static @NonNull MediaSendActivityResult forTraditionalSend(@NonNull RecipientId recipientId, @@ -58,10 +60,11 @@ public class MediaSendActivityResult implements Parcelable { @NonNull String body, @NonNull TransportOption transport, boolean viewOnce, - @NonNull List mentions) + @NonNull List mentions, + boolean isStory) { Preconditions.checkArgument(nonUploadedMedia.size() > 0, "Must supply media!"); - return new MediaSendActivityResult(recipientId, Collections.emptyList(), nonUploadedMedia, body, transport, viewOnce, mentions); + return new MediaSendActivityResult(recipientId, Collections.emptyList(), nonUploadedMedia, body, transport, viewOnce, mentions, isStory); } private MediaSendActivityResult(@NonNull RecipientId recipientId, @@ -70,7 +73,8 @@ public class MediaSendActivityResult implements Parcelable { @NonNull String body, @NonNull TransportOption transport, boolean viewOnce, - @NonNull List mentions) + @NonNull List mentions, + boolean isStory) { this.recipientId = recipientId; this.uploadResults = uploadResults; @@ -79,6 +83,7 @@ public class MediaSendActivityResult implements Parcelable { this.transport = transport; this.viewOnce = viewOnce; this.mentions = mentions; + this.isStory = isStory; } private MediaSendActivityResult(Parcel in) { @@ -89,6 +94,7 @@ public class MediaSendActivityResult implements Parcelable { this.transport = in.readParcelable(TransportOption.class.getClassLoader()); this.viewOnce = ParcelUtil.readBoolean(in); this.mentions = ParcelUtil.readParcelableCollection(in, Mention.class); + this.isStory = ParcelUtil.readBoolean(in); } public @NonNull RecipientId getRecipientId() { @@ -123,6 +129,10 @@ public class MediaSendActivityResult implements Parcelable { return mentions; } + public boolean isStory() { + return isStory; + } + public static final Creator CREATOR = new Creator() { @Override public MediaSendActivityResult createFromParcel(Parcel in) { @@ -149,5 +159,6 @@ public class MediaSendActivityResult implements Parcelable { dest.writeParcelable(transport, 0); ParcelUtil.writeBoolean(dest, viewOnce); ParcelUtil.writeParcelableCollection(dest, mentions); + ParcelUtil.writeBoolean(dest, isStory); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/HudCommand.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/HudCommand.kt index c3f3f04bee..4ef01ad242 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/HudCommand.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/HudCommand.kt @@ -7,6 +7,9 @@ sealed class HudCommand { object StartCropAndRotate : HudCommand() object SaveMedia : HudCommand() + object GoToText : HudCommand() + object GoToCapture : HudCommand() + object ResumeEntryTransition : HudCommand() object OpenEmojiSearch : HudCommand() diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt index 05fa6d35eb..b5ea957657 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt @@ -4,7 +4,10 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup import androidx.activity.OnBackPressedCallback +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatDelegate import androidx.lifecycle.ViewModelProvider import androidx.navigation.Navigation @@ -17,11 +20,15 @@ import org.thoughtcrime.securesms.TransportOptions import org.thoughtcrime.securesms.components.emoji.EmojiEventListener import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult import org.thoughtcrime.securesms.mediasend.v2.review.MediaReviewFragment +import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationViewModel import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.navigation.safeNavigate +import org.thoughtcrime.securesms.util.visible class MediaSelectionActivity : PassphraseRequiredActivity(), @@ -32,24 +39,46 @@ class MediaSelectionActivity : lateinit var viewModel: MediaSelectionViewModel + private val textViewModel: TextStoryPostCreationViewModel by viewModels() + + private val destination: MediaSelectionDestination + get() = MediaSelectionDestination.fromBundle(requireNotNull(intent.getBundleExtra(DESTINATION))) + override fun attachBaseContext(newBase: Context) { delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES super.attachBaseContext(newBase) } override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { - setContentView(R.layout.fragment_container) + setContentView(R.layout.media_selection_activity) val transportOption: TransportOption = requireNotNull(intent.getParcelableExtra(TRANSPORT_OPTION)) val initialMedia: List = intent.getParcelableArrayListExtra(MEDIA) ?: listOf() - val destination: MediaSelectionDestination = MediaSelectionDestination.fromBundle(requireNotNull(intent.getBundleExtra(DESTINATION))) val message: CharSequence? = intent.getCharSequenceExtra(MESSAGE) val isReply: Boolean = intent.getBooleanExtra(IS_REPLY, false) val factory = MediaSelectionViewModel.Factory(destination, transportOption, initialMedia, message, isReply, MediaSelectionRepository(this)) viewModel = ViewModelProvider(this, factory)[MediaSelectionViewModel::class.java] + val textStoryToggle: ViewGroup = findViewById(R.id.switch_widget) + val textSwitch: View = findViewById(R.id.text_switch) + val cameraSwitch: View = findViewById(R.id.camera_switch) + + textSwitch.setOnClickListener { + textSwitch.isSelected = true + cameraSwitch.isSelected = false + viewModel.sendCommand(HudCommand.GoToText) + } + + cameraSwitch.setOnClickListener { + textSwitch.isSelected = false + cameraSwitch.isSelected = true + viewModel.sendCommand(HudCommand.GoToCapture) + } + if (savedInstanceState == null) { + cameraSwitch.isSelected = true + val navHostFragment = NavHostFragment.create(R.navigation.media) supportFragmentManager @@ -60,14 +89,33 @@ class MediaSelectionActivity : navigateToStartDestination() } else { viewModel.onRestoreState(savedInstanceState) + textViewModel.restoreFromInstanceState(savedInstanceState) + } + + (supportFragmentManager.findFragmentByTag(NAV_HOST_TAG) as NavHostFragment).navController.addOnDestinationChangedListener { _, d, _ -> + when (d.id) { + R.id.mediaCaptureFragment -> textStoryToggle.visible = canDisplayStorySwitch() + R.id.textStoryPostCreationFragment -> textStoryToggle.visible = canDisplayStorySwitch() + else -> textStoryToggle.visible = false + } } onBackPressedDispatcher.addCallback(OnBackPressed()) } + private fun canDisplayStorySwitch(): Boolean { + return FeatureFlags.stories() && + FeatureFlags.storiesTextPosts() && + !SignalStore.storyValues().isFeatureDisabled && + isCameraFirst() && + !viewModel.hasSelectedMedia() && + destination == MediaSelectionDestination.ChooseAfterMediaSelection + } + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) viewModel.onSaveState(outState) + textViewModel.saveToInstanceState(outState) } override fun onSentWithResult(mediaSendActivityResult: MediaSendActivityResult) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionDestination.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionDestination.kt index f0df3df747..93aeef9e92 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionDestination.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionDestination.kt @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms.mediasend.v2 import android.os.Bundle +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey +import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey import org.thoughtcrime.securesms.recipients.RecipientId sealed class MediaSelectionDestination { @@ -28,7 +30,7 @@ sealed class MediaSelectionDestination { } class SingleRecipient(private val id: RecipientId) : MediaSelectionDestination() { - override fun getRecipientId(): RecipientId = id + override fun getRecipientSearchKey(): RecipientSearchKey = ContactSearchKey.KnownRecipient(id) override fun toBundle(): Bundle { return Bundle().apply { @@ -38,7 +40,7 @@ sealed class MediaSelectionDestination { } class MultipleRecipients(val recipientIds: List) : MediaSelectionDestination() { - override fun getRecipientIdList(): List = recipientIds + override fun getRecipientSearchKeyList(): List = recipientIds.map { ContactSearchKey.KnownRecipient(it) } override fun toBundle(): Bundle { return Bundle().apply { @@ -47,8 +49,8 @@ sealed class MediaSelectionDestination { } } - open fun getRecipientId(): RecipientId? = null - open fun getRecipientIdList(): List = emptyList() + open fun getRecipientSearchKey(): RecipientSearchKey? = null + open fun getRecipientSearchKeyList(): List = emptyList() abstract fun toBundle(): Bundle diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt index 814244aaf8..09a0780626 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt @@ -11,6 +11,8 @@ import org.signal.core.util.ThreadUtil import org.signal.core.util.logging.Log import org.signal.imageeditor.core.model.EditorModel import org.thoughtcrime.securesms.TransportOption +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey +import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.Mention @@ -31,12 +33,12 @@ import org.thoughtcrime.securesms.mms.SentMediaQuality import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.scribbles.ImageEditorFragment import org.thoughtcrime.securesms.sms.MessageSender import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult import org.thoughtcrime.securesms.util.MessageUtil import java.util.ArrayList +import java.util.Collections import java.util.concurrent.TimeUnit private val TAG = Log.tag(MediaSelectionRepository::class.java) @@ -68,12 +70,12 @@ class MediaSelectionRepository(context: Context) { message: CharSequence?, isSms: Boolean, isViewOnce: Boolean, - singleRecipientId: RecipientId?, - recipientIds: List, + singleContact: RecipientSearchKey?, + contacts: List, mentions: List, transport: TransportOption ): Maybe { - if (isSms && recipientIds.isNotEmpty()) { + if (isSms && contacts.isNotEmpty()) { throw IllegalStateException("Provided recipients to send to, but this is SMS!") } @@ -92,10 +94,10 @@ class MediaSelectionRepository(context: Context) { Log.w(TAG, media.uri.toString() + " : " + media.transformProperties.transform { t: TransformProperties -> "" + t.isVideoTrim }.or("null")) } - val singleRecipient = singleRecipientId?.let { Recipient.resolved(it) } + val singleRecipient: Recipient? = singleContact?.let { Recipient.resolved(it.recipientId) } if (isSms || MessageSender.isLocalSelfSend(context, singleRecipient, isSms)) { Log.i(TAG, "SMS or local self-send. Skipping pre-upload.") - emitter.onSuccess(MediaSendActivityResult.forTraditionalSend(requireNotNull(singleRecipient).id, updatedMedia, trimmedBody, transport, isViewOnce, trimmedMentions)) + emitter.onSuccess(MediaSendActivityResult.forTraditionalSend(singleRecipient!!.id, updatedMedia, trimmedBody, transport, isViewOnce, trimmedMentions, false)) } else { val splitMessage = MessageUtil.getSplitMessage(context, trimmedBody, transport.calculateCharacters(trimmedBody).maxPrimaryMessageSize) val splitBody = splitMessage.body @@ -119,16 +121,15 @@ class MediaSelectionRepository(context: Context) { uploadRepository.updateCaptions(updatedMedia) uploadRepository.updateDisplayOrder(updatedMedia) uploadRepository.getPreUploadResults { uploadResults -> - if (recipientIds.isNotEmpty()) { - val recipients = recipientIds.map { Recipient.resolved(it) } - sendMessages(recipients, splitBody, uploadResults, trimmedMentions, isViewOnce) + if (contacts.isNotEmpty()) { + sendMessages(contacts, splitBody, uploadResults, trimmedMentions, isViewOnce) uploadRepository.deleteAbandonedAttachments() emitter.onComplete() } else if (uploadResults.isNotEmpty()) { - emitter.onSuccess(MediaSendActivityResult.forPreUpload(requireNotNull(singleRecipient).id, uploadResults, splitBody, transport, isViewOnce, trimmedMentions)) + emitter.onSuccess(MediaSendActivityResult.forPreUpload(singleRecipient!!.id, uploadResults, splitBody, transport, isViewOnce, trimmedMentions, singleContact.isStory)) } else { - Log.w(TAG, "Got empty upload results! isSms: $isSms, updatedMedia.size(): ${updatedMedia.size}, isViewOnce: $isViewOnce, target: $singleRecipientId") - emitter.onSuccess(MediaSendActivityResult.forTraditionalSend(requireNotNull(singleRecipient).id, updatedMedia, trimmedBody, transport, isViewOnce, trimmedMentions)) + Log.w(TAG, "Got empty upload results! isSms: $isSms, updatedMedia.size(): ${updatedMedia.size}, isViewOnce: $isViewOnce, target: $singleContact") + emitter.onSuccess(MediaSendActivityResult.forTraditionalSend(singleRecipient!!.id, updatedMedia, trimmedBody, transport, isViewOnce, trimmedMentions, singleContact.isStory)) } } } @@ -188,28 +189,57 @@ class MediaSelectionRepository(context: Context) { } @WorkerThread - private fun sendMessages(recipients: List, body: String, preUploadResults: Collection, mentions: List, isViewOnce: Boolean) { - val messages: MutableList = ArrayList(recipients.size) + private fun sendMessages(contacts: List, body: String, preUploadResults: Collection, mentions: List, isViewOnce: Boolean) { + val broadcastMessages: MutableList = ArrayList(contacts.size) + val storyMessages: MutableMap> = mutableMapOf() - for (recipient in recipients) { + for (contact in contacts) { + val recipient = Recipient.resolved(contact.recipientId) + val isStory = contact is ContactSearchKey.Story || recipient.isDistributionList val message = OutgoingMediaMessage( recipient, - body, emptyList(), + body, + emptyList(), System.currentTimeMillis(), -1, TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()), isViewOnce, ThreadDatabase.DistributionTypes.DEFAULT, - null, emptyList(), emptyList(), - mentions, mutableSetOf(), mutableSetOf() + isStory, + null, + null, + emptyList(), + emptyList(), + mentions, + mutableSetOf(), + mutableSetOf() ) - messages.add(OutgoingSecureMediaMessage(message)) - // XXX We must do this to avoid sending out messages to the same recipient with the same - // sentTimestamp. If we do this, they'll be considered dupes by the receiver. - ThreadUtil.sleep(5) + if (isStory && preUploadResults.size > 1) { + preUploadResults.forEach { + val list = storyMessages[it] ?: mutableListOf() + list.add(OutgoingSecureMediaMessage(message)) + storyMessages[it] = list + + // XXX We must do this to avoid sending out messages to the same recipient with the same + // sentTimestamp. If we do this, they'll be considered dupes by the receiver. + ThreadUtil.sleep(5) + } + } else { + broadcastMessages.add(OutgoingSecureMediaMessage(message)) + + // XXX We must do this to avoid sending out messages to the same recipient with the same + // sentTimestamp. If we do this, they'll be considered dupes by the receiver. + ThreadUtil.sleep(5) + } } - MessageSender.sendMediaBroadcast(context, messages, preUploadResults) + storyMessages.forEach { (preUploadResult, messages) -> + MessageSender.sendMediaBroadcast(context, messages, Collections.singleton(preUploadResult)) + } + + if (broadcastMessages.isNotEmpty()) { + MessageSender.sendMediaBroadcast(context, broadcastMessages, preUploadResults) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt index ebe1210e50..ff97aac864 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt @@ -12,13 +12,13 @@ import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.subjects.PublishSubject import org.thoughtcrime.securesms.TransportOption import org.thoughtcrime.securesms.components.mention.MentionAnnotation +import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult import org.thoughtcrime.securesms.mediasend.VideoEditorFragment import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.mms.SentMediaQuality import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.scribbles.ImageEditorFragment import org.thoughtcrime.securesms.util.SingleLiveEvent import org.thoughtcrime.securesms.util.Util @@ -67,9 +67,9 @@ class MediaSelectionViewModel( private var lastMediaDrag: Pair = Pair(0, 0) init { - val recipientId = destination.getRecipientId() - if (recipientId != null) { - store.update(Recipient.live(recipientId).liveData) { r, s -> + val recipientSearchKey = destination.getRecipientSearchKey() + if (recipientSearchKey != null) { + store.update(Recipient.live(recipientSearchKey.recipientId).liveData) { r, s -> s.copy( recipient = r, isPreUploadEnabled = shouldPreUpload(s.isMeteredConnection, s.transportOption.isSms, r) @@ -278,7 +278,7 @@ class MediaSelectionViewModel( } fun send( - selectedRecipientIds: List = emptyList(), + selectedContacts: List = emptyList(), ): Maybe { return repository.send( store.state.selectedMedia, @@ -287,8 +287,8 @@ class MediaSelectionViewModel( store.state.message, store.state.transportOption.isSms, isViewOnceEnabled(), - destination.getRecipientId(), - if (selectedRecipientIds.isNotEmpty()) selectedRecipientIds else destination.getRecipientIdList(), + destination.getRecipientSearchKey(), + if (selectedContacts.isNotEmpty()) selectedContacts else destination.getRecipientSearchKeyList(), MentionAnnotation.getMentionsFromAnnotations(store.state.message), store.state.transportOption ) @@ -332,6 +332,10 @@ class MediaSelectionViewModel( outState.putParcelableArrayList(STATE_EDITORS, ArrayList(editorStates)) } + fun hasSelectedMedia(): Boolean { + return store.state.selectedMedia.isNotEmpty() + } + fun onRestoreState(savedInstanceState: Bundle) { val selection: List = savedInstanceState.getParcelableArrayList(STATE_SELECTION) ?: emptyList() val focused: Media? = savedInstanceState.getParcelable(STATE_FOCUSED) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt index 0fe60e8ee9..7b8b5e4bd2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt @@ -14,11 +14,14 @@ import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.mediasend.CameraFragment import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.v2.HudCommand import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator.Companion.requestPermissionsForGallery import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.whispersystems.libsignal.util.guava.Optional import java.io.FileDescriptor @@ -40,6 +43,8 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme private lateinit var captureChildFragment: CameraFragment private lateinit var navigator: MediaSelectionNavigator + private val lifecycleDisposable = LifecycleDisposable() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { captureChildFragment = CameraFragment.newInstance() as CameraFragment @@ -75,6 +80,13 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme captureChildFragment.presentHud(state.selectedMedia.size) } + lifecycleDisposable.bindTo(viewLifecycleOwner) + lifecycleDisposable += sharedViewModel.hudCommands.subscribe { command -> + if (command == HudCommand.GoToText) { + findNavController().safeNavigate(R.id.action_mediaCaptureFragment_to_textStoryPostCreationFragment) + } + } + if (isFirst()) { requireActivity().onBackPressedDispatcher.addCallback( viewLifecycleOwner, diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt index a3f578f5a3..4a3b55ea85 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt @@ -129,7 +129,7 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a } private fun initializeMentions() { - val recipientId: RecipientId = viewModel.destination.getRecipientId() ?: return + val recipientId: RecipientId = viewModel.destination.getRecipientSearchKey()?.recipientId ?: return mentionsContainer = requireView().findViewById(R.id.mentions_picker_container) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt index f4eee6f7b4..7ae8b7d13b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt @@ -23,6 +23,8 @@ import androidx.viewpager2.widget.ViewPager2 import app.cash.exhaustive.Exhaustive import io.reactivex.rxjava3.disposables.CompositeDisposable import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey +import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult @@ -35,7 +37,6 @@ import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel import org.thoughtcrime.securesms.mediasend.v2.MediaValidator import org.thoughtcrime.securesms.mms.SentMediaQuality import org.thoughtcrime.securesms.permissions.Permissions -import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.fragments.requireListener @@ -135,14 +136,15 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) { } setFragmentResultListener(MultiselectForwardFragment.RESULT_SELECTION) { _, bundle -> - val recipientIds: List = requireNotNull(bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION_RECIPIENTS)) - performSend(recipientIds) + val parcelizedKeys: List = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION_RECIPIENTS)!! + val contactSearchKeys = parcelizedKeys.map { it.asContactSearchKey() } + performSend(contactSearchKeys) } sendButton.setOnClickListener { if (sharedViewModel.isContactSelectionRequired) { val args = MultiselectForwardFragmentArgs(false, title = R.string.MediaReviewFragment__send_to) - MultiselectForwardFragment.show(parentFragmentManager, args) + MultiselectForwardFragment.showFullScreen(parentFragmentManager, args) } else { performSend() } @@ -248,7 +250,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) { } } - private fun performSend(selection: List = listOf()) { + private fun performSend(selection: List = listOf()) { progressWrapper.visible = true progressWrapper.animate() .setStartDelay(300) @@ -256,7 +258,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) { .alpha(1f) sharedViewModel - .send(selection) + .send(selection.filterIsInstance(RecipientSearchKey::class.java)) .subscribe( { result -> callback.onSentWithResult(result) }, { error -> callback.onSendError(error) }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt new file mode 100644 index 0000000000..c57443d7d1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt @@ -0,0 +1,155 @@ +package org.thoughtcrime.securesms.mediasend.v2.stories + +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.os.Bundle +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import android.widget.FrameLayout +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.setFragmentResult +import androidx.recyclerview.widget.RecyclerView +import org.signal.core.util.DimensionUnit +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment +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.sharing.ShareContact +import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter +import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel +import org.thoughtcrime.securesms.util.FeatureFlags + +class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() { + + companion object { + const val GROUP_STORY = "group-story" + const val RESULT_SET = "groups" + } + + private lateinit var confirmButton: View + private lateinit var selectedList: RecyclerView + private lateinit var backgroundHelper: View + private lateinit var divider: View + private lateinit var mediator: ContactSearchMediator + + private var animatorSet: AnimatorSet? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.cloneInContext(ContextThemeWrapper(inflater.context, themeResId)).inflate(R.layout.stories_choose_group_bottom_sheet, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view.minimumHeight = resources.displayMetrics.heightPixels + + val container = view.parent.parent.parent as FrameLayout + val bottomBar = LayoutInflater.from(requireContext()).inflate(R.layout.stories_choose_group_bottom_bar, container, true) + + confirmButton = bottomBar.findViewById(R.id.share_confirm) + selectedList = bottomBar.findViewById(R.id.selected_list) + backgroundHelper = bottomBar.findViewById(R.id.background_helper) + divider = bottomBar.findViewById(R.id.divider) + + val adapter = ShareSelectionAdapter() + selectedList.adapter = adapter + + confirmButton.setOnClickListener { + onDone() + } + + val contactRecycler: RecyclerView = view.findViewById(R.id.contact_recycler) + mediator = ContactSearchMediator( + this, + contactRecycler, + FeatureFlags.shareSelectionLimit() + ) { state -> + ContactSearchConfiguration.build { + query = state.query + + addSection( + ContactSearchConfiguration.Section.Groups( + includeHeader = false, + returnAsGroupStories = true + ) + ) + } + } + + mediator.getSelectionState().observe(viewLifecycleOwner) { state -> + adapter.submitList( + state.filterIsInstance(ContactSearchKey.Story::class.java) + .map { it.recipientId } + .mapIndexed { index, recipientId -> + ShareSelectionMappingModel( + ShareContact(recipientId), + index == 0 + ) + } + ) + + if (state.isEmpty()) { + animateOutBottomBar() + } else { + animateInBottomBar() + } + } + + val searchField: EditText = view.findViewById(R.id.search_field) + searchField.doAfterTextChanged { + mediator.onFilterChanged(it?.toString()) + } + } + + override fun onDestroyView() { + super.onDestroyView() + animatorSet?.cancel() + } + + private fun animateInBottomBar() { + animatorSet?.cancel() + animatorSet = AnimatorSet().apply { + playTogether( + ObjectAnimator.ofFloat(confirmButton, View.ALPHA, 1f), + ObjectAnimator.ofFloat(selectedList, View.TRANSLATION_Y, 0f), + ObjectAnimator.ofFloat(backgroundHelper, View.TRANSLATION_Y, 0f), + ObjectAnimator.ofFloat(divider, View.TRANSLATION_Y, 0f) + ) + start() + } + } + + private fun animateOutBottomBar() { + val translationY = DimensionUnit.DP.toPixels(48f) + + animatorSet?.cancel() + animatorSet = AnimatorSet().apply { + playTogether( + ObjectAnimator.ofFloat(confirmButton, View.ALPHA, 0f), + ObjectAnimator.ofFloat(selectedList, View.TRANSLATION_Y, translationY), + ObjectAnimator.ofFloat(backgroundHelper, View.TRANSLATION_Y, translationY), + ObjectAnimator.ofFloat(divider, View.TRANSLATION_Y, translationY) + ) + start() + } + } + + private fun onDone() { + setFragmentResult( + GROUP_STORY, + Bundle().apply { + putParcelableArrayList( + RESULT_SET, + ArrayList( + mediator.getSelectedContacts() + .filterIsInstance(ContactSearchKey.Story::class.java) + .map { it.recipientId } + ) + ) + } + ) + dismissAllowingStateLoss() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseStoryTypeBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseStoryTypeBottomSheet.kt new file mode 100644 index 0000000000..0db87d8e4e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseStoryTypeBottomSheet.kt @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.mediasend.v2.stories + +import org.signal.core.util.DimensionUnit +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment +import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon +import org.thoughtcrime.securesms.components.settings.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.components.settings.conversation.preferences.LargeIconClickPreference +import org.thoughtcrime.securesms.util.fragments.findListener + +class ChooseStoryTypeBottomSheet : DSLSettingsBottomSheetFragment( + layoutId = R.layout.dsl_settings_bottom_sheet_no_handle +) { + override fun bindAdapter(adapter: DSLSettingsAdapter) { + LargeIconClickPreference.register(adapter) + adapter.submitList(getConfiguration().toMappingModelList()) + } + + private fun getConfiguration(): DSLConfiguration { + return configure { + textPref( + title = DSLSettingsText.from( + stringId = R.string.ChooseStoryTypeBottomSheet__choose_your_story_type, + DSLSettingsText.CenterModifier, DSLSettingsText.Body1BoldModifier, DSLSettingsText.BoldModifier + ) + ) + + customPref( + LargeIconClickPreference.Model( + title = DSLSettingsText.from( + stringId = R.string.ChooseStoryTypeBottomSheet__new_private_story + ), + summary = DSLSettingsText.from( + stringId = R.string.ChooseStoryTypeBottomSheet__visible_only_to + ), + icon = DSLSettingsIcon.from( + R.drawable.ic_plus_24, + R.color.core_grey_15, + R.drawable.circle_tintable, + R.color.core_grey_80, + DimensionUnit.DP.toPixels(8f).toInt() + ), + onClick = { + dismissAllowingStateLoss() + findListener()?.onNewStoryClicked() + } + ) + ) + + customPref( + LargeIconClickPreference.Model( + title = DSLSettingsText.from( + stringId = R.string.ChooseStoryTypeBottomSheet__group_story + ), + summary = DSLSettingsText.from( + stringId = R.string.ChooseStoryTypeBottomSheet__share_to_an_existing_group + ), + icon = DSLSettingsIcon.from( + R.drawable.ic_group_outline_24, + R.color.core_grey_15, + R.drawable.circle_tintable, + R.color.core_grey_80, + DimensionUnit.DP.toPixels(8f).toInt() + ), + onClick = { + dismissAllowingStateLoss() + findListener()?.onGroupStoryClicked() + } + ) + ) + } + } + + interface Callback { + fun onNewStoryClicked() + fun onGroupStoryClicked() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/AutoSizeEmojiEditText.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/AutoSizeEmojiEditText.kt new file mode 100644 index 0000000000..665ddd2e30 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/AutoSizeEmojiEditText.kt @@ -0,0 +1,151 @@ +package org.thoughtcrime.securesms.mediasend.v2.text + +import android.content.Context +import android.text.Editable +import android.text.TextWatcher +import android.util.AttributeSet +import android.util.TypedValue +import androidx.core.view.doOnNextLayout +import org.signal.core.util.DimensionUnit +import org.signal.core.util.EditTextUtil +import org.thoughtcrime.securesms.components.emoji.EmojiEditText +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +class AutoSizeEmojiEditText @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : EmojiEditText(context, attrs) { + + private val maxTextSize = DimensionUnit.DP.toPixels(32f) + private val minTextSize = DimensionUnit.DP.toPixels(6f) + private var lowerBounds = minTextSize + private var upperBounds = maxTextSize + + private val sizeSet: MutableSet = mutableSetOf() + + private var beforeText: String? = null + private var beforeCursorPosition = 0 + + private val watcher: TextWatcher = object : TextWatcher { + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) = Unit + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + beforeText = s.toString() + beforeCursorPosition = start + } + + override fun afterTextChanged(s: Editable) { + if (lineCount == 0) { + doOnNextLayout { + checkCountAndAddListener() + } + } else { + checkCountAndAddListener() + } + } + } + + init { + EditTextUtil.addGraphemeClusterLimitFilter(this, 700) + addTextChangedListener(watcher) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + if (isInEditMode) return + + if (checkCountAndAddListener()) { + // TODO [stories] infinite measure loop when font change pushes us over the line count limit + measure(widthMeasureSpec, heightMeasureSpec) + return + } + + try { + val operation = getNextAutoSizeOperation() + val newSize = when (operation) { + AutoSizeOperation.INCREASE -> { + lowerBounds = textSize + val midpoint = abs(lowerBounds - upperBounds) / 2f + lowerBounds + min(maxTextSize, midpoint) + } + AutoSizeOperation.DECREASE -> { + upperBounds = textSize + val midpoint = abs(lowerBounds - upperBounds) / 2f + lowerBounds + max(minTextSize, midpoint) + } + AutoSizeOperation.NONE -> return + } + + if (abs(upperBounds - lowerBounds) < 1f) { + setTextSize(TypedValue.COMPLEX_UNIT_PX, lowerBounds) + return + } else if (sizeSet.add(newSize) || operation == AutoSizeOperation.INCREASE) { + setTextSize(TypedValue.COMPLEX_UNIT_PX, newSize) + measure(widthMeasureSpec, heightMeasureSpec) + } else { + return + } + } finally { + upperBounds = maxTextSize + lowerBounds = minTextSize + sizeSet.clear() + } + } + + private fun getNextAutoSizeOperation(): AutoSizeOperation { + if (lineCount == 0) { + return AutoSizeOperation.NONE + } + + val availableHeight = measuredHeight - paddingTop - paddingBottom + if (availableHeight <= 0) { + return AutoSizeOperation.NONE + } + + val pixelsRequired = lineHeight * lineCount + + return if (pixelsRequired > availableHeight) { + if (textSize > minTextSize) { + AutoSizeOperation.DECREASE + } else { + AutoSizeOperation.NONE + } + } else if (pixelsRequired < availableHeight) { + if (textSize < maxTextSize) { + AutoSizeOperation.INCREASE + } else { + AutoSizeOperation.NONE + } + } else { + AutoSizeOperation.NONE + } + } + + private fun checkCountAndAddListener(): Boolean { + removeTextChangedListener(watcher) + + if (lineCount > 12) { + setText(beforeText) + setSelection(beforeCursorPosition) + addTextChangedListener(watcher) + return true + } + + if (getNextAutoSizeOperation() != AutoSizeOperation.NONE) { + requestLayout() + } + + addTextChangedListener(watcher) + return false + } + + private enum class AutoSizeOperation { + INCREASE, + DECREASE, + NONE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextAlignment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextAlignment.kt new file mode 100644 index 0000000000..1094f7964e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextAlignment.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.mediasend.v2.text + +import android.view.Gravity +import androidx.annotation.DrawableRes +import org.thoughtcrime.securesms.R + +enum class TextAlignment(val gravity: Int, @DrawableRes val icon: Int) { + START(Gravity.START or Gravity.CENTER_VERTICAL, R.drawable.ic_text_start), + CENTER(Gravity.CENTER, R.drawable.ic_text_center), + END(Gravity.END or Gravity.CENTER_VERTICAL, R.drawable.ic_text_end); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextAlignmentButton.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextAlignmentButton.kt new file mode 100644 index 0000000000..e76056c2fe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextAlignmentButton.kt @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.mediasend.v2.text + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import org.thoughtcrime.securesms.util.next + +typealias OnTextAlignmentChanged = (TextAlignment) -> Unit + +/** + * Allows the user to toggle between START / END / CENTER alignment for text in a story post. + */ +class TextAlignmentButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppCompatImageView(context, attrs) { + + private var textAlignment: TextAlignment = TextAlignment.CENTER + + var onAlignmentChangedListener: OnTextAlignmentChanged? = null + + init { + setImageResource(textAlignment.icon) + super.setOnClickListener { + setAlignment(textAlignment.next()) + } + } + + override fun setOnClickListener(l: OnClickListener?) { + throw UnsupportedOperationException() + } + + fun setAlignment(textAlignment: TextAlignment) { + if (textAlignment != this.textAlignment) { + this.textAlignment = textAlignment + setImageResource(textAlignment.icon) + onAlignmentChangedListener?.invoke(textAlignment) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextColorStyle.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextColorStyle.kt new file mode 100644 index 0000000000..2370522721 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextColorStyle.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.mediasend.v2.text + +import androidx.annotation.DrawableRes +import org.thoughtcrime.securesms.R + +enum class TextColorStyle(@DrawableRes val icon: Int) { + /** + * Transparent background. + */ + NO_BACKGROUND(R.drawable.ic_text_normal), + + /** + * White background, textColor foreground. + */ + NORMAL(R.drawable.ic_text_effect), + + /** + * textColor background with white foreground. + */ + INVERT(R.drawable.ic_text_effect); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextColorStyleButton.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextColorStyleButton.kt new file mode 100644 index 0000000000..ef3df393cb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextColorStyleButton.kt @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.mediasend.v2.text + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import org.thoughtcrime.securesms.util.next + +typealias OnTextColorStyleChanged = (TextColorStyle) -> Unit + +/** + * Allows the user to cycle between text and background styling for a text post + */ +class TextColorStyleButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppCompatImageView(context, attrs) { + + private var textColorStyle: TextColorStyle = TextColorStyle.NO_BACKGROUND + + var onTextColorStyleChanged: OnTextColorStyleChanged? = null + + init { + setImageResource(textColorStyle.icon) + super.setOnClickListener { + setTextColorStyle(textColorStyle.next()) + } + } + + override fun setOnClickListener(l: OnClickListener?) { + throw UnsupportedOperationException() + } + + fun setTextColorStyle(textColorStyle: TextColorStyle) { + if (textColorStyle != this.textColorStyle) { + this.textColorStyle = textColorStyle + setImageResource(textColorStyle.icon) + onTextColorStyleChanged?.invoke(textColorStyle) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextFontButton.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextFontButton.kt new file mode 100644 index 0000000000..cfd60bb9a1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextFontButton.kt @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.mediasend.v2.text + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import org.thoughtcrime.securesms.fonts.TextFont +import org.thoughtcrime.securesms.util.next + +typealias OnTextFontChanged = (TextFont) -> Unit + +/** + * Allows the user to cycle between fonts for a story text post + */ +class TextFontButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppCompatImageView(context, attrs) { + + private var textFont: TextFont = TextFont.REGULAR + + var onTextFontChanged: OnTextFontChanged? = null + + init { + setImageResource(textFont.icon) + super.setOnClickListener { + setTextFont(textFont.next()) + } + } + + override fun setOnClickListener(l: OnClickListener?) { + throw UnsupportedOperationException() + } + + fun setTextFont(textFont: TextFont) { + if (textFont != this.textFont) { + this.textFont = textFont + setImageResource(textFont.icon) + onTextFontChanged?.invoke(textFont) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryBackgroundColors.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryBackgroundColors.kt new file mode 100644 index 0000000000..2dc97cc48d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryBackgroundColors.kt @@ -0,0 +1,101 @@ +package org.thoughtcrime.securesms.mediasend.v2.text + +import org.thoughtcrime.securesms.conversation.colors.ChatColors + +object TextStoryBackgroundColors { + + private val backgroundColors: List = listOf( + ChatColors.forGradient( + id = ChatColors.Id.NotSet, + linearGradient = ChatColors.LinearGradient( + degrees = 191.41f, + colors = intArrayOf(0xFFF53844.toInt(), 0xFFF33845.toInt(), 0xFFEC3848.toInt(), 0xFFE2384C.toInt(), 0xFFD63851.toInt(), 0xFFC73857.toInt(), 0xFFB6385E.toInt(), 0xFFA43866.toInt(), 0xFF93376D.toInt(), 0xFF813775.toInt(), 0xFF70377C.toInt(), 0xFF613782.toInt(), 0xFF553787.toInt(), 0xFF4B378B.toInt(), 0xFF44378E.toInt(), 0xFF42378F.toInt()), + positions = floatArrayOf(0.2109f, 0.2168f, 0.2339f, 0.2611f, 0.2975f, 0.3418f, 0.3932f, 0.4506f, 0.5129f, 0.5791f, 0.6481f, 0.719f, 0.7907f, 0.8621f, 0.9322f, 1.0f) + ) + ), + ChatColors.forGradient( + id = ChatColors.Id.NotSet, + linearGradient = ChatColors.LinearGradient( + degrees = 192.04f, + colors = intArrayOf(0xFFF04CE6.toInt(), 0xFFEE4BE6.toInt(), 0xFFE54AE5.toInt(), 0xFFD949E5.toInt(), 0xFFC946E4.toInt(), 0xFFB644E3.toInt(), 0xFFA141E3.toInt(), 0xFF8B3FE2.toInt(), 0xFF743CE1.toInt(), 0xFF5E39E0.toInt(), 0xFF4936DF.toInt(), 0xFF3634DE.toInt(), 0xFF2632DD.toInt(), 0xFF1930DD.toInt(), 0xFF112FDD.toInt(), 0xFF0E2FDD.toInt()), + positions = floatArrayOf(0.0f, 0.0807f, 0.1554f, 0.225f, 0.2904f, 0.3526f, 0.4125f, 0.471f, 0.529f, 0.5875f, 0.6474f, 0.7096f, 0.775f, 0.8446f, 0.9193f, 1.0f) + ), + ), + ChatColors.forGradient( + id = ChatColors.Id.NotSet, + linearGradient = ChatColors.LinearGradient( + degrees = 175.46f, + colors = intArrayOf(0xFFFFC044.toInt(), 0xFFFE5C38.toInt()), + positions = floatArrayOf(0f, 1f) + ) + ), + ChatColors.forGradient( + id = ChatColors.Id.NotSet, + linearGradient = ChatColors.LinearGradient( + degrees = 180f, + colors = intArrayOf(0xFF0093E9.toInt(), 0xFF0294E9.toInt(), 0xFF0696E7.toInt(), 0xFF0D99E5.toInt(), 0xFF169EE3.toInt(), 0xFF21A3E0.toInt(), 0xFF2DA8DD.toInt(), 0xFF3AAEDA.toInt(), 0xFF46B5D6.toInt(), 0xFF53BBD3.toInt(), 0xFF5FC0D0.toInt(), 0xFF6AC5CD.toInt(), 0xFF73CACB.toInt(), 0xFF7ACDC9.toInt(), 0xFF7ECFC7.toInt(), 0xFF80D0C7.toInt()), + positions = floatArrayOf(0.0f, 0.0807f, 0.1554f, 0.225f, 0.2904f, 0.3526f, 0.4125f, 0.471f, 0.529f, 0.5875f, 0.6474f, 0.7096f, 0.775f, 0.8446f, 0.9193f, 1.0f) + ) + ), + ChatColors.forGradient( + id = ChatColors.Id.NotSet, + linearGradient = ChatColors.LinearGradient( + degrees = 180f, + colors = intArrayOf(0xFF65CDAC.toInt(), 0xFF64CDAB.toInt(), 0xFF60CBA8.toInt(), 0xFF5BC8A3.toInt(), 0xFF55C49D.toInt(), 0xFF4DC096.toInt(), 0xFF45BB8F.toInt(), 0xFF3CB687.toInt(), 0xFF33B17F.toInt(), 0xFF2AAC76.toInt(), 0xFF21A76F.toInt(), 0xFF1AA268.toInt(), 0xFF139F62.toInt(), 0xFF0E9C5E.toInt(), 0xFF0B9A5B.toInt(), 0xFF0A995A.toInt()), + positions = floatArrayOf(0.0f, 0.0807f, 0.1554f, 0.225f, 0.2904f, 0.3526f, 0.4125f, 0.471f, 0.529f, 0.5875f, 0.6474f, 0.7096f, 0.775f, 0.8446f, 0.9193f, 1.0f) + ) + ), + ChatColors.forColor( + id = ChatColors.Id.NotSet, + color = 0xFFFFC153.toInt() + ), + ChatColors.forColor( + id = ChatColors.Id.NotSet, + color = 0xFFCCBD33.toInt() + ), + ChatColors.forColor( + id = ChatColors.Id.NotSet, + color = 0xFF84712E.toInt() + ), + ChatColors.forColor( + id = ChatColors.Id.NotSet, + color = 0xFF09B37B.toInt() + ), + ChatColors.forColor( + id = ChatColors.Id.NotSet, + color = 0xFF8B8BF9.toInt() + ), + ChatColors.forColor( + id = ChatColors.Id.NotSet, + color = 0xFF5151F6.toInt() + ), + ChatColors.forColor( + id = ChatColors.Id.NotSet, + color = 0xFFF76E6E.toInt() + ), + ChatColors.forColor( + id = ChatColors.Id.NotSet, + color = 0xFFC84641.toInt() + ), + ChatColors.forColor( + id = ChatColors.Id.NotSet, + color = 0xFFC6C4A5.toInt() + ), + ChatColors.forColor( + id = ChatColors.Id.NotSet, + color = 0xFFA49595.toInt() + ), + ChatColors.forColor( + id = ChatColors.Id.NotSet, + color = 0xFF292929.toInt() + ), + ) + + fun getInitialBackgroundColor(): ChatColors = backgroundColors.first() + + fun cycleBackgroundColor(chatColors: ChatColors): ChatColors { + val indexOfNextColor = (backgroundColors.indexOf(chatColors) + 1) % backgroundColors.size + + return backgroundColors[indexOfNextColor] + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt new file mode 100644 index 0000000000..c2299f4470 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt @@ -0,0 +1,125 @@ +package org.thoughtcrime.securesms.mediasend.v2.text + +import android.os.Bundle +import android.view.View +import androidx.appcompat.widget.AppCompatImageView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.drawToBitmap +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository +import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel +import org.thoughtcrime.securesms.mediasend.v2.HudCommand +import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel +import org.thoughtcrime.securesms.stories.StoryTextPostView +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creation_fragment), TextStoryPostTextEntryFragment.Callback { + + private lateinit var scene: ConstraintLayout + private lateinit var linkButton: View + private lateinit var backgroundButton: AppCompatImageView + private lateinit var send: View + private lateinit var storyTextPostView: StoryTextPostView + + private val sharedViewModel: MediaSelectionViewModel by viewModels( + ownerProducer = { + requireActivity() + } + ) + + private val viewModel: TextStoryPostCreationViewModel by viewModels( + ownerProducer = { + requireActivity() + } + ) + + private val linkPreviewViewModel: LinkPreviewViewModel by viewModels( + ownerProducer = { + requireActivity() + }, + factoryProducer = { + LinkPreviewViewModel.Factory(LinkPreviewRepository()) + } + ) + + private val lifecycleDisposable = LifecycleDisposable() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + scene = view.findViewById(R.id.scene) + linkButton = view.findViewById(R.id.add_link) + backgroundButton = view.findViewById(R.id.background_selector) + send = view.findViewById(R.id.send) + storyTextPostView = view.findViewById(R.id.story_text_post) + + storyTextPostView.showCloseButton() + + lifecycleDisposable.bindTo(viewLifecycleOwner) + lifecycleDisposable += sharedViewModel.hudCommands.subscribe { + if (it == HudCommand.GoToCapture) { + findNavController().popBackStack() + } + } + + viewModel.typeface.observe(viewLifecycleOwner) { typeface -> + storyTextPostView.setTypeface(typeface) + } + + viewModel.state.observe(viewLifecycleOwner) { state -> + backgroundButton.background = state.backgroundColor.chatBubbleMask + storyTextPostView.bindFromCreationState(state) + + if (state.linkPreviewUri != null) { + linkPreviewViewModel.onTextChanged(requireContext(), state.linkPreviewUri, 0, state.linkPreviewUri.lastIndex) + } else { + linkPreviewViewModel.onSend() + } + + val canSend = state.body.isNotEmpty() || !state.linkPreviewUri.isNullOrEmpty() + send.alpha = if (canSend) 1f else 0.5f + send.isEnabled = canSend + } + + linkPreviewViewModel.linkPreviewState.observe(viewLifecycleOwner) { state -> + storyTextPostView.bindLinkPreviewState(state, View.GONE) + storyTextPostView.postAdjustLinkPreviewTranslationY() + } + + storyTextPostView.setTextViewClickListener { + storyTextPostView.hidePostContent() + TextStoryPostTextEntryFragment().show(childFragmentManager, null) + } + + backgroundButton.setOnClickListener { + viewModel.cycleBackgroundColor() + } + + linkButton.setOnClickListener { + TextStoryPostLinkEntryFragment().show(childFragmentManager, null) + } + + storyTextPostView.setLinkPreviewCloseListener { + viewModel.setLinkPreview("") + } + + send.setOnClickListener { + storyTextPostView.hideCloseButton() + viewModel.setBitmap(storyTextPostView.drawToBitmap()) + findNavController().safeNavigate(R.id.action_textStoryPostCreationFragment_to_textStoryPostSendFragment) + } + } + + override fun onResume() { + super.onResume() + storyTextPostView.showCloseButton() + } + + override fun onTextStoryPostTextEntryDismissed() { + storyTextPostView.showPostContent() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationState.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationState.kt new file mode 100644 index 0000000000..ef9654d93f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationState.kt @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.mediasend.v2.text + +import android.graphics.Color +import android.os.Parcelable +import androidx.annotation.ColorInt +import androidx.annotation.IntRange +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import org.signal.core.util.DimensionUnit +import org.thoughtcrime.securesms.conversation.colors.ChatColors +import org.thoughtcrime.securesms.fonts.TextFont +import org.thoughtcrime.securesms.scribbles.HSVColorSlider +import org.thoughtcrime.securesms.util.FeatureFlags + +@Parcelize +data class TextStoryPostCreationState( + val body: CharSequence = "", + val textColor: Int = HSVColorSlider.getLastColor(), + val textColorStyle: TextColorStyle = TextColorStyle.NO_BACKGROUND, + val textAlignment: TextAlignment = if (FeatureFlags.storiesTextFunctions()) TextAlignment.START else TextAlignment.CENTER, + val textSize: Float = DimensionUnit.DP.toPixels(32f), + val textFont: TextFont = TextFont.REGULAR, + @IntRange(from = 0, to = 100) val textScale: Int = 50, + val backgroundColor: ChatColors = TextStoryBackgroundColors.getInitialBackgroundColor(), + val linkPreviewUri: String? = null, +) : Parcelable { + + @ColorInt + @IgnoredOnParcel + val textForegroundColor: Int = when (textColorStyle) { + TextColorStyle.NO_BACKGROUND -> textColor + TextColorStyle.NORMAL -> textColor + TextColorStyle.INVERT -> Color.WHITE + } + + @ColorInt + @IgnoredOnParcel + val textBackgroundColor: Int = when (textColorStyle) { + TextColorStyle.NO_BACKGROUND -> Color.TRANSPARENT + TextColorStyle.NORMAL -> Color.WHITE + TextColorStyle.INVERT -> textColor + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationViewModel.kt new file mode 100644 index 0000000000..4a30503542 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationViewModel.kt @@ -0,0 +1,149 @@ +package org.thoughtcrime.securesms.mediasend.v2.text + +import android.graphics.Bitmap +import android.graphics.Typeface +import android.os.Bundle +import androidx.annotation.ColorInt +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.Subject +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.fonts.Fonts +import org.thoughtcrime.securesms.fonts.TextFont +import org.thoughtcrime.securesms.util.FutureTaskListener +import org.thoughtcrime.securesms.util.livedata.Store +import java.util.Locale +import java.util.concurrent.ExecutionException + +class TextStoryPostCreationViewModel : ViewModel() { + + private val store = Store(TextStoryPostCreationState()) + private val textFontSubject: Subject = BehaviorSubject.create() + private val disposables = CompositeDisposable() + + private val internalThumbnail = MutableLiveData() + val thumbnail: LiveData = internalThumbnail + + private val internalTypeface = MutableLiveData() + + val state: LiveData = store.stateLiveData + val typeface: LiveData = internalTypeface + + init { + textFontSubject.onNext(store.state.textFont) + + textFontSubject + .observeOn(Schedulers.io()) + .distinctUntilChanged() + .map { Fonts.resolveFont(ApplicationDependencies.getApplication(), Locale.getDefault(), it) } + .switchMap { + when (it) { + is Fonts.FontResult.Async -> asyncFontEmitter(it) + is Fonts.FontResult.Immediate -> Observable.just(it.typeface) + } + } + .subscribeOn(Schedulers.io()) + .subscribe { + internalTypeface.postValue(it) + } + } + + fun setBitmap(bitmap: Bitmap) { + internalThumbnail.value?.recycle() + internalThumbnail.value = bitmap + } + + private fun asyncFontEmitter(async: Fonts.FontResult.Async): Observable { + return Observable.create { + it.onNext(async.placeholder) + + val listener = object : FutureTaskListener { + override fun onSuccess(result: Typeface) { + it.onNext(result) + it.onComplete() + } + + override fun onFailure(exception: ExecutionException?) { + Log.w(TAG, "Failed to load remote font.", exception) + it.onComplete() + } + } + + it.setCancellable { + async.future.removeListener(listener) + } + + async.future.addListener(listener) + } + } + + override fun onCleared() { + disposables.clear() + thumbnail.value?.recycle() + } + + fun saveToInstanceState(outState: Bundle) { + outState.putParcelable(TEXT_STORY_INSTANCE_STATE, store.state) + } + + fun restoreFromInstanceState(inState: Bundle) { + if (inState.containsKey(TEXT_STORY_INSTANCE_STATE)) { + val state: TextStoryPostCreationState = inState.getParcelable(TEXT_STORY_INSTANCE_STATE)!! + textFontSubject.onNext(store.state.textFont) + store.update { state } + } + } + + fun getBody(): CharSequence { + return store.state.body + } + + @ColorInt + fun getTextColor(): Int { + return store.state.textColor + } + + fun setTextColor(@ColorInt textColor: Int) { + store.update { it.copy(textColor = textColor) } + } + + fun setBody(body: CharSequence) { + store.update { it.copy(body = body) } + } + + fun setAlignment(textAlignment: TextAlignment) { + store.update { it.copy(textAlignment = textAlignment) } + } + + fun setTextScale(scale: Int) { + store.update { it.copy(textScale = scale) } + } + + fun setTextColorStyle(textColorStyle: TextColorStyle) { + store.update { it.copy(textColorStyle = textColorStyle) } + } + + fun setTextFont(textFont: TextFont) { + textFontSubject.onNext(textFont) + store.update { it.copy(textFont = textFont) } + } + + fun cycleBackgroundColor() { + store.update { it.copy(backgroundColor = TextStoryBackgroundColors.cycleBackgroundColor(it.backgroundColor)) } + } + + fun setLinkPreview(url: String) { + store.update { it.copy(linkPreviewUri = url) } + } + + companion object { + private val TAG = Log.tag(TextStoryPostCreationViewModel::class.java) + private const val TEXT_STORY_INSTANCE_STATE = "text.story.instance.state" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostLinkEntryFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostLinkEntryFragment.kt new file mode 100644 index 0000000000..6437539e9a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostLinkEntryFragment.kt @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.mediasend.v2.text + +import android.content.DialogInterface +import android.os.Bundle +import android.view.View +import android.widget.EditText +import androidx.constraintlayout.widget.Group +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.viewModels +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment +import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository +import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel +import org.thoughtcrime.securesms.stories.StoryLinkPreviewView +import org.thoughtcrime.securesms.util.visible + +class TextStoryPostLinkEntryFragment : KeyboardEntryDialogFragment( + contentLayoutId = R.layout.stories_text_post_link_entry_fragment +) { + + private val linkPreviewViewModel: LinkPreviewViewModel by viewModels( + factoryProducer = { LinkPreviewViewModel.Factory(LinkPreviewRepository()) } + ) + + private val viewModel: TextStoryPostCreationViewModel by viewModels( + ownerProducer = { + requireActivity() + } + ) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val input: EditText = view.findViewById(R.id.input) + val linkPreview: StoryLinkPreviewView = view.findViewById(R.id.link_preview) + val confirmButton: View = view.findViewById(R.id.confirm_button) + + val shareALinkGroup: Group = view.findViewById(R.id.share_a_link_group) + + input.addTextChangedListener( + afterTextChanged = { + linkPreviewViewModel.onTextChanged(requireContext(), it!!.toString(), input.selectionStart, input.selectionEnd) + } + ) + + confirmButton.setOnClickListener { + if (linkPreviewViewModel.hasLinkPreview()) { + viewModel.setLinkPreview(linkPreviewViewModel.linkPreviewState.value!!.linkPreview.get().url) + } + + dismissAllowingStateLoss() + } + + linkPreviewViewModel.linkPreviewState.observe(viewLifecycleOwner) { state -> + linkPreview.bind(state) + shareALinkGroup.visible = !state.isLoading && !state.linkPreview.isPresent && state.error == null + confirmButton.isEnabled = state.linkPreview.isPresent + } + } + + override fun onDismiss(dialog: DialogInterface) { + linkPreviewViewModel.onSend() + super.onDismiss(dialog) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostTextEntryFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostTextEntryFragment.kt new file mode 100644 index 0000000000..113ae23c33 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostTextEntryFragment.kt @@ -0,0 +1,328 @@ +package org.thoughtcrime.securesms.mediasend.v2.text + +import android.animation.Animator +import android.animation.ObjectAnimator +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.graphics.Color +import android.os.Bundle +import android.text.InputFilter +import android.text.Spanned +import android.text.TextUtils +import android.view.MotionEvent +import android.view.View +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.SeekBar +import androidx.appcompat.content.res.AppCompatResources +import androidx.appcompat.widget.AppCompatSeekBar +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.view.updateLayoutParams +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.viewModels +import androidx.transition.TransitionManager +import com.airbnb.lottie.SimpleColorFilter +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment +import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations +import org.thoughtcrime.securesms.scribbles.HSVColorSlider +import org.thoughtcrime.securesms.scribbles.HSVColorSlider.getColor +import org.thoughtcrime.securesms.scribbles.HSVColorSlider.setColor +import org.thoughtcrime.securesms.scribbles.HSVColorSlider.setUpForColor +import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.fragments.findListener +import java.util.Locale + +/** + * Allows user to enter and style the text of a text-based story post + */ +class TextStoryPostTextEntryFragment : KeyboardEntryDialogFragment( + contentLayoutId = R.layout.stories_text_post_text_entry_fragment +) { + + private val viewModel: TextStoryPostCreationViewModel by viewModels( + ownerProducer = { + requireActivity() + } + ) + + private lateinit var scene: ConstraintLayout + private lateinit var input: EditText + private lateinit var confirmButton: View + private lateinit var colorBar: AppCompatSeekBar + private lateinit var colorIndicator: ImageView + private lateinit var alignmentButton: TextAlignmentButton + private lateinit var scaleBar: AppCompatSeekBar + private lateinit var backgroundButton: TextColorStyleButton + private lateinit var fontButton: TextFontButton + + private lateinit var fadeableViews: List + + private var colorIndicatorAlphaAnimator: Animator? = null + private var bufferFilter = BufferFilter() + private var allCapsFilter = InputFilter.AllCaps() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + initializeViews(view) + initializeInput() + initializeAlignmentButton() + initializeColorBar() + initializeConfirmButton() + initializeWidthBar() + initializeBackgroundButton() + initializeFontButton() + initializeViewModel() + + view.setOnClickListener { dismissAllowingStateLoss() } + } + + private fun initializeViews(view: View) { + scene = view.findViewById(R.id.scene) + input = view.findViewById(R.id.input) + confirmButton = view.findViewById(R.id.confirm) + colorBar = view.findViewById(R.id.color_bar) + colorIndicator = view.findViewById(R.id.color_indicator) + alignmentButton = view.findViewById(R.id.alignment_button) + fontButton = view.findViewById(R.id.font_button) + scaleBar = view.findViewById(R.id.width_bar) + backgroundButton = view.findViewById(R.id.background_button) + + fadeableViews = listOf( + confirmButton, + fontButton, + backgroundButton + ) + + if (FeatureFlags.storiesTextFunctions()) { + fadeableViews = fadeableViews + alignmentButton + alignmentButton.visibility = View.VISIBLE + scaleBar.visibility = View.VISIBLE + } + } + + private fun initializeInput() { + input.filters = input.filters + bufferFilter + input.doOnTextChanged { _, _, _, _ -> + presentHint() + } + input.setText(viewModel.getBody()) + } + + private fun presentHint() { + if (TextUtils.isEmpty(input.text)) { + if (input.filters.contains(allCapsFilter)) { + input.hint = getString(R.string.TextStoryPostTextEntryFragment__add_text).toUpperCase(Locale.getDefault()) + } else { + input.setHint(R.string.TextStoryPostTextEntryFragment__add_text) + } + } else { + input.hint = "" + } + } + + private fun initializeBackgroundButton() { + backgroundButton.onTextColorStyleChanged = { + viewModel.setTextColorStyle(it) + } + } + + private fun initializeFontButton() { + fontButton.onTextFontChanged = { + viewModel.setTextFont(it) + } + } + + private fun initializeColorBar() { + colorIndicator.background = AppCompatResources.getDrawable(requireContext(), R.drawable.ic_color_preview) + colorBar.setUpForColor( + thumbBorderColor = Color.WHITE, + onColorChanged = { + colorIndicator.drawable.colorFilter = SimpleColorFilter(colorBar.getColor()) + colorIndicator.translationX = (colorBar.thumb.bounds.left.toFloat() + ViewUtil.dpToPx(16)) + viewModel.setTextColor(colorBar.getColor()) + }, + onDragStart = { + colorIndicatorAlphaAnimator?.end() + colorIndicatorAlphaAnimator = ObjectAnimator.ofFloat(colorIndicator, "alpha", colorIndicator.alpha, 1f) + colorIndicatorAlphaAnimator?.duration = 150L + colorIndicatorAlphaAnimator?.start() + + TransitionManager.endTransitions(scene) + + val constraintSet = ConstraintSet() + constraintSet.clone(scene) + fadeableViews.forEach { + constraintSet.setVisibility(it.id, ConstraintSet.INVISIBLE) + } + constraintSet.applyTo(scene) + + TransitionManager.beginDelayedTransition(scene) + constraintSet.connect(colorBar.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) + constraintSet.connect(colorBar.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) + constraintSet.applyTo(scene) + }, + onDragEnd = { + colorIndicatorAlphaAnimator?.end() + colorIndicatorAlphaAnimator = ObjectAnimator.ofFloat(colorIndicator, "alpha", colorIndicator.alpha, 0f) + colorIndicatorAlphaAnimator?.duration = 150L + colorIndicatorAlphaAnimator?.start() + + TransitionManager.endTransitions(scene) + TransitionManager.beginDelayedTransition(scene) + + val constraintSet = ConstraintSet() + constraintSet.clone(scene) + fadeableViews.forEach { + constraintSet.setVisibility(it.id, ConstraintSet.VISIBLE) + } + constraintSet.connect(colorBar.id, ConstraintSet.START, backgroundButton.id, ConstraintSet.END) + constraintSet.connect(colorBar.id, ConstraintSet.END, fontButton.id, ConstraintSet.START) + constraintSet.applyTo(scene) + } + ) + + colorBar.setColor(viewModel.getTextColor()) + } + + private fun initializeConfirmButton() { + confirmButton.setOnClickListener { + dismissAllowingStateLoss() + } + } + + private fun initializeAlignmentButton() { + alignmentButton.onAlignmentChangedListener = { alignment -> + viewModel.setAlignment(alignment) + } + } + + private fun initializeViewModel() { + viewModel.typeface.observe(viewLifecycleOwner) { typeface -> + input.typeface = typeface + } + + viewModel.state.observe(viewLifecycleOwner) { state -> + input.setTextColor(state.textForegroundColor) + input.setHintTextColor(state.textForegroundColor) + + if (state.textBackgroundColor == Color.TRANSPARENT) { + input.background = null + } else { + input.background = AppCompatResources.getDrawable(requireContext(), R.drawable.rounded_rectangle_secondary_18)?.apply { + colorFilter = SimpleColorFilter(state.textBackgroundColor) + } + } + + alignmentButton.setAlignment(state.textAlignment) + scaleBar.progress = state.textScale + val scale = TextStoryScale.convertToScale(state.textScale) + input.scaleX = scale + input.scaleY = scale + input.gravity = state.textAlignment.gravity + input.updateLayoutParams { + gravity = state.textAlignment.gravity + } + + if (state.textFont.isAllCaps && !input.filters.contains(allCapsFilter)) { + input.filters = input.filters + allCapsFilter + val selectionStart = input.selectionStart + val selectionEnd = input.selectionEnd + val text = bufferFilter.text + bufferFilter.text = "" + input.setText(text) + input.setSelection(selectionStart, selectionEnd) + } else if (!state.textFont.isAllCaps && input.filters.contains(allCapsFilter)) { + input.filters = (input.filters.toList() - allCapsFilter).toTypedArray() + val selectionStart = input.selectionStart + val selectionEnd = input.selectionEnd + val text = bufferFilter.text + bufferFilter.text = "" + input.setText(text) + input.setSelection(selectionStart, selectionEnd) + } + + backgroundButton.setTextColorStyle(state.textColorStyle) + fontButton.setTextFont(state.textFont) + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun initializeWidthBar() { + scaleBar.progressDrawable = AppCompatResources.getDrawable(requireContext(), R.drawable.ic_width_slider_bg) + scaleBar.thumb = HSVColorSlider.createThumbDrawable(Color.WHITE) + scaleBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + viewModel.setTextScale(progress) + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit + }) + + scaleBar.setOnTouchListener { v, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + animateWidthBarIn() + } else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { + animateWidthBarOut() + } + + v.onTouchEvent(event) + } + } + + private fun animateWidthBarIn() { + scaleBar.animate() + .setDuration(250L) + .setInterpolator(MediaAnimations.interpolator) + .translationX(ViewUtil.dpToPx(36).toFloat()) + } + + private fun animateWidthBarOut() { + scaleBar.animate() + .setDuration(250L) + .setInterpolator(MediaAnimations.interpolator) + .translationX(0f) + } + + override fun onResume() { + super.onResume() + ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(input) + } + + override fun onPause() { + super.onPause() + ViewUtil.hideKeyboard(requireContext(), input) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + viewModel.setBody(bufferFilter.text) + findListener()?.onTextStoryPostTextEntryDismissed() + } + + interface Callback { + fun onTextStoryPostTextEntryDismissed() + } + + /** + * BufferFilter records the input to a text field such that a later filter can capitalize text without the buffer + * being modified. + */ + class BufferFilter : InputFilter { + + var text: CharSequence = "" + + override fun filter(source: CharSequence?, start: Int, end: Int, dest: Spanned?, dstart: Int, dend: Int): CharSequence? { + text = if (source.isNullOrEmpty()) { + text.removeRange(dstart, dend) + } else { + text.replaceRange(dstart, dend, source.subSequence(start, end)) + } + + return null + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryScale.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryScale.kt new file mode 100644 index 0000000000..87bd1cf799 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryScale.kt @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.mediasend.v2.text + +object TextStoryScale { + fun convertToScale(textScale: Int): Float { + if (textScale < 0) { + return 1f + } + + val minimumScale = 0.5f + val maximumScale = 1.5f + val scaleRange = maximumScale - minimumScale + + val percent = textScale / 100f + val scale = scaleRange * percent + minimumScale + + return scale + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt new file mode 100644 index 0000000000..d2ea628808 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt @@ -0,0 +1,220 @@ +package org.thoughtcrime.securesms.mediasend.v2.text.send + +import android.os.Bundle +import android.view.View +import android.widget.EditText +import android.widget.ImageView +import androidx.appcompat.widget.Toolbar +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResultListener +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.RecyclerView +import org.signal.core.util.DimensionUnit +import org.thoughtcrime.securesms.R +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.linkpreview.LinkPreviewViewModel +import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet +import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet +import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationViewModel +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter +import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel +import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs +import org.thoughtcrime.securesms.stories.settings.create.CreateStoryWithViewersFragment +import org.thoughtcrime.securesms.stories.settings.hide.HideStoryFromDialogFragment +import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil + +class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragment), ChooseStoryTypeBottomSheet.Callback { + + private lateinit var shareListWrapper: View + private lateinit var shareSelectionRecyclerView: RecyclerView + private lateinit var shareConfirmButton: View + + private val shareSelectionAdapter = ShareSelectionAdapter() + private val disposables = LifecycleDisposable() + + private lateinit var contactSearchMediator: ContactSearchMediator + + private val viewModel: TextStoryPostSendViewModel by viewModels( + factoryProducer = { + TextStoryPostSendViewModel.Factory(TextStoryPostSendRepository()) + } + ) + + private val creationViewModel: TextStoryPostCreationViewModel by viewModels( + ownerProducer = { + requireActivity() + } + ) + + private val linkPreviewViewModel: LinkPreviewViewModel by viewModels( + ownerProducer = { + requireActivity() + } + ) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val toolbar: Toolbar = view.findViewById(R.id.toolbar) + val viewPort: ImageView = view.findViewById(R.id.preview_viewport) + val searchField: EditText = view.findViewById(R.id.search_field) + + toolbar.setNavigationOnClickListener { + findNavController().popBackStack() + } + + shareListWrapper = view.findViewById(R.id.list_wrapper) + shareConfirmButton = view.findViewById(R.id.share_confirm) + shareSelectionRecyclerView = view.findViewById(R.id.selected_list) + shareSelectionRecyclerView.adapter = shareSelectionAdapter + + disposables.bindTo(viewLifecycleOwner) + + creationViewModel.thumbnail.observe(viewLifecycleOwner) { bitmap -> + viewPort.setImageBitmap(bitmap) + } + + shareConfirmButton.setOnClickListener { + if (viewModel.isFirstSendToAStory(contactSearchMediator.getSelectedContacts())) { + StoryDialogs.guardWithAddToYourStoryDialog( + context = requireContext(), + onAddToStory = { send() }, + onEditViewers = { + viewModel.onSendCancelled() + HideStoryFromDialogFragment().show(childFragmentManager, null) + }, + onCancel = { + viewModel.onSendCancelled() + } + ) + } else { + send() + } + } + + searchField.doAfterTextChanged { + contactSearchMediator.onFilterChanged(it?.toString()) + } + + setFragmentResultListener(CreateStoryWithViewersFragment.REQUEST_KEY) { _, bundle -> + val recipientId: RecipientId = bundle.getParcelable(CreateStoryWithViewersFragment.STORY_RECIPIENT)!! + contactSearchMediator.setKeysSelected(setOf(ContactSearchKey.Story(recipientId))) + contactSearchMediator.onFilterChanged("") + } + + setFragmentResultListener(ChooseGroupStoryBottomSheet.GROUP_STORY) { _, bundle -> + val groups: Set = bundle.getParcelableArrayList(ChooseGroupStoryBottomSheet.RESULT_SET)?.toSet() ?: emptySet() + val keys: Set = groups.map { ContactSearchKey.Story(it) }.toSet() + contactSearchMediator.addToVisibleGroupStories(keys) + contactSearchMediator.onFilterChanged("") + contactSearchMediator.setKeysSelected(keys) + } + + val contactsRecyclerView: RecyclerView = view.findViewById(R.id.contacts_container) + contactSearchMediator = ContactSearchMediator(this, contactsRecyclerView, FeatureFlags.shareSelectionLimit()) { contactSearchState -> + 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 = ContactSearchConfiguration.TransportType.PUSH, + includeSelf = true + ) + ) + + addSection( + ContactSearchConfiguration.Section.Groups( + includeHeader = true + ) + ) + } + } + + contactSearchMediator.getSelectionState().observe(viewLifecycleOwner) { selection -> + shareSelectionAdapter.submitList(selection.mapIndexed { index, contact -> ShareSelectionMappingModel(contact.requireShareContact(), index == 0) }) + if (selection.isNotEmpty()) { + animateInSelection() + } else { + animateOutSelection() + } + } + + val saveStateAndSelection = LiveDataUtil.combineLatest(viewModel.state, contactSearchMediator.getSelectionState(), ::Pair) + saveStateAndSelection.observe(viewLifecycleOwner) { (state, selection) -> + when (state) { + TextStoryPostSendState.INIT -> shareConfirmButton.isEnabled = selection.isNotEmpty() + TextStoryPostSendState.SENDING -> shareConfirmButton.isEnabled = false + TextStoryPostSendState.SENT -> requireActivity().finish() + } + } + } + + private fun send() { + shareConfirmButton.isEnabled = false + + val textStoryPostCreationState = creationViewModel.state.value + val linkPreviewState = linkPreviewViewModel.linkPreviewState.value + + viewModel.onSend(contactSearchMediator.getSelectedContacts(), textStoryPostCreationState!!, linkPreviewState!!) + } + + private fun animateInSelection() { + shareListWrapper.animate() + .alpha(1f) + .translationY(0f) + shareConfirmButton.animate() + .alpha(1f) + } + + private fun animateOutSelection() { + shareListWrapper.animate() + .alpha(0f) + .translationY(DimensionUnit.DP.toPixels(48f)) + shareConfirmButton.animate() + .alpha(0f) + } + + 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) + } + } + + override fun onNewStoryClicked() { + findNavController().navigate(R.id.action_textStoryPostSendFragment_to_newStory) + } + + override fun onGroupStoryClicked() { + ChooseGroupStoryBottomSheet().show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt new file mode 100644 index 0000000000..ba734f42b8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.mediasend.v2.text.send + +import io.reactivex.rxjava3.core.Completable +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.linkpreview.LinkPreview +import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationState + +class TextStoryPostSendRepository { + + fun isFirstSendToStory(shareContacts: Set): Boolean { + if (SignalStore.storyValues().userHasAddedToAStory) { + return false + } + + return shareContacts.any { it is ContactSearchKey.Story } + } + + fun send(contactSearchKey: Set, textStoryPostCreationState: TextStoryPostCreationState, linkPreview: LinkPreview?): Completable { + // TODO [stories] -- Implementation once we know what text post messages look like. + return Completable.complete() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendState.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendState.kt new file mode 100644 index 0000000000..9755038142 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendState.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.mediasend.v2.text.send + +enum class TextStoryPostSendState { + INIT, + SENDING, + SENT +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendViewModel.kt new file mode 100644 index 0000000000..aeb5b841c4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendViewModel.kt @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.mediasend.v2.text.send + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey +import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel +import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationState +import org.thoughtcrime.securesms.util.livedata.Store + +class TextStoryPostSendViewModel(private val repository: TextStoryPostSendRepository) : ViewModel() { + + private val store = Store(TextStoryPostSendState.INIT) + private val disposables = CompositeDisposable() + + val state: LiveData = store.stateLiveData + + override fun onCleared() { + disposables.clear() + } + + fun isFirstSendToAStory(contactSearchKeys: Set): Boolean { + store.update { + TextStoryPostSendState.SENDING + } + + return repository.isFirstSendToStory(contactSearchKeys) + } + + fun onSendCancelled() { + store.update { + TextStoryPostSendState.INIT + } + } + + fun onSend(contactSearchKeys: Set, textStoryPostCreationState: TextStoryPostCreationState, linkPreviewState: LinkPreviewViewModel.LinkPreviewState) { + store.update { + TextStoryPostSendState.SENDING + } + + disposables += repository.send(contactSearchKeys, textStoryPostCreationState, linkPreviewState.linkPreview.orNull()).subscribeBy( + onComplete = { + store.update { TextStoryPostSendState.SENT } + }, + onError = { + // TODO [stories] -- Error of some sort. + store.update { TextStoryPostSendState.INIT } + } + ) + } + + class Factory(private val repository: TextStoryPostSendRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(TextStoryPostSendViewModel(repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java b/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java index 248251e9ef..d3ae651cee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java @@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; import org.thoughtcrime.securesms.database.MessageSendLogDatabase; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.DistributionListId; import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; @@ -37,6 +38,7 @@ import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.push.DistributionId; @@ -76,6 +78,7 @@ public final class GroupSendUtil { * @param groupId The groupId of the group you're sending to, or null if you're sending to a collection of recipients not joined by a group. * @param isRecipientUpdate True if you've already sent this message to some recipients in the past, otherwise false. */ + @WorkerThread public static List sendResendableDataMessage(@NonNull Context context, @Nullable GroupId.V2 groupId, @NonNull List allTargets, @@ -85,7 +88,7 @@ public final class GroupSendUtil { @NonNull SignalServiceDataMessage message) throws IOException, UntrustedIdentityException { - return sendMessage(context, groupId, messageId, allTargets, isRecipientUpdate, DataSendOperation.resendable(message, contentHint, messageId), null); + return sendMessage(context, groupId, getDistributionId(groupId), messageId, allTargets, isRecipientUpdate, DataSendOperation.resendable(message, contentHint, messageId), null); } /** @@ -106,7 +109,7 @@ public final class GroupSendUtil { @NonNull SignalServiceDataMessage message) throws IOException, UntrustedIdentityException { - return sendMessage(context, groupId, null, allTargets, isRecipientUpdate, DataSendOperation.unresendable(message, contentHint), null); + return sendMessage(context, groupId, getDistributionId(groupId), null, allTargets, isRecipientUpdate, DataSendOperation.unresendable(message, contentHint), null); } /** @@ -123,7 +126,7 @@ public final class GroupSendUtil { @Nullable CancelationSignal cancelationSignal) throws IOException, UntrustedIdentityException { - return sendMessage(context, groupId, null, allTargets, false, new TypingSendOperation(message), cancelationSignal); + return sendMessage(context, groupId, getDistributionId(groupId), null, allTargets, false, new TypingSendOperation(message), cancelationSignal); } /** @@ -139,7 +142,43 @@ public final class GroupSendUtil { @NonNull SignalServiceCallMessage message) throws IOException, UntrustedIdentityException { - return sendMessage(context, groupId, null, allTargets, false, new CallSendOperation(message), null); + return sendMessage(context, groupId, getDistributionId(groupId), null, allTargets, false, new CallSendOperation(message), null); + } + + /** + * Handles all of the logic of sending a story to a group. Will do sender key sends and legacy 1:1 sends as-needed, and give you back a list of + * {@link SendMessageResult}s just like we're used to. + * + * @param isRecipientUpdate True if you've already sent this message to some recipients in the past, otherwise false. + */ + public static List sendStoryMessage(@NonNull Context context, + @NonNull DistributionListId distributionListId, + @NonNull List allTargets, + boolean isRecipientUpdate, + @NonNull MessageId messageId, + long sentTimestamp, + @NonNull SignalServiceStoryMessage message) + throws IOException, UntrustedIdentityException + { + return sendMessage(context, null, getDistributionId(distributionListId), messageId, allTargets, isRecipientUpdate, new StorySendOperation(messageId, null, sentTimestamp, message), null); + } + + /** + * Handles all of the logic of sending a story to a group. Will do sender key sends and legacy 1:1 sends as-needed, and give you back a list of + * {@link SendMessageResult}s just like we're used to. + * + * @param isRecipientUpdate True if you've already sent this message to some recipients in the past, otherwise false. + */ + public static List sendGroupStoryMessage(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull List allTargets, + boolean isRecipientUpdate, + @NonNull MessageId messageId, + long sentTimestamp, + @NonNull SignalServiceStoryMessage message) + throws IOException, UntrustedIdentityException + { + return sendMessage(context, groupId, getDistributionId(groupId), messageId, allTargets, isRecipientUpdate, new StorySendOperation(messageId, groupId, sentTimestamp, message), null); } /** @@ -152,6 +191,7 @@ public final class GroupSendUtil { @WorkerThread private static List sendMessage(@NonNull Context context, @Nullable GroupId.V2 groupId, + @Nullable DistributionId distributionId, @Nullable MessageId relatedMessageId, @NonNull List allTargets, boolean isRecipientUpdate, @@ -159,7 +199,7 @@ public final class GroupSendUtil { @Nullable CancelationSignal cancelationSignal) throws IOException, UntrustedIdentityException { - Log.i(TAG, "Starting group send. GroupId: " + (groupId != null ? groupId.toString() : "none") + ", RelatedMessageId: " + (relatedMessageId != null ? relatedMessageId.toString() : "none") + ", Targets: " + allTargets.size() + ", RecipientUpdate: " + isRecipientUpdate + ", Operation: " + sendOperation.getClass().getSimpleName()); + Log.i(TAG, "Starting group send. GroupId: " + (groupId != null ? groupId.toString() : "none") + ", DistributionListId: " + (distributionId != null ? distributionId.toString() : "none") + " RelatedMessageId: " + (relatedMessageId != null ? relatedMessageId.toString() : "none") + ", Targets: " + allTargets.size() + ", RecipientUpdate: " + isRecipientUpdate + ", Operation: " + sendOperation.getClass().getSimpleName()); Set unregisteredTargets = allTargets.stream().filter(Recipient::isUnregistered).collect(Collectors.toSet()); List registeredTargets = allTargets.stream().filter(r -> !unregisteredTargets.contains(r)).collect(Collectors.toList()); @@ -172,7 +212,11 @@ public final class GroupSendUtil { for (Recipient recipient : registeredTargets) { Optional access = recipients.getAccessPair(recipient.getId()); - boolean validMembership = groupRecord.isPresent() && groupRecord.get().getMembers().contains(recipient.getId()); + boolean validMembership = true; + + if (groupId != null && (!groupRecord.isPresent() || !groupRecord.get().getMembers().contains(recipient.getId()))) { + validMembership = false; + } if (recipient.getSenderKeyCapability() == Recipient.Capability.SUPPORTED && recipient.hasServiceId() && @@ -186,8 +230,8 @@ public final class GroupSendUtil { } } - if (groupId == null) { - Log.i(TAG, "Recipients not in a group. Using legacy."); + if (distributionId == null) { + Log.i(TAG, "No DistributionId. Using legacy."); legacyTargets.addAll(senderKeyTargets); senderKeyTargets.clear(); } else if (Recipient.self().getSenderKeyCapability() != Recipient.Capability.SUPPORTED) { @@ -204,21 +248,20 @@ public final class GroupSendUtil { Log.i(TAG, "Can use sender key for " + senderKeyTargets.size() + "/" + allTargets.size() + " recipients."); } - if (relatedMessageId != null) { + if (relatedMessageId != null && groupId != null) { SignalLocalMetrics.GroupMessageSend.onSenderKeyStarted(relatedMessageId.getId()); } List allResults = new ArrayList<>(allTargets.size()); - SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); - if (senderKeyTargets.size() > 0 && groupId != null) { - DistributionId distributionId = SignalDatabase.groups().getOrCreateDistributionId(groupId); - long keyCreateTime = SenderKeyUtil.getCreateTimeForOurKey(context, distributionId); + if (senderKeyTargets.size() > 0 && distributionId != null) { + long keyCreateTime = SenderKeyUtil.getCreateTimeForOurKey(distributionId); long keyAge = System.currentTimeMillis() - keyCreateTime; if (keyCreateTime != -1 && keyAge > FeatureFlags.senderKeyMaxAge()) { Log.w(TAG, "DistributionId " + distributionId + " was created at " + keyCreateTime + " and is " + (keyAge) + " ms old (~" + TimeUnit.MILLISECONDS.toDays(keyAge) + " days). Rotating."); - SenderKeyUtil.rotateOurKey(context, distributionId); + SenderKeyUtil.rotateOurKey(distributionId); } try { @@ -324,6 +367,22 @@ public final class GroupSendUtil { return allResults; } + private static @Nullable DistributionId getDistributionId(@Nullable GroupId.V2 groupId) { + if (groupId != null) { + return SignalDatabase.groups().getOrCreateDistributionId(groupId); + } else { + return null; + } + } + + private static @Nullable DistributionId getDistributionId(@Nullable DistributionListId distributionListId) { + if (distributionListId != null) { + return Optional.fromNullable(SignalDatabase.distributionLists().getDistributionId(distributionListId)).orNull(); + } else { + return null; + } + } + /** Abstraction layer to handle the different types of message send operations we can do */ private interface SendOperation { @NonNull List sendWithSenderKey(@NonNull SignalServiceMessageSender messageSender, @@ -528,6 +587,64 @@ public final class GroupSendUtil { } } + public static class StorySendOperation implements SendOperation { + + private final MessageId relatedMessageId; + private final GroupId groupId; + private final long sentTimestamp; + private final SignalServiceStoryMessage message; + + public StorySendOperation(@NonNull MessageId relatedMessageId, @Nullable GroupId groupId, long sentTimestamp, @NonNull SignalServiceStoryMessage message) { + this.relatedMessageId = relatedMessageId; + this.groupId = groupId; + this.sentTimestamp = sentTimestamp; + this.message = message; + } + + @Override + public @NonNull List sendWithSenderKey(@NonNull SignalServiceMessageSender messageSender, + @NonNull DistributionId distributionId, + @NonNull List targets, + @NonNull List access, + boolean isRecipientUpdate) + throws NoSessionException, UntrustedIdentityException, InvalidKeyException, IOException, InvalidRegistrationIdException + { + return messageSender.sendGroupStory(distributionId, Optional.fromNullable(groupId).transform(GroupId::getDecodedId), targets, access, message, getSentTimestamp()); + } + + @Override + public @NonNull List sendLegacy(@NonNull SignalServiceMessageSender messageSender, + @NonNull List targets, + @NonNull List> access, + boolean isRecipientUpdate, + @Nullable PartialSendCompleteListener partialListener, + @Nullable CancelationSignal cancelationSignal) + throws IOException, UntrustedIdentityException + { + return messageSender.sendStory(targets, access, message, getSentTimestamp()); + } + + @Override + public @NonNull ContentHint getContentHint() { + return ContentHint.RESENDABLE; + } + + @Override + public long getSentTimestamp() { + return sentTimestamp; + } + + @Override + public boolean shouldIncludeInMessageLog() { + return true; + } + + @Override + public @NonNull MessageId getRelatedMessageId() { + return relatedMessageId; + } + } + private static final class SenderKeyMetricEventListener implements SenderKeyGroupEvents { private final long messageId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index e490ce4438..a1c12db1b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.database.MessageDatabase; import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult; import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId; import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.NoSuchMessageException; import org.thoughtcrime.securesms.database.PaymentDatabase; import org.thoughtcrime.securesms.database.PaymentMetaDataUtil; import org.thoughtcrime.securesms.database.RecipientDatabase; @@ -132,7 +133,9 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceGroup; import org.whispersystems.signalservice.api.messages.SignalServiceGroupContext; import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; +import org.whispersystems.signalservice.api.messages.SignalServicePreview; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; import org.whispersystems.signalservice.api.messages.calls.AnswerMessage; import org.whispersystems.signalservice.api.messages.calls.BusyMessage; @@ -266,7 +269,7 @@ public final class MessageContentProcessor { else if (message.getReaction().isPresent()) messageId = handleReaction(content, message, senderRecipient); else if (message.getRemoteDelete().isPresent()) messageId = handleRemoteDelete(content, message, senderRecipient); else if (message.getPayment().isPresent()) handlePayment(content, message, senderRecipient); - else if (message.getStoryContext().isPresent()) handleStoryMessage(content); + else if (message.getStoryContext().isPresent()) handleStoryReply(content, message, senderRecipient); else if (isMediaMessage) messageId = handleMediaMessage(content, message, smsMessageId, senderRecipient, threadRecipient, receivedTime); else if (message.getBody().isPresent()) messageId = handleTextMessage(content, message, smsMessageId, groupId, senderRecipient, threadRecipient, receivedTime); else if (Build.VERSION.SDK_INT > 19 && message.getGroupCallUpdate().isPresent()) handleGroupCallUpdateMessage(content, message, groupId, senderRecipient); @@ -344,6 +347,8 @@ public final class MessageContentProcessor { else if (message.isViewedReceipt()) handleViewedReceipt(content, message, senderRecipient); } else if (content.getTypingMessage().isPresent()) { handleTypingMessage(content, content.getTypingMessage().get(), senderRecipient); + } else if (content.getStoryMessage().isPresent()) { + handleStoryMessage(content, content.getStoryMessage().get(), senderRecipient); } else if (content.getDecryptionErrorMessage().isPresent()) { handleRetryReceipt(content, content.getDecryptionErrorMessage().get(), senderRecipient); } else if (content.getSenderKeyDistributionMessage().isPresent()) { @@ -824,6 +829,8 @@ public final class MessageContentProcessor { content.getTimestamp(), content.getServerReceivedTimestamp(), receivedTime, + false, + null, -1, expiresInSeconds * 1000L, true, @@ -1161,6 +1168,8 @@ public final class MessageContentProcessor { handleRemoteDelete(content, message.getMessage(), senderRecipient); } else if (message.getMessage().getAttachments().isPresent() || message.getMessage().getQuote().isPresent() || message.getMessage().getPreviews().isPresent() || message.getMessage().getSticker().isPresent() || message.getMessage().isViewOnce() || message.getMessage().getMentions().isPresent()) { threadId = handleSynchronizeSentMediaMessage(message, content.getTimestamp()); + } else if (message.getMessage().getStoryContext().isPresent()) { + handleStoryReply(content, message.getMessage(), senderRecipient); } else { threadId = handleSynchronizeSentTextMessage(message, content.getTimestamp()); } @@ -1305,8 +1314,109 @@ public final class MessageContentProcessor { messageNotifier.updateNotification(context); } - private void handleStoryMessage(SignalServiceContent content) { - warn(content.getTimestamp(), "Detected a story reply. We do not support this yet. Dropping."); + private void handleStoryMessage(@NonNull SignalServiceContent content, @NonNull SignalServiceStoryMessage message, @NonNull Recipient senderRecipient) throws StorageFailedException { + log(content.getTimestamp(), "Story message."); + + Optional insertResult; + + MessageDatabase database = SignalDatabase.mms(); + database.beginTransaction(); + + try { + IncomingMediaMessage mediaMessage = new IncomingMediaMessage(senderRecipient.getId(), + content.getTimestamp(), + content.getServerReceivedTimestamp(), + System.currentTimeMillis(), + true, + null, + -1, + 0, + false, + false, + content.isNeedsReceipt(), + Optional.absent(), + Optional.fromNullable(GroupUtil.getGroupContextIfPresent(content)), + message.getFileAttachment().transform(Collections::singletonList), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + content.getServerUuid()); + + insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); + + if (insertResult.isPresent()) { + database.setTransactionSuccessful(); + } + } catch (MmsException e) { + throw new StorageFailedException(e, content.getSender().getIdentifier(), content.getSenderDevice()); + } finally { + database.endTransaction(); + } + + if (insertResult.isPresent()) { + List allAttachments = SignalDatabase.attachments().getAttachmentsForMessage(insertResult.get().getMessageId()); + List attachments = Stream.of(allAttachments).filterNot(Attachment::isSticker).toList(); + + for (DatabaseAttachment attachment : attachments) { + ApplicationDependencies.getJobManager().add(new AttachmentDownloadJob(insertResult.get().getMessageId(), attachment.getAttachmentId(), false)); + } + + ApplicationDependencies.getExpireStoriesManager().scheduleIfNecessary(); + } + } + + private void handleStoryReply(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message, @NonNull Recipient senderRecipient) throws StorageFailedException { + log(content.getTimestamp(), "Story reply."); + + SignalServiceDataMessage.StoryContext storyContext = message.getStoryContext().get(); + + MessageDatabase database = SignalDatabase.mms(); + database.beginTransaction(); + + try { + // TODO [stories] check if this works for group stories + RecipientId storyAuthorRecipient = RecipientId.from(storyContext.getAuthorServiceId(), null); + MessageId storyId; + try { + storyId = database.getStoryId(storyAuthorRecipient, storyContext.getSentTimestamp()); + } catch (NoSuchMessageException e) { + warn(content.getTimestamp(), "Couldn't find story for reply.", e); + return; + } + + IncomingMediaMessage mediaMessage = new IncomingMediaMessage(senderRecipient.getId(), + content.getTimestamp(), + content.getServerReceivedTimestamp(), + System.currentTimeMillis(), + false, + storyId, + -1, + 0, + false, + false, + content.isNeedsReceipt(), + message.getBody(), + Optional.fromNullable(GroupUtil.getGroupContextIfPresent(content)), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + content.getServerUuid()); + + Optional insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); + + if (insertResult.isPresent()) { + database.setTransactionSuccessful(); + } + } catch (MmsException e) { + throw new StorageFailedException(e, content.getSender().getIdentifier(), content.getSenderDevice()); + } finally { + database.endTransaction(); + } } private @Nullable MessageId handleMediaMessage(@NonNull SignalServiceContent content, @@ -1337,6 +1447,8 @@ public final class MessageContentProcessor { message.getTimestamp(), content.getServerReceivedTimestamp(), receivedTime, + false, + null, -1, TimeUnit.SECONDS.toMillis(message.getExpiresInSeconds()), false, @@ -1433,16 +1545,22 @@ public final class MessageContentProcessor { syncAttachments.add(sticker.get()); } - OutgoingMediaMessage mediaMessage = new OutgoingMediaMessage(recipients, message.getMessage().getBody().orNull(), - syncAttachments, - message.getTimestamp(), -1, - TimeUnit.SECONDS.toMillis(message.getMessage().getExpiresInSeconds()), - viewOnce, - ThreadDatabase.DistributionTypes.DEFAULT, quote.orNull(), - sharedContacts.or(Collections.emptyList()), - previews.or(Collections.emptyList()), - mentions.or(Collections.emptyList()), - Collections.emptySet(), Collections.emptySet()); + OutgoingMediaMessage mediaMessage = new OutgoingMediaMessage(recipients, + message.getMessage().getBody().orNull(), + syncAttachments, + message.getTimestamp(), + -1, + TimeUnit.SECONDS.toMillis(message.getMessage().getExpiresInSeconds()), + viewOnce, + ThreadDatabase.DistributionTypes.DEFAULT, + false, + null, + quote.orNull(), + sharedContacts.or(Collections.emptyList()), + previews.or(Collections.emptyList()), + mentions.or(Collections.emptyList()), + Collections.emptySet(), + Collections.emptySet()); mediaMessage = new OutgoingSecureMediaMessage(mediaMessage); @@ -1622,17 +1740,19 @@ public final class MessageContentProcessor { if (isGroup) { OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, - new SlideDeck(), - body, - message.getTimestamp(), - -1, - expiresInMillis, - false, - ThreadDatabase.DistributionTypes.DEFAULT, - null, - Collections.emptyList(), - Collections.emptyList(), - Collections.emptyList()); + new SlideDeck(), + body, + message.getTimestamp(), + -1, + expiresInMillis, + false, + ThreadDatabase.DistributionTypes.DEFAULT, + false, + null, + null, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); outgoingMediaMessage = new OutgoingSecureMediaMessage(outgoingMediaMessage); messageId = SignalDatabase.mms().insertMessageOutbox(outgoingMediaMessage, threadId, false, GroupReceiptDatabase.STATUS_UNKNOWN, null); @@ -2160,13 +2280,13 @@ public final class MessageContentProcessor { return Optional.of(contacts); } - private Optional> getLinkPreviews(Optional> previews, @NonNull String message) { + private Optional> getLinkPreviews(Optional> previews, @NonNull String message) { if (!previews.isPresent() || previews.get().isEmpty()) return Optional.absent(); List linkPreviews = new ArrayList<>(previews.get().size()); LinkPreviewUtil.Links urlsInMessage = LinkPreviewUtil.findValidPreviewUrls(message); - for (SignalServiceDataMessage.Preview preview : previews.get()) { + for (SignalServicePreview preview : previews.get()) { Optional thumbnail = PointerAttachment.forPointer(preview.getImage()); Optional url = Optional.fromNullable(preview.getUrl()); Optional title = Optional.fromNullable(preview.getTitle()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt index c5ca7fad55..8914324ea3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt @@ -4,6 +4,7 @@ import org.thoughtcrime.securesms.attachments.Attachment import org.thoughtcrime.securesms.attachments.PointerAttachment import org.thoughtcrime.securesms.contactshare.Contact import org.thoughtcrime.securesms.database.model.Mention +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.linkpreview.LinkPreview @@ -18,6 +19,8 @@ class IncomingMediaMessage( val groupId: GroupId? = null, val body: String? = null, val isPushMessage: Boolean = false, + val isStory: Boolean = false, + val parentStoryId: MessageId? = null, val sentTimeMillis: Long, val serverTimeMillis: Long, val receivedTimeMillis: Long, @@ -80,6 +83,8 @@ class IncomingMediaMessage( sentTimeMillis: Long, serverTimeMillis: Long, receivedTimeMillis: Long, + isStory: Boolean, + parentStoryId: MessageId?, subscriptionId: Int, expiresIn: Long, expirationUpdate: Boolean, @@ -99,6 +104,8 @@ class IncomingMediaMessage( groupId = if (group.isPresent) GroupUtil.idFromGroupContextOrThrow(group.get()) else null, body = body.orNull(), isPushMessage = true, + isStory = isStory, + parentStoryId = parentStoryId, sentTimeMillis = sentTimeMillis, serverTimeMillis = serverTimeMillis, receivedTimeMillis = receivedTimeMillis, diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java index 1efad1d5d4..2cb179dfa1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java @@ -10,9 +10,19 @@ import java.util.LinkedList; public class OutgoingExpirationUpdateMessage extends OutgoingSecureMediaMessage { public OutgoingExpirationUpdateMessage(Recipient recipient, long sentTimeMillis, long expiresIn) { - super(recipient, "", new LinkedList(), sentTimeMillis, - ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, false, null, Collections.emptyList(), - Collections.emptyList(), Collections.emptyList()); + super(recipient, + "", + new LinkedList(), + sentTimeMillis, + ThreadDatabase.DistributionTypes.CONVERSATION, + expiresIn, + false, + false, + null, + null, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingGroupUpdateMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingGroupUpdateMessage.java index 6a19dbfeb2..c05bcd4604 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingGroupUpdateMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingGroupUpdateMessage.java @@ -31,8 +31,19 @@ public final class OutgoingGroupUpdateMessage extends OutgoingSecureMediaMessage @NonNull List previews, @NonNull List mentions) { - super(recipient, groupContext.getEncodedGroupContext(), avatar, sentTimeMillis, - ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, viewOnce, quote, contacts, previews, mentions); + super(recipient, + groupContext.getEncodedGroupContext(), + avatar, + sentTimeMillis, + ThreadDatabase.DistributionTypes.CONVERSATION, + expiresIn, + viewOnce, + false, + null, + quote, + contacts, + previews, + mentions); this.messageGroupContext = groupContext; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java index db6903c236..65b4ba9e69 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java @@ -10,6 +10,7 @@ import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.NetworkFailure; import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.recipients.Recipient; @@ -29,6 +30,8 @@ public class OutgoingMediaMessage { private final long expiresIn; private final boolean viewOnce; private final QuoteModel outgoingQuote; + private final boolean isStory; + private final MessageId parentStoryId; private final Set networkFailures = new HashSet<>(); private final Set identityKeyMismatches = new HashSet<>(); @@ -36,10 +39,16 @@ public class OutgoingMediaMessage { private final List linkPreviews = new LinkedList<>(); private final List mentions = new LinkedList<>(); - public OutgoingMediaMessage(Recipient recipient, String message, - List attachments, long sentTimeMillis, - int subscriptionId, long expiresIn, boolean viewOnce, + public OutgoingMediaMessage(Recipient recipient, + String message, + List attachments, + long sentTimeMillis, + int subscriptionId, + long expiresIn, + boolean viewOnce, int distributionType, + boolean isStory, + @Nullable MessageId parentStoryId, @Nullable QuoteModel outgoingQuote, @NonNull List contacts, @NonNull List linkPreviews, @@ -56,6 +65,8 @@ public class OutgoingMediaMessage { this.expiresIn = expiresIn; this.viewOnce = viewOnce; this.outgoingQuote = outgoingQuote; + this.isStory = isStory; + this.parentStoryId = parentStoryId; this.contacts.addAll(contacts); this.linkPreviews.addAll(linkPreviews); @@ -64,9 +75,16 @@ public class OutgoingMediaMessage { this.identityKeyMismatches.addAll(identityKeyMismatches); } - public OutgoingMediaMessage(Recipient recipient, SlideDeck slideDeck, String message, - long sentTimeMillis, int subscriptionId, long expiresIn, - boolean viewOnce, int distributionType, + public OutgoingMediaMessage(Recipient recipient, + SlideDeck slideDeck, + String message, + long sentTimeMillis, + int subscriptionId, + long expiresIn, + boolean viewOnce, + int distributionType, + boolean isStory, + @Nullable MessageId parentStoryId, @Nullable QuoteModel outgoingQuote, @NonNull List contacts, @NonNull List linkPreviews, @@ -75,9 +93,19 @@ public class OutgoingMediaMessage { this(recipient, buildMessage(slideDeck, message), slideDeck.asAttachments(), - sentTimeMillis, subscriptionId, - expiresIn, viewOnce, distributionType, outgoingQuote, - contacts, linkPreviews, mentions, new HashSet<>(), new HashSet<>()); + sentTimeMillis, + subscriptionId, + expiresIn, + viewOnce, + distributionType, + isStory, + parentStoryId, + outgoingQuote, + contacts, + linkPreviews, + mentions, + new HashSet<>(), + new HashSet<>()); } public OutgoingMediaMessage(OutgoingMediaMessage that) { @@ -90,6 +118,8 @@ public class OutgoingMediaMessage { this.expiresIn = that.expiresIn; this.viewOnce = that.viewOnce; this.outgoingQuote = that.outgoingQuote; + this.isStory = that.isStory; + this.parentStoryId = that.parentStoryId; this.identityKeyMismatches.addAll(that.identityKeyMismatches); this.networkFailures.addAll(that.networkFailures); @@ -108,6 +138,8 @@ public class OutgoingMediaMessage { expiresIn, viewOnce, distributionType, + isStory, + parentStoryId, outgoingQuote, contacts, linkPreviews, @@ -161,6 +193,14 @@ public class OutgoingMediaMessage { return viewOnce; } + public boolean isStory() { + return isStory; + } + + public @Nullable MessageId getParentStoryId() { + return parentStoryId; + } + public @Nullable QuoteModel getOutgoingQuote() { return outgoingQuote; } @@ -194,5 +234,4 @@ public class OutgoingMediaMessage { return slideDeck.getBody(); } } - } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java index df5a89b35a..5b3a81078d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java @@ -6,6 +6,7 @@ import androidx.annotation.Nullable; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.recipients.Recipient; @@ -14,18 +15,21 @@ import java.util.List; public class OutgoingSecureMediaMessage extends OutgoingMediaMessage { - public OutgoingSecureMediaMessage(Recipient recipient, String body, + public OutgoingSecureMediaMessage(Recipient recipient, + String body, List attachments, long sentTimeMillis, int distributionType, long expiresIn, boolean viewOnce, + boolean isStory, + @Nullable MessageId parentStoryId, @Nullable QuoteModel quote, @NonNull List contacts, @NonNull List previews, @NonNull List mentions) { - super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, viewOnce, distributionType, quote, contacts, previews, mentions, Collections.emptySet(), Collections.emptySet()); + super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, viewOnce, distributionType, isStory, parentStoryId, quote, contacts, previews, mentions, Collections.emptySet(), Collections.emptySet()); } public OutgoingSecureMediaMessage(OutgoingMediaMessage base) { @@ -46,6 +50,8 @@ public class OutgoingSecureMediaMessage extends OutgoingMediaMessage { getDistributionType(), expiresIn, isViewOnce(), + isStory(), + getParentStoryId(), getOutgoingQuote(), getSharedContacts(), getLinkPreviews(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index e548923b92..33fb8ea549 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -86,6 +86,8 @@ public class RemoteReplyReceiver extends BroadcastReceiver { expiresIn, false, 0, + false, + null, null, Collections.emptyList(), Collections.emptyList(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java index 8776592717..1f873a8aa2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java @@ -70,7 +70,7 @@ public class PaymentRecipientSelectionFragment extends LoggingFragment implement } @Override - public void onBeforeContactSelected(@NonNull Optional recipientId, @Nullable String number, Consumer callback) { + public void onBeforeContactSelected(@NonNull Optional recipientId, @Nullable String number, @NonNull Consumer callback) { if (recipientId.isPresent()) { SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> Recipient.resolved(recipientId.get()), diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiBottomSheetDialogFragment.java index e2d4f87876..c2c2139523 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiBottomSheetDialogFragment.java @@ -73,6 +73,20 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends BottomShee private final UpdateCategorySelectionOnScroll categoryUpdateOnScroll = new UpdateCategorySelectionOnScroll(); + public static DialogFragment createForStory() { + DialogFragment fragment = new ReactWithAnyEmojiBottomSheetDialogFragment(); + Bundle args = new Bundle(); + + args.putLong(ARG_MESSAGE_ID, -1); + args.putBoolean(ARG_IS_MMS, false); + args.putInt(ARG_START_PAGE, -1); + args.putString(ARG_RECENT_KEY, REACTION_STORAGE_KEY); + args.putBoolean(ARG_EDIT, false); + fragment.setArguments(args); + + return fragment; + } + public static DialogFragment createForMessageRecord(@NonNull MessageRecord messageRecord, int startingPage) { DialogFragment fragment = new ReactWithAnyEmojiBottomSheetDialogFragment(); Bundle args = new Bundle(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java index 78d6c0e026..deae91dcbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java @@ -13,11 +13,13 @@ import com.annimon.stream.Stream; import org.signal.core.util.ThreadUtil; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DistributionListDatabase; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; import org.thoughtcrime.securesms.database.RecipientDatabase; -import org.thoughtcrime.securesms.database.model.RecipientRecord; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.DistributionListRecord; +import org.thoughtcrime.securesms.database.model.RecipientRecord; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import org.whispersystems.libsignal.util.guava.Optional; @@ -40,16 +42,18 @@ public final class LiveRecipient { private final AtomicReference recipient; private final RecipientDatabase recipientDatabase; private final GroupDatabase groupDatabase; + private final DistributionListDatabase distributionListDatabase; private final MutableLiveData refreshForceNotify; LiveRecipient(@NonNull Context context, @NonNull Recipient defaultRecipient) { - this.context = context.getApplicationContext(); - this.liveData = new MutableLiveData<>(defaultRecipient); - this.recipient = new AtomicReference<>(defaultRecipient); - this.recipientDatabase = SignalDatabase.recipients(); - this.groupDatabase = SignalDatabase.groups(); - this.observers = new CopyOnWriteArraySet<>(); - this.foreverObserver = recipient -> { + this.context = context.getApplicationContext(); + this.liveData = new MutableLiveData<>(defaultRecipient); + this.recipient = new AtomicReference<>(defaultRecipient); + this.recipientDatabase = SignalDatabase.recipients(); + this.groupDatabase = SignalDatabase.groups(); + this.distributionListDatabase = SignalDatabase.distributionLists(); + this.observers = new CopyOnWriteArraySet<>(); + this.foreverObserver = recipient -> { ThreadUtil.postToMain(() -> { for (RecipientForeverObserver o : observers) { o.onRecipientChanged(recipient); @@ -192,9 +196,15 @@ public final class LiveRecipient { } private @NonNull Recipient fetchAndCacheRecipientFromDisk(@NonNull RecipientId id) { - RecipientRecord settings = recipientDatabase.getRecord(id); - RecipientDetails details = settings.getGroupId() != null ? getGroupRecipientDetails(settings) - : RecipientDetails.forIndividual(context, settings); + RecipientRecord record = recipientDatabase.getRecord(id); + RecipientDetails details; + if (record.getGroupId() != null) { + details = getGroupRecipientDetails(record); + } else if (record.getDistributionListId() != null) { + details = getDistributionListRecipientDetails(record); + } else { + details = RecipientDetails.forIndividual(context, record); + } Recipient recipient = new Recipient(id, details, true); RecipientIdCache.INSTANCE.put(recipient); @@ -202,8 +212,8 @@ public final class LiveRecipient { } @WorkerThread - private @NonNull RecipientDetails getGroupRecipientDetails(@NonNull RecipientRecord settings) { - Optional groupRecord = groupDatabase.getGroup(settings.getId()); + private @NonNull RecipientDetails getGroupRecipientDetails(@NonNull RecipientRecord record) { + Optional groupRecord = groupDatabase.getGroup(record.getId()); if (groupRecord.isPresent()) { String title = groupRecord.get().getTitle(); @@ -214,10 +224,25 @@ public final class LiveRecipient { avatarId = Optional.of(groupRecord.get().getAvatarId()); } - return new RecipientDetails(title, null, avatarId, false, false, settings.getRegistered(), settings, members, false); + return new RecipientDetails(title, null, avatarId, false, false, record.getRegistered(), record, members, false); } - return new RecipientDetails(null, null, Optional.absent(), false, false, settings.getRegistered(), settings, null, false); + return new RecipientDetails(null, null, Optional.absent(), false, false, record.getRegistered(), record, null, false); + } + + @WorkerThread + private @NonNull RecipientDetails getDistributionListRecipientDetails(@NonNull RecipientRecord record) { + DistributionListRecord groupRecord = distributionListDatabase.getList(Objects.requireNonNull(record.getDistributionListId())); + + // TODO [stories] We'll have to see what the perf is like for very large distribution lists. We may not be able to support fetching all the members. + if (groupRecord != null) { + String title = groupRecord.getName(); + List members = Stream.of(groupRecord.getMembers()).filterNot(RecipientId::isUnknown).map(this::fetchAndCacheRecipientFromDisk).toList(); + + return RecipientDetails.forDistributionList(title, members, record); + } + + return RecipientDetails.forDistributionList(null, null, record); } synchronized void set(@NonNull Recipient recipient) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index 393541353a..b4a4736a8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode; import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.DistributionListId; import org.thoughtcrime.securesms.database.model.databaseprotos.RecipientExtras; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; @@ -48,8 +49,8 @@ import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.libsignal.util.guava.Preconditions; -import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.PNI; +import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.util.UuidUtil; @@ -70,7 +71,7 @@ public class Recipient { private static final String TAG = Log.tag(Recipient.class); - public static final Recipient UNKNOWN = new Recipient(RecipientId.UNKNOWN, new RecipientDetails(), true); + public static final Recipient UNKNOWN = new Recipient(RecipientId.UNKNOWN, RecipientDetails.forUnknown(), true); public static final FallbackPhotoProvider DEFAULT_FALLBACK_PHOTO_PROVIDER = new FallbackPhotoProvider(); @@ -84,6 +85,7 @@ public class Recipient { private final String e164; private final String email; private final GroupId groupId; + private final DistributionListId distributionListId; private final List participants; private final Optional groupAvatarId; private final boolean isSelf; @@ -115,6 +117,7 @@ public class Recipient { private final Capability senderKeyCapability; private final Capability announcementGroupCapability; private final Capability changeNumberCapability; + private final Capability storiesCapability; private final InsightsBannerTier insightsBannerTier; private final byte[] storageId; private final MentionSetting mentionSetting; @@ -162,6 +165,12 @@ public class Recipient { return recipients; } + @WorkerThread + public static @NonNull Recipient distributionList(@NonNull DistributionListId distributionListId) { + RecipientId id = SignalDatabase.recipients().getOrInsertFromDistributionListId(distributionListId); + return resolved(id); + } + /** * Returns a fully-populated {@link Recipient} and associates it with the provided username. */ @@ -343,6 +352,7 @@ public class Recipient { this.e164 = null; this.email = null; this.groupId = null; + this.distributionListId = null; this.participants = Collections.emptyList(); this.groupAvatarId = Optional.absent(); this.isSelf = false; @@ -375,6 +385,7 @@ public class Recipient { this.senderKeyCapability = Capability.UNKNOWN; this.announcementGroupCapability = Capability.UNKNOWN; this.changeNumberCapability = Capability.UNKNOWN; + this.storiesCapability = Capability.UNKNOWN; this.storageId = null; this.mentionSetting = MentionSetting.ALWAYS_NOTIFY; this.wallpaper = null; @@ -399,6 +410,7 @@ public class Recipient { this.e164 = details.e164; this.email = details.email; this.groupId = details.groupId; + this.distributionListId = details.distributionListId; this.participants = details.participants; this.groupAvatarId = details.groupAvatarId; this.isSelf = details.isSelf; @@ -431,6 +443,7 @@ public class Recipient { this.senderKeyCapability = details.senderKeyCapability; this.announcementGroupCapability = details.announcementGroupCapability; this.changeNumberCapability = details.changeNumberCapability; + this.storiesCapability = details.storiesCapability; this.storageId = details.storageId; this.mentionSetting = details.mentionSetting; this.wallpaper = details.wallpaper; @@ -492,6 +505,8 @@ public class Recipient { } return Util.join(names, ", "); + } else if (isMyStory()) { + return context.getString(R.string.Recipient_my_story); } else { return this.groupName; } @@ -642,6 +657,10 @@ public class Recipient { return Optional.fromNullable(groupId); } + public @NonNull Optional getDistributionListId() { + return Optional.fromNullable(distributionListId); + } + public @NonNull Optional getSmsAddress() { return Optional.fromNullable(e164).or(Optional.fromNullable(email)); } @@ -704,6 +723,10 @@ public class Recipient { return hasServiceId() && !hasSmsAddress(); } + public boolean shouldHideStory() { + return extras.transform(Extras::hideStory).or(false); + } + public @NonNull GroupId requireGroupId() { GroupId resolved = resolving ? resolve().groupId : groupId; @@ -714,6 +737,16 @@ public class Recipient { return resolved; } + public @NonNull DistributionListId requireDistributionListId() { + DistributionListId resolved = resolving ? resolve().distributionListId : distributionListId; + + if (resolved == null) { + throw new MissingAddressError(id); + } + + return resolved; + } + /** * The {@link ServiceId} of the user if available, otherwise throw. */ @@ -796,6 +829,14 @@ public class Recipient { return groupId != null && groupId.isV2(); } + public boolean isDistributionList() { + return resolve().distributionListId != null; + } + + public boolean isMyStory() { + return Objects.equals(resolve().distributionListId, DistributionListId.from(DistributionListId.MY_STORY_ID)); + } + public boolean isActiveGroup() { return Stream.of(getParticipants()).anyMatch(Recipient::isSelf); } @@ -835,6 +876,7 @@ public class Recipient { public @NonNull FallbackContactPhoto getFallbackContactPhoto(@NonNull FallbackPhotoProvider fallbackPhotoProvider, int targetSize) { if (isSelf) return fallbackPhotoProvider.getPhotoForLocalNumber(); else if (isResolving()) return fallbackPhotoProvider.getPhotoForResolvingRecipient(); + else if (isDistributionList()) return fallbackPhotoProvider.getPhotoForDistributionList(); else if (isGroupInternal()) return fallbackPhotoProvider.getPhotoForGroup(); else if (isGroup()) return fallbackPhotoProvider.getPhotoForGroup(); else if (!TextUtils.isEmpty(groupName)) return fallbackPhotoProvider.getPhotoForRecipientWithName(groupName, targetSize); @@ -947,6 +989,10 @@ public class Recipient { return changeNumberCapability; } + public @NonNull Capability getStoriesCapability() { + return storiesCapability; + } + /** * True if this recipient supports the message retry system, or false if we should use the legacy session reset system. */ @@ -1170,17 +1216,21 @@ public class Recipient { return recipientExtras.getManuallyShownAvatar(); } + public boolean hideStory() { + return recipientExtras.getHideStory(); + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final Extras that = (Extras) o; - return manuallyShownAvatar() == that.manuallyShownAvatar(); + return manuallyShownAvatar() == that.manuallyShownAvatar() && hideStory() == that.hideStory(); } @Override public int hashCode() { - return Objects.hash(manuallyShownAvatar()); + return Objects.hash(manuallyShownAvatar(), hideStory()); } } @@ -1269,6 +1319,10 @@ public class Recipient { public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { return new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20, R.drawable.ic_profile_outline_48); } + + public @NonNull FallbackContactPhoto getPhotoForDistributionList() { + return new ResourceContactPhoto(R.drawable.ic_group_outline_34, R.drawable.ic_group_outline_20, R.drawable.ic_group_outline_48); + } } private static class MissingAddressError extends AssertionError { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java index c7c831c7bf..508dfdf4bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java @@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.conversation.colors.AvatarColor; import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.database.RecipientDatabase.InsightsBannerTier; import org.thoughtcrime.securesms.database.RecipientDatabase.MentionSetting; +import org.thoughtcrime.securesms.database.model.DistributionListId; import org.thoughtcrime.securesms.database.model.RecipientRecord; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode; @@ -39,6 +40,7 @@ public class RecipientDetails { final String e164; final String email; final GroupId groupId; + final DistributionListId distributionListId; final String groupName; final String systemContactName; final String customLabel; @@ -72,6 +74,7 @@ public class RecipientDetails { final Recipient.Capability senderKeyCapability; final Recipient.Capability announcementGroupCapability; final Recipient.Capability changeNumberCapability; + final Recipient.Capability storiesCapability; final InsightsBannerTier insightsBannerTier; final byte[] storageId; final MentionSetting mentionSetting; @@ -106,6 +109,7 @@ public class RecipientDetails { this.e164 = record.getE164(); this.email = record.getEmail(); this.groupId = record.getGroupId(); + this.distributionListId = record.getDistributionListId(); this.messageRingtone = record.getMessageRingtone(); this.callRingtone = record.getCallRingtone(); this.mutedUntil = record.getMuteUntil(); @@ -133,6 +137,7 @@ public class RecipientDetails { this.senderKeyCapability = record.getSenderKeyCapability(); this.announcementGroupCapability = record.getAnnouncementGroupCapability(); this.changeNumberCapability = record.getChangeNumberCapability(); + this.storiesCapability = record.getStoriesCapability(); this.insightsBannerTier = record.getInsightsBannerTier(); this.storageId = record.getStorageId(); this.mentionSetting = record.getMentionSetting(); @@ -150,10 +155,7 @@ public class RecipientDetails { this.isReleaseChannel = isReleaseChannel; } - /** - * Only used for {@link Recipient#UNKNOWN}. - */ - RecipientDetails() { + private RecipientDetails() { this.groupAvatarId = null; this.systemContactPhoto = null; this.customLabel = null; @@ -164,6 +166,7 @@ public class RecipientDetails { this.e164 = null; this.email = null; this.groupId = null; + this.distributionListId = null; this.messageRingtone = null; this.callRingtone = null; this.mutedUntil = 0; @@ -193,6 +196,7 @@ public class RecipientDetails { this.senderKeyCapability = Recipient.Capability.UNKNOWN; this.announcementGroupCapability = Recipient.Capability.UNKNOWN; this.changeNumberCapability = Recipient.Capability.UNKNOWN; + this.storiesCapability = Recipient.Capability.UNKNOWN; this.storageId = null; this.mentionSetting = MentionSetting.ALWAYS_NOTIFY; this.wallpaper = null; @@ -226,4 +230,12 @@ public class RecipientDetails { return new RecipientDetails(null, settings.getSystemDisplayName(), Optional.absent(), systemContact, isSelf, registeredState, settings, null, isReleaseChannel); } + + public static @NonNull RecipientDetails forDistributionList(String title, @Nullable List members, @NonNull RecipientRecord record) { + return new RecipientDetails(title, null, Optional.absent(), false, false, record.getRegistered(), record, members, false); + } + + public static @NonNull RecipientDetails forUnknown() { + return new RecipientDetails(); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java index 70cd292b97..f70fe088b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java @@ -11,6 +11,7 @@ import androidx.annotation.Nullable; import com.annimon.stream.Stream; +import org.thoughtcrime.securesms.database.model.DatabaseId; import org.thoughtcrime.securesms.util.DelimiterUtil; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.signalservice.api.push.ServiceId; @@ -22,7 +23,7 @@ import java.util.Collection; import java.util.List; import java.util.regex.Pattern; -public class RecipientId implements Parcelable, Comparable { +public class RecipientId implements Parcelable, Comparable, DatabaseId { private static final long UNKNOWN_ID = -1; private static final char DELIMITER = ','; @@ -141,6 +142,7 @@ public class RecipientId implements Parcelable, Comparable { return id == UNKNOWN_ID; } + @Override public @NonNull String serialize() { return String.valueOf(id); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java index 00a5db0f92..23379f3dec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java @@ -29,6 +29,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.avatar.view.AvatarView; import org.thoughtcrime.securesms.badges.BadgeImageView; import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment; import org.thoughtcrime.securesms.components.AvatarImageView; @@ -69,7 +70,7 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF private static final String ARGS_GROUP_ID = "GROUP_ID"; private RecipientDialogViewModel viewModel; - private AvatarImageView avatar; + private AvatarView avatar; private TextView fullName; private TextView about; private TextView usernameNumber; @@ -160,7 +161,7 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF return new FallbackPhoto80dp(R.drawable.ic_note_80, recipient.getAvatarColor()); } }); - avatar.setAvatar(recipient); + avatar.displayChatAvatar(recipient); if (!recipient.isSelf()) { badgeImageView.setBadgeFromRecipient(recipient); diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationRepository.java b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationRepository.java index cc145d4b8e..50dae637c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationRepository.java @@ -137,7 +137,7 @@ public final class RegistrationRepository { ApplicationDependencies.getProtocolStore().aci().sessions().archiveAllSessions(); ApplicationDependencies.getProtocolStore().pni().sessions().archiveAllSessions(); - SenderKeyUtil.clearAllState(context); + SenderKeyUtil.clearAllState(); SignalServiceAccountManager accountManager = AccountManagerFactory.createAuthenticated(context, aci, pni, registrationData.getE164(), SignalServiceAddress.DEFAULT_DEVICE_ID, registrationData.getPassword()); SignalServiceAccountDataStoreImpl aciProtocolStore = ApplicationDependencies.getProtocolStore().aci(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/HSVColorSlider.kt b/app/src/main/java/org/thoughtcrime/securesms/scribbles/HSVColorSlider.kt index 7bb2d8d138..5c26b8f0a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/HSVColorSlider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/HSVColorSlider.kt @@ -57,6 +57,11 @@ object HSVColorSlider { ) }.toIntArray() + @ColorInt + fun getLastColor(): Int { + return colors.last() + } + fun AppCompatSeekBar.getColor(): Int { return colors[progress] } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringStoriesManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringStoriesManager.kt new file mode 100644 index 0000000000..9dc6b5a59f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringStoriesManager.kt @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.service + +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.annotation.WorkerThread +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import java.util.concurrent.TimeUnit + +/** + * Manages deleting stories 24 hours after they've been sent. + */ +class ExpiringStoriesManager( + application: Application +) : TimedEventManager(application, "ExpiringStoriesManager") { + + companion object { + private val TAG = Log.tag(ExpiringStoriesManager::class.java) + + private val STORY_LIFESPAN = TimeUnit.HOURS.toMillis(24) + } + + private val mmsDatabase = SignalDatabase.mms + + init { + scheduleIfNecessary() + } + + @WorkerThread + override fun getNextClosestEvent(): Event? { + val oldestTimestamp = mmsDatabase.oldestStorySendTimestamp ?: return null + + val timeSinceSend = System.currentTimeMillis() - oldestTimestamp + val delay = (STORY_LIFESPAN - timeSinceSend).coerceAtLeast(0) + Log.i(TAG, "The oldest story needs to be deleted in $delay ms.") + + return Event(delay) + } + + @WorkerThread + override fun executeEvent(event: Event) { + val threshold = System.currentTimeMillis() - STORY_LIFESPAN + val deletes = mmsDatabase.deleteStoriesOlderThan(threshold) + Log.i(TAG, "Deleted $deletes stories before $threshold") + } + + @WorkerThread + override fun getDelayForEvent(event: Event): Long = event.delay + + @WorkerThread + override fun scheduleAlarm(application: Application, delay: Long) { + setAlarm(application, delay, ExpireStoriesAlarm::class.java) + } + + data class Event(val delay: Long) + + class ExpireStoriesAlarm : BroadcastReceiver() { + + companion object { + private val TAG = Log.tag(ExpireStoriesAlarm::class.java) + } + + override fun onReceive(context: Context?, intent: Intent?) { + Log.d(TAG, "onReceive()") + ApplicationDependencies.getExpireStoriesManager().scheduleIfNecessary() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java index 5f9ed40cff..8940493d04 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java @@ -102,8 +102,10 @@ public final class MultiShareSender { if ((recipient.isMmsGroup() || recipient.getEmail().isPresent()) && !isMmsEnabled) { results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.MMS_NOT_ENABLED)); } else if (isMediaMessage) { - sendMediaMessage(context, multiShareArgs, recipient, slideDeck, transport, shareContactAndThread.getThreadId(), forceSms, expiresIn, multiShareArgs.isViewOnce(), subscriptionId, mentions); + sendMediaMessage(context, multiShareArgs, recipient, slideDeck, transport, shareContactAndThread.getThreadId(), forceSms, expiresIn, multiShareArgs.isViewOnce(), subscriptionId, mentions, shareContactAndThread.isStory()); results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.SUCCESS)); + } else if (shareContactAndThread.isStory()) { + results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.INVALID_SHARE_TO_STORY)); } else { sendTextMessage(context, multiShareArgs, recipient, shareContactAndThread.getThreadId(), forceSms, expiresIn, subscriptionId); results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.SUCCESS)); @@ -152,7 +154,8 @@ public final class MultiShareSender { long expiresIn, boolean isViewOnce, int subscriptionId, - @NonNull List validatedMentions) + @NonNull List validatedMentions, + boolean isStory) { String body = multiShareArgs.getDraftText(); if (transportOption.isType(TransportOption.Type.TEXTSECURE) && !forceSms && body != null) { @@ -164,26 +167,64 @@ public final class MultiShareSender { } } - OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, - slideDeck, - body, - System.currentTimeMillis(), - subscriptionId, - expiresIn, - isViewOnce, - ThreadDatabase.DistributionTypes.DEFAULT, - null, - Collections.emptyList(), - multiShareArgs.getLinkPreview() != null ? Collections.singletonList(multiShareArgs.getLinkPreview()) - : Collections.emptyList(), - validatedMentions); + List outgoingMessages = new ArrayList<>(); - if (recipient.isRegistered() && !forceSms) { - MessageSender.send(context, new OutgoingSecureMediaMessage(outgoingMediaMessage), threadId, false, null, null); + if (isStory && slideDeck.getSlides().size() > 1) { + for (final Slide slide : slideDeck.getSlides()) { + SlideDeck singletonDeck = new SlideDeck(); + singletonDeck.addSlide(slide); + + OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, + singletonDeck, + body, + System.currentTimeMillis(), + subscriptionId, + expiresIn, + isViewOnce, + ThreadDatabase.DistributionTypes.DEFAULT, + true, + null, + null, + Collections.emptyList(), + multiShareArgs.getLinkPreview() != null ? Collections.singletonList(multiShareArgs.getLinkPreview()) + : Collections.emptyList(), + validatedMentions); + + outgoingMessages.add(outgoingMediaMessage); + + // XXX We must do this to avoid sending out messages to the same recipient with the same + // sentTimestamp. If we do this, they'll be considered dupes by the receiver. + ThreadUtil.sleep(5); + } } else { - MessageSender.send(context, outgoingMediaMessage, threadId, forceSms, null, null); + OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, + slideDeck, + body, + System.currentTimeMillis(), + subscriptionId, + expiresIn, + isViewOnce, + ThreadDatabase.DistributionTypes.DEFAULT, + isStory, + null, + null, + Collections.emptyList(), + multiShareArgs.getLinkPreview() != null ? Collections.singletonList(multiShareArgs.getLinkPreview()) + : Collections.emptyList(), + validatedMentions); + + outgoingMessages.add(outgoingMediaMessage); } + if (recipient.isRegistered() && !forceSms) { + for (final OutgoingMediaMessage outgoingMessage : outgoingMessages) { + MessageSender.send(context, new OutgoingSecureMediaMessage(outgoingMessage), threadId, false, null, null); + } + } else { + for (final OutgoingMediaMessage outgoingMessage : outgoingMessages) { + MessageSender.send(context, new OutgoingSecureMediaMessage(outgoingMessage), threadId, forceSms, null, null); + } + } } private static void sendTextMessage(@NonNull Context context, @@ -280,6 +321,7 @@ public final class MultiShareSender { private enum Type { GENERIC_ERROR, + INVALID_SHARE_TO_STORY, MMS_NOT_ENABLED, SUCCESS } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java index a88f74a7e2..ed9308e737 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java @@ -188,7 +188,7 @@ public class ShareActivity extends PassphraseRequiredActivity } @Override - public void onBeforeContactSelected(Optional recipientId, String number, java.util.function.Consumer callback) { + public void onBeforeContactSelected(@NonNull Optional recipientId, String number, @NonNull java.util.function.Consumer callback) { if (disallowMultiShare) { Toast.makeText(this, R.string.ShareActivity__sharing_to_multiple_chats_is, Toast.LENGTH_LONG).show(); callback.accept(false); @@ -527,7 +527,7 @@ public class ShareActivity extends PassphraseRequiredActivity .toArray(RecipientId[]::new)); return Stream.of(recipients) - .map(recipient -> new ShareContactAndThread(recipient.getId(), Util.getOrDefault(existingThreads, recipient.getId(), -1L), recipient.isForceSmsSelection() || !recipient.isRegistered())) + .map(recipient -> new ShareContactAndThread(recipient.getId(), Util.getOrDefault(existingThreads, recipient.getId(), -1L), recipient.isForceSmsSelection() || !recipient.isRegistered(), recipient.isDistributionList())) .collect(Collectors.toSet()); } @@ -542,7 +542,7 @@ public class ShareActivity extends PassphraseRequiredActivity } long existingThread = SignalDatabase.threads().getThreadIdIfExistsFor(recipient.getId()); - return new ShareContactAndThread(recipient.getId(), existingThread, recipient.isForceSmsSelection() || !recipient.isRegistered()); + return new ShareContactAndThread(recipient.getId(), existingThread, recipient.isForceSmsSelection() || !recipient.isRegistered(), recipient.isDistributionList()); } private void validateAvailableRecipients() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContact.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContact.java index 4fb62d4f3c..aea1b9c8b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContact.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContact.java @@ -12,6 +12,11 @@ public final class ShareContact { private final Optional recipientId; private final String number; + public ShareContact(@NonNull RecipientId recipientId) { + this.recipientId = Optional.of(recipientId); + this.number = null; + } + public ShareContact(@NonNull Optional recipientId, @Nullable String number) { this.recipientId = recipientId; this.number = number; diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java index 411d2e4bad..b36c73ac63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java @@ -13,17 +13,20 @@ public final class ShareContactAndThread implements Parcelable { private final RecipientId recipientId; private final long threadId; private final boolean forceSms; + private final boolean isStory; - public ShareContactAndThread(@NonNull RecipientId recipientId, long threadId, boolean forceSms) { + public ShareContactAndThread(@NonNull RecipientId recipientId, long threadId, boolean forceSms, boolean isStory) { this.recipientId = recipientId; this.threadId = threadId; this.forceSms = forceSms; + this.isStory = isStory; } protected ShareContactAndThread(@NonNull Parcel in) { recipientId = in.readParcelable(RecipientId.class.getClassLoader()); threadId = in.readLong(); forceSms = in.readByte() == 1; + isStory = in.readByte() == 1; } public @NonNull RecipientId getRecipientId() { @@ -38,6 +41,10 @@ public final class ShareContactAndThread implements Parcelable { return forceSms; } + public boolean isStory() { + return isStory; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -45,12 +52,13 @@ public final class ShareContactAndThread implements Parcelable { ShareContactAndThread that = (ShareContactAndThread) o; return threadId == that.threadId && forceSms == that.forceSms && + isStory == that.isStory && recipientId.equals(that.recipientId); } @Override public int hashCode() { - return Objects.hash(recipientId, threadId, forceSms); + return Objects.hash(recipientId, threadId, forceSms, isStory); } @Override @@ -63,6 +71,7 @@ public final class ShareContactAndThread implements Parcelable { dest.writeParcelable(recipientId, flags); dest.writeLong(threadId); dest.writeByte((byte) (forceSms ? 1 : 0)); + dest.writeByte((byte) (isStory ? 1 : 0)); } public static final Creator CREATOR = new Creator() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java index bc2dec6cf7..8c0c3b138d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java @@ -55,6 +55,7 @@ import org.thoughtcrime.securesms.jobs.AttachmentMarkUploadedJob; import org.thoughtcrime.securesms.jobs.AttachmentUploadJob; import org.thoughtcrime.securesms.jobs.MmsSendJob; import org.thoughtcrime.securesms.jobs.ProfileKeySendJob; +import org.thoughtcrime.securesms.jobs.PushDistributionListSendJob; import org.thoughtcrime.securesms.jobs.PushGroupSendJob; import org.thoughtcrime.securesms.jobs.PushMediaSendJob; import org.thoughtcrime.securesms.jobs.PushTextSendJob; @@ -272,8 +273,10 @@ public class MessageSender { if (isLocalSelfSend(context, recipient, false)) { sendLocalMediaSelf(context, messageId); - } else if (isGroupPushSend(recipient)) { + } else if (recipient.isPushGroup()) { jobManager.add(new PushGroupSendJob(messageId, recipient.getId(), null, true), messageDependsOnIds, recipient.getId().toQueueKey()); + } else if (recipient.isDistributionList()) { + jobManager.add(new PushDistributionListSendJob(messageId, recipient.getId(), true), messageDependsOnIds, recipient.getId().toQueueKey()); } else { jobManager.add(new PushMediaSendJob(messageId, recipient, true), messageDependsOnIds, recipient.getId().toQueueKey()); } @@ -398,8 +401,10 @@ public class MessageSender { { if (isLocalSelfSend(context, recipient, forceSms)) { sendLocalMediaSelf(context, messageId); - } else if (isGroupPushSend(recipient)) { + } else if (recipient.isPushGroup()) { sendGroupPush(context, recipient, messageId, null, uploadJobIds); + } else if (recipient.isDistributionList()) { + sendDistributionList(context, recipient, messageId, uploadJobIds); } else if (!forceSms && isPushMediaSend(context, recipient)) { sendMediaPush(context, recipient, messageId, uploadJobIds); } else { @@ -446,6 +451,17 @@ public class MessageSender { } } + private static void sendDistributionList(Context context, Recipient recipient, long messageId, @NonNull Collection uploadJobIds) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + + if (uploadJobIds.size() > 0) { + Job groupSend = new PushDistributionListSendJob(messageId, recipient.getId(), !uploadJobIds.isEmpty()); + jobManager.add(groupSend, uploadJobIds, uploadJobIds.isEmpty() ? null : recipient.getId().toQueueKey()); + } else { + PushDistributionListSendJob.enqueue(context, jobManager, messageId, recipient.getId()); + } + } + private static void sendSms(Recipient recipient, long messageId) { JobManager jobManager = ApplicationDependencies.getJobManager(); jobManager.add(new SmsSendJob(messageId, recipient)); @@ -480,10 +496,6 @@ public class MessageSender { return isPushDestination(context, recipient); } - private static boolean isGroupPushSend(Recipient recipient) { - return recipient.isGroup() && !recipient.isMmsGroup(); - } - private static boolean isPushDestination(Context context, Recipient destination) { if (destination.resolve().getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) { return true; diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.java index c68afa9bdc..b48949a7a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.java @@ -110,8 +110,9 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor 0) { + url.text = url.context.getString(R.string.LinkPreviewView_domain_date, domain, formatDate(linkPreview.date)) + url.visibility = View.VISIBLE + } else if (domain != null) { + url.text = domain + url.visibility = View.VISIBLE + } else if (linkPreview.date > 0) { + url.text = formatDate(linkPreview.date) + url.visibility = View.VISIBLE + } else { + url.visibility = View.GONE + } + } + + fun setOnCloseClickListener(onClickListener: OnClickListener?) { + close.setOnClickListener(onClickListener) + } + + fun setCanClose(canClose: Boolean) { + close.visible = canClose + } + + private fun formatDate(date: Long): String? { + val dateFormat: DateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) + return dateFormat.format(date) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt new file mode 100644 index 0000000000..fbb2ba2b1c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt @@ -0,0 +1,232 @@ +package org.thoughtcrime.securesms.stories + +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.util.TypedValue +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.Px +import androidx.appcompat.content.res.AppCompatResources +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.doOnNextLayout +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import com.airbnb.lottie.SimpleColorFilter +import org.signal.core.util.DimensionUnit +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel +import org.thoughtcrime.securesms.mediasend.v2.text.TextAlignment +import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationState +import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryScale +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.visible + +class StoryTextPostView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + init { + inflate(context, R.layout.stories_text_post_view, this) + } + + private var textAlignment: TextAlignment? = null + private val backgroundView: ImageView = findViewById(R.id.text_story_post_background) + private val textView: TextView = findViewById(R.id.text_story_post_text) + private val linkPreviewView: StoryLinkPreviewView = findViewById(R.id.text_story_post_link_preview) + + private var isPlaceholder: Boolean = true + + init { + backgroundView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + textView.maxWidth = backgroundView.measuredWidth - DimensionUnit.DP.toPixels(40f).toInt() + + textAlignment?.apply { + adjustTextTranslationX(this) + } + } + + textView.doAfterTextChanged { + textAlignment?.apply { + adjustTextTranslationX(this) + } + } + } + + fun showCloseButton() { + linkPreviewView.setCanClose(true) + } + + fun hideCloseButton() { + linkPreviewView.setCanClose(false) + } + + fun setTypeface(typeface: Typeface) { + textView.typeface = typeface + } + + fun setPostBackground(drawable: Drawable) { + backgroundView.setImageDrawable(drawable) + } + + fun setTextColor(@ColorInt color: Int) { + textView.setTextColor(color) + } + + fun setText(text: CharSequence, isPlaceholder: Boolean) { + this.isPlaceholder = isPlaceholder + textView.text = text + } + + fun setTextSize(@Px textSize: Float) { + textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) + } + + fun setTextGravity(textAlignment: TextAlignment) { + textView.gravity = textAlignment.gravity + } + + fun setTextScale(scalePercent: Int) { + val scale = TextStoryScale.convertToScale(scalePercent) + textView.scaleX = scale + textView.scaleY = scale + } + + fun setTextVisible(visible: Boolean) { + textView.visible = visible + } + + fun setTextBackgroundColor(@ColorInt color: Int) { + if (color == Color.TRANSPARENT) { + textView.background = null + } else { + textView.background = AppCompatResources.getDrawable(context, R.drawable.rounded_rectangle_secondary_18)?.apply { + colorFilter = SimpleColorFilter(color) + } + } + } + + fun bindFromCreationState(state: TextStoryPostCreationState) { + textAlignment = state.textAlignment + + setPostBackground(state.backgroundColor.chatBubbleMask) + setText( + if (state.body.isEmpty()) { + context.getString(R.string.TextStoryPostCreationFragment__tap_to_add_text) + } else { + state.body + }, + state.body.isEmpty() + ) + + setTextColor(state.textForegroundColor) + setTextSize(state.textSize) + setTextBackgroundColor(state.textBackgroundColor) + setTextGravity(state.textAlignment) + setTextScale(state.textScale) + + postAdjustTextTranslationX(state.textAlignment) + postAdjustLinkPreviewTranslationY() + } + + fun bindLinkPreviewState(linkPreviewState: LinkPreviewViewModel.LinkPreviewState, hiddenVisibility: Int) { + linkPreviewView.bind(linkPreviewState, hiddenVisibility) + } + + fun postAdjustLinkPreviewTranslationY() { + setTextVisible(canDisplayText()) + doOnNextLayout { + adjustLinkPreviewTranslationY() + } + } + + fun postAdjustTextTranslationX(textAlignment: TextAlignment) { + doOnNextLayout { + adjustTextTranslationX(textAlignment) + } + } + + fun setTextViewClickListener(onClickListener: OnClickListener) { + textView.setOnClickListener(onClickListener) + } + + fun setLinkPreviewCloseListener(onClickListener: OnClickListener) { + linkPreviewView.setOnCloseClickListener(onClickListener) + } + + fun showPostContent() { + textView.alpha = 1f + linkPreviewView.alpha = 1f + } + + fun hidePostContent() { + textView.alpha = 0f + linkPreviewView.alpha = 0f + } + + private fun canDisplayText(): Boolean { + return !(linkPreviewView.isVisible && isPlaceholder) + } + + private fun adjustLinkPreviewTranslationY() { + val backgroundHeight = backgroundView.measuredHeight + val textHeight = if (canDisplayText()) textView.measuredHeight * textView.scaleY else 0f + val previewHeight = if (linkPreviewView.visible) linkPreviewView.measuredHeight else 0 + val availableHeight = backgroundHeight - textHeight + + if (availableHeight >= previewHeight) { + val totalContentHeight = textHeight + previewHeight + val topAndBottomMargin = backgroundHeight - totalContentHeight + val margin = topAndBottomMargin / 2f + + linkPreviewView.translationY = -margin + + val originPoint = textView.measuredHeight / 2f + val desiredPoint = (textHeight / 2f) + margin + + textView.translationY = desiredPoint - originPoint + } else { + linkPreviewView.translationY = 0f + + val originPoint = textView.measuredHeight / 2f + val desiredPoint = backgroundHeight / 2f + + textView.translationY = desiredPoint - originPoint + } + } + + private fun alignTextLeft() { + textView.translationX = DimensionUnit.DP.toPixels(20f) + } + + private fun alignTextRight() { + textView.translationX = backgroundView.measuredWidth - textView.measuredWidth - DimensionUnit.DP.toPixels(20f) + } + + private fun adjustTextTranslationX(textAlignment: TextAlignment) { + when (textAlignment) { + TextAlignment.CENTER -> { + textView.translationX = backgroundView.measuredWidth / 2f - textView.measuredWidth / 2f + } + TextAlignment.START -> { + if (ViewUtil.isLtr(textView)) { + alignTextLeft() + } else { + alignTextRight() + } + } + TextAlignment.END -> { + if (ViewUtil.isRtl(textView)) { + alignTextLeft() + } else { + alignTextRight() + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt new file mode 100644 index 0000000000..0969aed381 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt @@ -0,0 +1,237 @@ +package org.thoughtcrime.securesms.stories.dialogs + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.AsyncTask +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.app.ShareCompat +import androidx.fragment.app.Fragment +import io.reactivex.rxjava3.core.Single +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.components.menu.ActionItem +import org.thoughtcrime.securesms.components.menu.SignalContextMenu +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.stories.landing.StoriesLandingItem +import org.thoughtcrime.securesms.stories.my.MyStoriesItem +import org.thoughtcrime.securesms.stories.viewer.page.StoryPost +import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageState +import org.thoughtcrime.securesms.util.DeleteDialog +import org.thoughtcrime.securesms.util.SaveAttachmentTask + +object StoryContextMenu { + + private val TAG = Log.tag(StoryContextMenu::class.java) + + fun delete(context: Context, records: Set): Single { + return DeleteDialog.show( + context = context, + messageRecords = records, + title = context.getString(R.string.MyStories__delete_story), + message = context.getString(R.string.MyStories__this_story_will_be_deleted), + forceRemoteDelete = true + ) + } + + fun save(context: Context, messageRecord: MessageRecord) { + val mediaMessageRecord = messageRecord as? MediaMmsMessageRecord + val uri: Uri? = mediaMessageRecord?.slideDeck?.firstSlide?.uri + val contentType: String? = mediaMessageRecord?.slideDeck?.firstSlide?.contentType + if (uri == null || contentType == null) { + // TODO [stories] Toast that we can't save this media + return + } + + val saveAttachment = SaveAttachmentTask.Attachment( + uri, + contentType, + System.currentTimeMillis(), + null + ) + + SaveAttachmentTask(context) + .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, saveAttachment) + } + + fun share(fragment: Fragment, messageRecord: MediaMmsMessageRecord) { + val attachment: Attachment = messageRecord.slideDeck.firstSlide!!.asAttachment() + val intent: Intent = ShareCompat.IntentBuilder(fragment.requireContext()) + .setStream(attachment.publicUri) + .setType(attachment.contentType) + .createChooserIntent() + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + try { + fragment.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "No activity existed to share the media.", e) + Toast.makeText(fragment.requireContext(), R.string.MediaPreviewActivity_cant_find_an_app_able_to_share_this_media, Toast.LENGTH_LONG).show() + } + } + + fun show( + context: Context, + anchorView: View, + model: StoriesLandingItem.Model, + onDismiss: () -> Unit + ) { + show( + context = context, + anchorView = anchorView, + isFromSelf = model.data.primaryStory.messageRecord.isOutgoing, + isToGroup = model.data.storyRecipient.isGroup, + canHide = !model.data.isHidden, + callbacks = object : Callbacks { + override fun onHide() = model.onHideStory(model) + override fun onUnhide() = model.onHideStory(model) + override fun onForward() = model.onForwardStory(model) + override fun onShare() = model.onShareStory(model) + override fun onGoToChat() = model.onGoToChat(model) + override fun onDismissed() = onDismiss() + override fun onDelete() = model.onDeleteStory(model) + override fun onSave() = model.onSave(model) + } + ) + } + + fun show( + context: Context, + anchorView: View, + storyViewerPageState: StoryViewerPageState, + onHide: (StoryPost) -> Unit, + onForward: (StoryPost) -> Unit, + onShare: (StoryPost) -> Unit, + onGoToChat: (StoryPost) -> Unit, + onSave: (StoryPost) -> Unit, + onDelete: (StoryPost) -> Unit, + onDismiss: () -> Unit + ) { + val selectedStory: StoryPost = storyViewerPageState.posts[storyViewerPageState.selectedPostIndex] + show( + context = context, + anchorView = anchorView, + isFromSelf = selectedStory.sender.isSelf, + isToGroup = selectedStory.group != null, + canHide = true, + callbacks = object : Callbacks { + override fun onHide() = onHide(selectedStory) + override fun onUnhide() = throw NotImplementedError() + override fun onForward() = onForward(selectedStory) + override fun onShare() = onShare(selectedStory) + override fun onGoToChat() = onGoToChat(selectedStory) + override fun onDismissed() = onDismiss() + override fun onSave() = onSave(selectedStory) + override fun onDelete() = onDelete(selectedStory) + } + ) + } + + fun show( + context: Context, + anchorView: View, + myStoriesItemModel: MyStoriesItem.Model, + onDismiss: () -> Unit + ) { + show( + context = context, + anchorView = anchorView, + isFromSelf = true, + isToGroup = false, + canHide = false, + callbacks = object : Callbacks { + override fun onHide() = throw NotImplementedError() + override fun onUnhide() = throw NotImplementedError() + override fun onForward() = myStoriesItemModel.onForwardClick(myStoriesItemModel) + override fun onShare() = myStoriesItemModel.onShareClick(myStoriesItemModel) + override fun onGoToChat() = throw NotImplementedError() + override fun onDismissed() = onDismiss() + override fun onSave() = myStoriesItemModel.onSaveClick(myStoriesItemModel) + override fun onDelete() = myStoriesItemModel.onDeleteClick(myStoriesItemModel) + } + ) + } + + private fun show( + context: Context, + anchorView: View, + isFromSelf: Boolean, + isToGroup: Boolean, + rootView: ViewGroup = anchorView.rootView as ViewGroup, + canHide: Boolean, + callbacks: Callbacks + ) { + val actions = mutableListOf().apply { + if (!isFromSelf || isToGroup) { + if (canHide) { + add( + ActionItem(R.drawable.ic_circle_x_24_tinted, context.getString(R.string.StoriesLandingItem__hide_story)) { + callbacks.onHide() + } + ) + } else { + // TODO [stories] -- Final icon + add( + ActionItem(R.drawable.ic_check_circle_24, context.getString(R.string.StoriesLandingItem__unhide_story)) { + callbacks.onUnhide() + } + ) + } + } + + if (isFromSelf) { + add( + ActionItem(R.drawable.ic_forward_24_tinted, context.getString(R.string.StoriesLandingItem__forward)) { + callbacks.onForward() + } + ) + add( + ActionItem(R.drawable.ic_share_24_tinted, context.getString(R.string.StoriesLandingItem__share)) { + callbacks.onShare() + } + ) + add( + ActionItem(R.drawable.ic_delete_24_tinted, context.getString(R.string.delete)) { + callbacks.onDelete() + } + ) + add( + ActionItem(R.drawable.ic_download_24_tinted, context.getString(R.string.save)) { + callbacks.onSave() + } + ) + } + + if (isToGroup || !isFromSelf) { + add( + ActionItem(R.drawable.ic_open_24_tinted, context.getString(R.string.StoriesLandingItem__go_to_chat)) { + callbacks.onGoToChat() + } + ) + } + } + + SignalContextMenu.Builder(anchorView, rootView) + .preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START) + .onDismiss { + callbacks.onDismissed() + } + .show(actions) + } + + private interface Callbacks { + fun onHide() + fun onUnhide() + fun onForward() + fun onShare() + fun onGoToChat() + fun onDismissed() + fun onSave() + fun onDelete() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryDialogs.kt new file mode 100644 index 0000000000..cf810e25d2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryDialogs.kt @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.stories.dialogs + +import android.content.Context +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.thoughtcrime.securesms.R + +object StoryDialogs { + + /** + * Guards onAddToStory with a dialog + */ + fun guardWithAddToYourStoryDialog( + context: Context, + onAddToStory: () -> Unit, + onEditViewers: () -> Unit, + onCancel: () -> Unit = {} + ) { + MaterialAlertDialogBuilder(context, R.style.Signal_ThemeOverlay_Dialog_Rounded) + .setTitle(R.string.StoryDialogs__add_to_story_q) + .setMessage(R.string.StoryDialogs__adding_content) + .setPositiveButton(R.string.StoryDialogs__add_to_story) { _, _ -> onAddToStory.invoke() } + .setNeutralButton(R.string.StoryDialogs__edit_viewers) { _, _ -> onEditViewers.invoke() } + .setNegativeButton(android.R.string.cancel) { _, _ -> onCancel.invoke() } + .setCancelable(false) + .show() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/ExpandHeader.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/ExpandHeader.kt new file mode 100644 index 0000000000..7965d75bc8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/ExpandHeader.kt @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.stories.landing + +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder + +/** + * Header that expands a section underneath it. + */ +object ExpandHeader { + + fun register(mappingAdapter: MappingAdapter) { + mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.expand_header)) + } + + class Model( + override val title: DSLSettingsText, + val isExpanded: Boolean, + val onClick: (Boolean) -> Unit + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean = true + + override fun areContentsTheSame(newItem: Model): Boolean { + return super.areContentsTheSame(newItem) && + isExpanded == newItem.isExpanded + } + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val sectionHeader: TextView = itemView.findViewById(R.id.section_header) + private val icon: ImageView = itemView.findViewById(R.id.icon) + + override fun bind(model: Model) { + sectionHeader.text = model.title.resolve(context) + icon.setImageResource(if (model.isExpanded) R.drawable.ic_chevron_up_24 else R.drawable.ic_chevron_down_24) + icon.setColorFilter(ContextCompat.getColor(context, R.color.signal_icon_tint_primary)) + itemView.setOnClickListener { model.onClick(!model.isExpanded) } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/MyStoriesItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/MyStoriesItem.kt new file mode 100644 index 0000000000..027035bb98 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/MyStoriesItem.kt @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.stories.landing + +import android.view.View +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder + +/** + * Item displayed on an empty Stories landing page allowing the user to add a new story. + */ +object MyStoriesItem { + + fun register(mappingAdapter: MappingAdapter) { + mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.stories_landing_item_my_stories)) + } + + class Model( + val onClick: () -> Unit + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean = true + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + override fun bind(model: Model) { + itemView.setOnClickListener { model.onClick() } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt new file mode 100644 index 0000000000..56e77b524e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt @@ -0,0 +1,195 @@ +package org.thoughtcrime.securesms.stories.landing + +import android.Manifest +import android.content.Intent +import android.graphics.Color +import android.view.MenuItem +import android.view.View +import android.widget.Toast +import androidx.fragment.app.viewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.BaseTransientBottomBar +import com.google.android.material.snackbar.Snackbar +import org.thoughtcrime.securesms.MainNavigator +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment +import org.thoughtcrime.securesms.components.settings.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment +import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu +import org.thoughtcrime.securesms.stories.my.MyStoriesActivity +import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity +import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel +import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.visible + +/** + * The "landing page" for Stories. + */ +class StoriesLandingFragment : + DSLSettingsFragment( + layoutId = R.layout.stories_landing_fragment, + menuId = R.menu.story_landing_menu, + titleId = R.string.ConversationListTabs__stories + ), + MainNavigator.BackHandler { + + private lateinit var emptyNotice: View + private lateinit var cameraFab: View + + private val lifecycleDisposable = LifecycleDisposable() + + private val viewModel: StoriesLandingViewModel by viewModels( + factoryProducer = { + StoriesLandingViewModel.Factory(StoriesLandingRepository(requireContext())) + } + ) + + private val tabsViewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() }) + + override fun bindAdapter(adapter: DSLSettingsAdapter) { + StoriesLandingItem.register(adapter) + MyStoriesItem.register(adapter) + ExpandHeader.register(adapter) + + lifecycleDisposable.bindTo(viewLifecycleOwner) + emptyNotice = requireView().findViewById(R.id.empty_notice) + cameraFab = requireView().findViewById(R.id.camera_fab) + + cameraFab.setOnClickListener { + Permissions.with(requireActivity()) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_camera_24) + .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video)) + .onAllGranted { startActivity(MediaSelectionActivity.camera(requireContext())) } + .onAnyDenied { Toast.makeText(requireContext(), R.string.ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video, Toast.LENGTH_LONG).show() } + .execute() + } + + viewModel.state.observe(viewLifecycleOwner) { + adapter.submitList(getConfiguration(it).toMappingModelList()) + emptyNotice.visible = it.storiesLandingItems.isEmpty() + } + } + + private fun getConfiguration(state: StoriesLandingState): DSLConfiguration { + return configure { + val (stories, hidden) = state.storiesLandingItems.map { + createStoryLandingItem(it) + }.partition { + !it.data.isHidden + } + + if (state.displayMyStoryItem) { + customPref( + MyStoriesItem.Model( + onClick = { + cameraFab.performClick() + } + ) + ) + } + + stories.forEach { item -> + customPref(item) + } + + if (hidden.isNotEmpty()) { + customPref( + ExpandHeader.Model( + title = DSLSettingsText.from(R.string.StoriesLandingFragment__hidden_stories), + isExpanded = state.isHiddenContentVisible, + onClick = { viewModel.setHiddenContentVisible(it) } + ) + ) + } + + if (state.isHiddenContentVisible) { + hidden.forEach { item -> + customPref(item) + } + } + } + } + + private fun createStoryLandingItem(data: StoriesLandingItemData): StoriesLandingItem.Model { + return StoriesLandingItem.Model( + data = data, + onRowClick = { + if (it.data.storyRecipient.isMyStory) { + startActivity(Intent(requireContext(), MyStoriesActivity::class.java)) + } else { + startActivity(StoryViewerActivity.createIntent(requireContext(), it.data.storyRecipient.id)) + } + }, + onForwardStory = { + MultiselectForwardFragmentArgs.create(requireContext(), it.data.primaryStory.multiselectCollection.toSet()) { args -> + MultiselectForwardFragment.showBottomSheet(childFragmentManager, args) + } + }, + onGoToChat = { + startActivity(ConversationIntents.createBuilder(requireContext(), it.data.storyRecipient.id, -1L).build()) + }, + onHideStory = { + if (!it.data.isHidden) { + handleHideStory(it) + } else { + lifecycleDisposable += viewModel.setHideStory(it.data.storyRecipient, it.data.isHidden).subscribe() + } + }, + onShareStory = { + StoryContextMenu.share(this@StoriesLandingFragment, it.data.primaryStory as MediaMmsMessageRecord) + }, + onSave = { + StoryContextMenu.save(requireContext(), it.data.primaryStory.messageRecord) + }, + onDeleteStory = { + handleDeleteStory(it) + } + ) + } + + private fun handleDeleteStory(model: StoriesLandingItem.Model) { + lifecycleDisposable += StoryContextMenu.delete(requireContext(), setOf(model.data.primaryStory.messageRecord)).subscribe() + } + + private fun handleHideStory(model: StoriesLandingItem.Model) { + MaterialAlertDialogBuilder(requireContext(), R.style.Signal_ThemeOverlay_Dialog_Rounded) + .setTitle(R.string.StoriesLandingFragment__hide_story) + .setMessage(getString(R.string.StoriesLandingFragment__new_story_updates, model.data.storyRecipient.getShortDisplayName(requireContext()))) + .setPositiveButton(R.string.StoriesLandingFragment__hide) { _, _ -> + viewModel.setHideStory(model.data.storyRecipient, true).subscribe { + Snackbar.make(cameraFab, R.string.StoriesLandingFragment__story_hidden, Snackbar.LENGTH_SHORT) + .setTextColor(Color.WHITE) + .setAnchorView(cameraFab) + .setAnimationMode(BaseTransientBottomBar.ANIMATION_MODE_FADE) + .show() + } + } + .setNegativeButton(android.R.string.cancel) { _, _ -> } + .show() + } + + override fun onBackPressed(): Boolean { + tabsViewModel.onChatsSelected() + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return if (item.itemId == R.id.action_settings) { + startActivity(StorySettingsActivity.getIntent(requireContext())) + true + } else { + false + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt new file mode 100644 index 0000000000..e4f9af5b8f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt @@ -0,0 +1,126 @@ +package org.thoughtcrime.securesms.stories.landing + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.view.AvatarView +import org.thoughtcrime.securesms.components.ThumbnailView +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder +import org.thoughtcrime.securesms.util.visible +import java.util.Locale + +/** + * Items displaying a preview and metadata for a story from a user, allowing them to launch into the story viewer. + */ +object StoriesLandingItem { + fun register(mappingAdapter: MappingAdapter) { + mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.stories_landing_item)) + } + + class Model( + val data: StoriesLandingItemData, + val onRowClick: (Model) -> Unit, + val onHideStory: (Model) -> Unit, + val onForwardStory: (Model) -> Unit, + val onShareStory: (Model) -> Unit, + val onGoToChat: (Model) -> Unit, + val onSave: (Model) -> Unit, + val onDeleteStory: (Model) -> Unit + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean { + return data.storyRecipient.id == newItem.data.storyRecipient.id + } + + override fun areContentsTheSame(newItem: Model): Boolean { + return data.storyRecipient.hasSameContent(newItem.data.storyRecipient) && + data == newItem.data && + super.areContentsTheSame(newItem) + } + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val avatarView: AvatarView = itemView.findViewById(R.id.avatar) + private val storyPreview: ThumbnailView = itemView.findViewById(R.id.story) + private val storyMulti: ThumbnailView = itemView.findViewById(R.id.story_multi) + private val sender: TextView = itemView.findViewById(R.id.sender) + private val date: TextView = itemView.findViewById(R.id.date) + private val icon: ImageView = itemView.findViewById(R.id.icon) + + override fun bind(model: Model) { + itemView.setOnClickListener { model.onRowClick(model) } + + if (model.data.storyRecipient.isMyStory) { + itemView.setOnLongClickListener(null) + avatarView.displayProfileAvatar(Recipient.self()) + } else { + itemView.setOnLongClickListener { + displayContext(model) + true + } + + avatarView.displayProfileAvatar(model.data.storyRecipient) + } + + val record = model.data.primaryStory.messageRecord as MediaMmsMessageRecord + + avatarView.showStoryRing(model.data.hasUnreadStory) + storyPreview.setImageResource(GlideApp.with(storyPreview), record.slideDeck.thumbnailSlide!!, false, true) + + if (model.data.secondaryStory != null) { + val secondaryRecord = model.data.secondaryStory.messageRecord as MediaMmsMessageRecord + storyMulti.setImageResource(GlideApp.with(storyPreview), secondaryRecord.slideDeck.thumbnailSlide!!, false, true) + storyMulti.visible = true + } else { + storyMulti.visible = false + } + + sender.text = when { + model.data.storyRecipient.isMyStory -> context.getText(R.string.StoriesLandingFragment__my_stories) + model.data.storyRecipient.isGroup -> getGroupPresentation(model) + else -> model.data.storyRecipient.getDisplayName(context) + } + + date.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), model.data.dateInMilliseconds) + icon.visible = model.data.hasReplies || model.data.hasRepliesFromSelf + // TODO [stories] -- Set actual image resource + icon.setImageDrawable(ColorDrawable(Color.RED)) + + listOf(avatarView, storyPreview, storyMulti, sender, date, icon).forEach { + it.alpha = if (model.data.isHidden) 0.5f else 1f + } + } + + private fun getGroupPresentation(model: Model): String { + return context.getString( + R.string.StoryViewerPageFragment__s_to_s, + getIndividualPresentation(model), + model.data.storyRecipient.getDisplayName(context) + ) + } + + private fun getIndividualPresentation(model: Model): String { + return if (model.data.primaryStory.messageRecord.isOutgoing) { + context.getString(R.string.Recipient_you) + } else { + model.data.individualRecipient.getDisplayName(context) + } + } + + private fun displayContext(model: Model) { + itemView.isSelected = true + StoryContextMenu.show(context, itemView, model) { itemView.isSelected = false } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItemData.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItemData.kt new file mode 100644 index 0000000000..46c81861a7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItemData.kt @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.stories.landing + +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * Data required by each row of the Stories Landing Page for proper rendering. + */ +data class StoriesLandingItemData( + val hasUnreadStory: Boolean, + val hasReplies: Boolean, + val hasRepliesFromSelf: Boolean, + val isHidden: Boolean, + val primaryStory: ConversationMessage, + val secondaryStory: ConversationMessage?, + val storyRecipient: Recipient, + val individualRecipient: Recipient = primaryStory.messageRecord.individualRecipient, + val dateInMilliseconds: Long = primaryStory.messageRecord.dateSent +) : Comparable { + override fun compareTo(other: StoriesLandingItemData): Int { + return if (storyRecipient.isMyStory && !other.storyRecipient.isMyStory) { + -1 + } else if (!storyRecipient.isMyStory && other.storyRecipient.isMyStory) { + 1 + } else { + -dateInMilliseconds.compareTo(other.dateInMilliseconds) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingRepository.kt new file mode 100644 index 0000000000..ca429c1ddb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingRepository.kt @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.stories.landing + +import android.content.Context +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.database.DatabaseObserver +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientForeverObserver +import org.thoughtcrime.securesms.recipients.RecipientId + +class StoriesLandingRepository(context: Context) { + + private val context = context.applicationContext + + fun getStories(): Observable> { + return Observable.create>> { emitter -> + val myStoriesId = SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY) + val myStories = Recipient.resolved(myStoriesId) + + fun refresh() { + val storyMap = mutableMapOf>() + SignalDatabase.mms.allStories.use { + while (it.next != null) { + val messageRecord = it.current + val recipient = if (messageRecord.isOutgoing && !messageRecord.recipient.isGroup) { + myStories + } else if (messageRecord.isOutgoing && messageRecord.recipient.isGroup) { + messageRecord.recipient + } else { + SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId)!! + } + + storyMap[recipient] = (storyMap[recipient] ?: emptyList()) + messageRecord + } + } + + val data: List> = storyMap.map { (sender, records) -> createStoriesLandingItemData(sender, records) } + if (data.isEmpty()) { + emitter.onNext(Observable.just(emptyList())) + } else { + emitter.onNext(Observable.combineLatest(data) { it.toList() as List }) + } + } + + val observer = DatabaseObserver.Observer { + refresh() + } + + ApplicationDependencies.getDatabaseObserver().registerConversationListObserver(observer) + emitter.setCancellable { + ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer) + } + + refresh() + }.switchMap { it }.subscribeOn(Schedulers.io()) + } + + private fun createStoriesLandingItemData(sender: Recipient, messageRecords: List): Observable { + return Observable.create { emitter -> + fun refresh() { + val itemData = StoriesLandingItemData( + storyRecipient = sender, + hasUnreadStory = messageRecords.any { it.readReceiptCount == 0 && !it.isOutgoing }, + hasReplies = messageRecords.any { SignalDatabase.mms.getNumberOfStoryReplies(it.id) > 0 }, + hasRepliesFromSelf = messageRecords.any { SignalDatabase.mms.hasSelfReplyInStory(it.id) }, + isHidden = Recipient.resolved(messageRecords.first().recipient.id).shouldHideStory(), + primaryStory = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, messageRecords.first()), + secondaryStory = messageRecords.drop(1).firstOrNull()?.let { + ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it) + } + ) + + emitter.onNext(itemData) + } + + val newRepliesObserver = DatabaseObserver.Observer { + refresh() + } + + val recipientChangedObserver = RecipientForeverObserver { + refresh() + } + + ApplicationDependencies.getDatabaseObserver().registerConversationObserver(messageRecords.first().threadId, newRepliesObserver) + val liveRecipient = Recipient.live(messageRecords.first().recipient.id) + liveRecipient.observeForever(recipientChangedObserver) + + emitter.setCancellable { + ApplicationDependencies.getDatabaseObserver().unregisterObserver(newRepliesObserver) + liveRecipient.removeForeverObserver(recipientChangedObserver) + } + + refresh() + } + } + + fun setHideStory(recipientId: RecipientId, hideStory: Boolean): Completable { + return Completable.fromAction { + SignalDatabase.recipients.setHideStory(recipientId, hideStory) + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingState.kt new file mode 100644 index 0000000000..2f01659a5b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingState.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.stories.landing + +data class StoriesLandingState( + val storiesLandingItems: List = emptyList(), + val displayMyStoryItem: Boolean = false, + val isHiddenContentVisible: Boolean = false +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingViewModel.kt new file mode 100644 index 0000000000..87025c2e17 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingViewModel.kt @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.stories.landing + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.livedata.Store + +class StoriesLandingViewModel(private val storiesLandingRepository: StoriesLandingRepository) : ViewModel() { + private val store = Store(StoriesLandingState()) + private val disposables = CompositeDisposable() + + val state: LiveData = store.stateLiveData + + init { + disposables += storiesLandingRepository.getStories().subscribe { stories -> + store.update { state -> + state.copy( + storiesLandingItems = stories.sorted(), + displayMyStoryItem = stories.isEmpty() || stories.none { it.storyRecipient.isMyStory } + ) + } + } + } + + override fun onCleared() { + disposables.clear() + } + + fun setHideStory(sender: Recipient, hide: Boolean): Completable { + return storiesLandingRepository.setHideStory(sender.id, hide) + } + + fun setHiddenContentVisible(isExpanded: Boolean) { + store.update { it.copy(isHiddenContentVisible = isExpanded) } + } + + class Factory(private val storiesLandingRepository: StoriesLandingRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(StoriesLandingViewModel(storiesLandingRepository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesActivity.kt new file mode 100644 index 0000000000..109e31a9bc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesActivity.kt @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.stories.my + +import androidx.fragment.app.Fragment +import org.thoughtcrime.securesms.components.FragmentWrapperActivity + +class MyStoriesActivity : FragmentWrapperActivity() { + override fun getFragment(): Fragment { + return MyStoriesFragment() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt new file mode 100644 index 0000000000..3946eef5e3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.stories.my + +import androidx.fragment.app.viewModels +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment +import org.thoughtcrime.securesms.components.settings.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment +import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu +import org.thoughtcrime.securesms.util.LifecycleDisposable + +class MyStoriesFragment : DSLSettingsFragment( + titleId = R.string.StoriesLandingFragment__my_stories +) { + + private val lifecycleDisposable = LifecycleDisposable() + + private val viewModel: MyStoriesViewModel by viewModels( + factoryProducer = { + MyStoriesViewModel.Factory(MyStoriesRepository(requireContext())) + } + ) + + override fun bindAdapter(adapter: DSLSettingsAdapter) { + MyStoriesItem.register(adapter) + + lifecycleDisposable.bindTo(viewLifecycleOwner) + viewModel.state.observe(viewLifecycleOwner) { + adapter.submitList(getConfiguration(it).toMappingModelList()) + if (it.distributionSets.isEmpty()) { + requireActivity().finish() + } + } + } + + private fun getConfiguration(state: MyStoriesState): DSLConfiguration { + return configure { + val nonEmptySets = state.distributionSets.filter { it.stories.isNotEmpty() } + nonEmptySets + .forEachIndexed { index, distributionSet -> + sectionHeaderPref( + if (distributionSet.label == null) { + DSLSettingsText.from(getString(R.string.MyStories__ss_story, Recipient.self().getShortDisplayName(requireContext()))) + } else { + DSLSettingsText.from(distributionSet.label) + } + ) + distributionSet.stories.forEach { conversationMessage -> + customPref( + MyStoriesItem.Model( + distributionStory = conversationMessage, + onSaveClick = { + StoryContextMenu.save(requireContext(), it.distributionStory.messageRecord) + }, + onDeleteClick = this@MyStoriesFragment::handleDeleteClick, + onForwardClick = { item -> + MultiselectForwardFragmentArgs.create( + requireContext(), + item.distributionStory.multiselectCollection.toSet() + ) { + MultiselectForwardFragment.showBottomSheet(childFragmentManager, it) + } + }, + onShareClick = { + StoryContextMenu.share(this@MyStoriesFragment, it.distributionStory.messageRecord as MediaMmsMessageRecord) + } + ) + ) + } + + if (index != nonEmptySets.lastIndex) { + dividerPref() + } + } + } + } + + private fun handleDeleteClick(model: MyStoriesItem.Model) { + lifecycleDisposable += StoryContextMenu.delete(requireContext(), setOf(model.distributionStory.messageRecord)).subscribe() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesItem.kt new file mode 100644 index 0000000000..c4d66603ac --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesItem.kt @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.stories.my + +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ThumbnailView +import org.thoughtcrime.securesms.components.menu.ActionItem +import org.thoughtcrime.securesms.components.menu.SignalContextMenu +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder +import java.util.Locale + +object MyStoriesItem { + + fun register(mappingAdapter: MappingAdapter) { + mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.stories_my_stories_item)) + } + + class Model( + val distributionStory: ConversationMessage, + val onSaveClick: (Model) -> Unit, + val onDeleteClick: (Model) -> Unit, + val onForwardClick: (Model) -> Unit, + val onShareClick: (Model) -> Unit + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean { + return distributionStory.messageRecord.id == newItem.distributionStory.messageRecord.id + } + + override fun areContentsTheSame(newItem: Model): Boolean { + return distributionStory == newItem.distributionStory && super.areContentsTheSame(newItem) + } + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val downloadTarget: View = itemView.findViewById(R.id.download_touch) + private val moreTarget: View = itemView.findViewById(R.id.more_touch) + private val storyPreview: ThumbnailView = itemView.findViewById(R.id.story) + private val viewCount: TextView = itemView.findViewById(R.id.view_count) + private val date: TextView = itemView.findViewById(R.id.date) + + override fun bind(model: Model) { + downloadTarget.setOnClickListener { model.onSaveClick(model) } + moreTarget.setOnClickListener { showContextMenu(model) } + viewCount.text = context.resources.getQuantityString(R.plurals.MyStories__d_views, model.distributionStory.messageRecord.readReceiptCount, model.distributionStory.messageRecord.readReceiptCount) + date.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), model.distributionStory.messageRecord.dateSent) + + val thumbnail = (model.distributionStory.messageRecord as MmsMessageRecord).slideDeck.thumbnailSlide + if (thumbnail != null) { + storyPreview.setImageResource(GlideApp.with(itemView), thumbnail, false, true) + } else { + storyPreview.clear(GlideApp.with(itemView)) + } + } + + private fun showContextMenu(model: Model) { + SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup) + .preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.END) + .show( + listOf( + ActionItem(R.drawable.ic_delete_24_tinted, context.getString(R.string.delete)) { model.onDeleteClick(model) }, + ActionItem(R.drawable.ic_download_24_tinted, context.getString(R.string.save)) { model.onSaveClick(model) }, + ActionItem(R.drawable.ic_forward_24_tinted, context.getString(R.string.MyStories_forward)) { model.onForwardClick(model) }, + ActionItem(R.drawable.ic_share_24_tinted, context.getString(R.string.StoriesLandingItem__share)) { model.onShareClick(model) } + ) + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesRepository.kt new file mode 100644 index 0000000000..5e5cd56413 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesRepository.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.stories.my + +import android.content.Context +import io.reactivex.rxjava3.core.Observable +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.database.DatabaseObserver +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.recipients.Recipient + +class MyStoriesRepository(context: Context) { + + private val context = context.applicationContext + + fun getMyStories(): Observable> { + return Observable.create { emitter -> + fun refresh() { + val storiesMap = mutableMapOf>() + SignalDatabase.mms.allOutgoingStories.use { + while (it.next != null) { + val messageRecord = it.current + val currentList = storiesMap[messageRecord.recipient] ?: emptyList() + storiesMap[messageRecord.recipient] = (currentList + messageRecord) + } + } + + emitter.onNext(storiesMap.map { (r, m) -> createDistributionSet(r, m) }) + } + + val observer = DatabaseObserver.Observer { + refresh() + } + + ApplicationDependencies.getDatabaseObserver().registerConversationListObserver(observer) + emitter.setCancellable { + ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer) + } + + refresh() + } + } + + private fun createDistributionSet(recipient: Recipient, messageRecords: List): MyStoriesState.DistributionSet { + return MyStoriesState.DistributionSet( + label = recipient.getDisplayName(context), + stories = messageRecords.map { + ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it) + } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesState.kt new file mode 100644 index 0000000000..87bcf5ed33 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesState.kt @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.stories.my + +import org.thoughtcrime.securesms.conversation.ConversationMessage + +data class MyStoriesState( + val distributionSets: List = emptyList() +) { + + data class DistributionSet( + val label: String?, + val stories: List + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesViewModel.kt new file mode 100644 index 0000000000..24fdb79640 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesViewModel.kt @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.stories.my + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.thoughtcrime.securesms.util.livedata.Store + +class MyStoriesViewModel(private val repository: MyStoriesRepository) : ViewModel() { + + private val store = Store(MyStoriesState()) + private val disposables = CompositeDisposable() + + val state: LiveData = store.stateLiveData + + init { + disposables += repository.getMyStories().subscribe { distributionSets -> + store.update { it.copy(distributionSets = distributionSets) } + } + } + + override fun onCleared() { + disposables.clear() + } + + class Factory(private val repository: MyStoriesRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(MyStoriesViewModel(repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/StorySettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/StorySettingsActivity.kt new file mode 100644 index 0000000000..cf78095bc2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/StorySettingsActivity.kt @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.stories.settings + +import android.content.Context +import android.content.Intent +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity + +class StorySettingsActivity : DSLSettingsActivity() { + companion object { + fun getIntent(context: Context): Intent { + return Intent(context, StorySettingsActivity::class.java) + .putExtra(ARG_NAV_GRAPH, R.navigation.story_settings) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryFlowDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryFlowDialogFragment.kt new file mode 100644 index 0000000000..701a2d42da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryFlowDialogFragment.kt @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.stories.settings.create + +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.settings.select.BaseStoryRecipientSelectionFragment + +class CreateStoryFlowDialogFragment : DialogFragment(R.layout.create_story_flow_dialog_fragment), BaseStoryRecipientSelectionFragment.Callback, CreateStoryWithViewersFragment.Callback { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen) + } + + override fun exitFlow() { + dismissAllowingStateLoss() + } + + override fun onDone(recipientId: RecipientId) { + setFragmentResult( + CreateStoryWithViewersFragment.REQUEST_KEY, + Bundle().apply { + putParcelable(CreateStoryWithViewersFragment.STORY_RECIPIENT, recipientId) + } + ) + dismissAllowingStateLoss() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryNameFieldItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryNameFieldItem.kt new file mode 100644 index 0000000000..b891db4de4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryNameFieldItem.kt @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.stories.settings.create + +import android.view.View +import android.widget.EditText +import androidx.core.widget.doAfterTextChanged +import com.google.android.material.textfield.TextInputLayout +import org.signal.core.util.EditTextUtil +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder + +/** + * Field that user can utilize to enter the name of a new distribution list. + */ +object CreateStoryNameFieldItem { + + fun register(adapter: MappingAdapter, onTextChanged: (CharSequence) -> Unit) { + adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onTextChanged) }, R.layout.stories_create_story_name_field_item)) + } + + class Model( + val body: CharSequence, + val error: CharSequence?, + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean = true + override fun areContentsTheSame(newItem: Model): Boolean { + return super.areContentsTheSame(newItem) && body == newItem.body && error == newItem.error + } + } + + class ViewHolder(itemView: View, onTextChanged: (CharSequence) -> Unit) : MappingViewHolder(itemView) { + + private val editTextWrapper: TextInputLayout = itemView.findViewById(R.id.edit_text_wrapper) + private val editText: EditText = itemView.findViewById(R.id.edit_text).apply { + EditTextUtil.addGraphemeClusterLimitFilter(this, 23) + doAfterTextChanged { + if (it != null) { + onTextChanged(it) + } + } + } + + override fun bind(model: Model) { + if (model.body != editText.text) { + editText.setText(model.body) + } + + editTextWrapper.error = model.error + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryViewerSelectionFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryViewerSelectionFragment.kt new file mode 100644 index 0000000000..d8cfa11949 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryViewerSelectionFragment.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.stories.settings.create + +import androidx.navigation.fragment.findNavController +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.settings.select.BaseStoryRecipientSelectionFragment +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +/** + * Allows user to select who will see the story they are creating + */ +class CreateStoryViewerSelectionFragment : BaseStoryRecipientSelectionFragment() { + override val actionButtonLabel: Int = R.string.CreateStoryViewerSelectionFragment__next + override val distributionListId: DistributionListId? = null + + override fun goToNextScreen(recipients: Set) { + findNavController().safeNavigate(CreateStoryViewerSelectionFragmentDirections.actionCreateStoryViewerSelectionToCreateStoryWithViewers(recipients.toTypedArray())) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersFragment.kt new file mode 100644 index 0000000000..ebf6d41f12 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersFragment.kt @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.stories.settings.create + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.fragments.findListener +import org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel +import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder + +/** + * Creates a new distribution list with the passed set of viewers and entered distribution label. + */ +class CreateStoryWithViewersFragment : DSLSettingsFragment( + titleId = R.string.CreateStoryWithViewersFragment__name_story, + layoutId = R.layout.stories_create_with_recipients_fragment +) { + + companion object { + const val REQUEST_KEY = "new-story" + const val STORY_RECIPIENT = "story-recipient" + } + + private val viewModel: CreateStoryWithViewersViewModel by viewModels( + factoryProducer = { + CreateStoryWithViewersViewModel.Factory(CreateStoryWithViewersRepository()) + } + ) + + private val recipientIds: Array + get() = CreateStoryWithViewersFragmentArgs.fromBundle(requireArguments()).recipients + + override fun bindAdapter(adapter: DSLSettingsAdapter) { + adapter.registerFactory(RecipientMappingModel.RecipientIdMappingModel::class.java, LayoutFactory({ RecipientViewHolder(it, null) }, R.layout.stories_recipient_item)) + CreateStoryNameFieldItem.register(adapter) { + viewModel.setLabel(it) + } + + val createButton: View = requireView().findViewById(R.id.create) + createButton.setOnClickListener { viewModel.create(recipientIds.toSet()) } + + viewModel.state.observe(viewLifecycleOwner) { state -> + adapter.submitList(getConfiguration(state).toMappingModelList()) + + when (state.saveState) { + CreateStoryWithViewersState.SaveState.Init -> createButton.setCanPress(state.label.isNotEmpty()) + CreateStoryWithViewersState.SaveState.Saving -> createButton.setCanPress(false) + is CreateStoryWithViewersState.SaveState.Saved -> onDone(state.saveState.recipientId) + } + } + } + + private fun View.setCanPress(canPress: Boolean) { + isEnabled = canPress + alpha = if (canPress) 1f else 0.5f + } + + private fun getConfiguration(state: CreateStoryWithViewersState): DSLConfiguration { + return configure { + customPref( + CreateStoryNameFieldItem.Model( + body = state.label, + error = presentError(state.error) + ) + ) + + dividerPref() + + sectionHeaderPref(R.string.CreateStoryWithViewersFragment__viewers) + + recipientIds.forEach { + customPref(RecipientMappingModel.RecipientIdMappingModel(it)) + } + } + } + + private fun presentError(error: CreateStoryWithViewersState.NameError?): String? { + return when (error) { + CreateStoryWithViewersState.NameError.NO_LABEL -> getString(R.string.CreateStoryWithViewersFragment__this_field_is_required) + CreateStoryWithViewersState.NameError.DUPLICATE_LABEL -> getString(R.string.CreateStoryWithViewersFragment__there_is_already_a_story_with_this_name) + else -> null + } + } + + private fun onDone(recipientId: RecipientId) { + val callback: Callback? = findListener() + if (callback != null) { + callback.onDone(recipientId) + } else { + setFragmentResult( + REQUEST_KEY, + Bundle().apply { + putParcelable(STORY_RECIPIENT, recipientId) + } + ) + findNavController().popBackStack(R.id.createStoryViewerSelection, true) + } + } + + interface Callback { + fun onDone(recipientId: RecipientId) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersRepository.kt new file mode 100644 index 0000000000..f95208c51f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersRepository.kt @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.stories.settings.create + +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.recipients.RecipientId + +class CreateStoryWithViewersRepository { + fun createList(name: CharSequence, members: Set): Single { + return Single.create { + val result = SignalDatabase.distributionLists.createList(name.toString(), members.toList()) + if (result == null) { + it.onError(Exception("Null result, due to a duplicated name.")) + } else { + it.onSuccess(SignalDatabase.recipients.getOrInsertFromDistributionListId(result)) + } + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersState.kt new file mode 100644 index 0000000000..c72a26e61f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersState.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.stories.settings.create + +import org.thoughtcrime.securesms.recipients.RecipientId + +data class CreateStoryWithViewersState( + val label: CharSequence = "", + val error: NameError? = null, + val saveState: SaveState = SaveState.Init +) { + enum class NameError { + NO_LABEL, + DUPLICATE_LABEL + } + + sealed class SaveState { + object Init : SaveState() + object Saving : SaveState() + data class Saved(val recipientId: RecipientId) : SaveState() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersViewModel.kt new file mode 100644 index 0000000000..120bb3d1da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersViewModel.kt @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.stories.settings.create + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.livedata.Store + +class CreateStoryWithViewersViewModel( + private val repository: CreateStoryWithViewersRepository +) : ViewModel() { + + private val store = Store(CreateStoryWithViewersState()) + private val disposables = CompositeDisposable() + + val state: LiveData = store.stateLiveData + + override fun onCleared() { + disposables.clear() + } + + fun setLabel(label: CharSequence) { + store.update { it.copy(label = label) } + } + + fun create(members: Set) { + store.update { it.copy(saveState = CreateStoryWithViewersState.SaveState.Saving) } + + val label = store.state.label + if (label.isEmpty()) { + store.update { + it.copy( + error = CreateStoryWithViewersState.NameError.NO_LABEL, + saveState = CreateStoryWithViewersState.SaveState.Init + ) + } + } + + disposables += repository.createList(label, members).subscribeBy( + onSuccess = { recipientId -> + store.update { + it.copy(saveState = CreateStoryWithViewersState.SaveState.Saved(recipientId)) + } + }, + onError = { + store.update { + it.copy( + saveState = CreateStoryWithViewersState.SaveState.Init, + error = CreateStoryWithViewersState.NameError.DUPLICATE_LABEL + ) + } + } + ) + } + + class Factory( + private val repository: CreateStoryWithViewersRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(CreateStoryWithViewersViewModel(repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsFragment.kt new file mode 100644 index 0000000000..26b571c82d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsFragment.kt @@ -0,0 +1,136 @@ +package org.thoughtcrime.securesms.stories.settings.custom + +import android.view.MenuItem +import androidx.appcompat.widget.Toolbar +import androidx.core.content.ContextCompat +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment +import org.thoughtcrime.securesms.components.settings.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.stories.settings.story.PrivateStoryItem +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.navigation.safeNavigate +import org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel +import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder + +class PrivateStorySettingsFragment : DSLSettingsFragment( + menuId = R.menu.story_private_menu +) { + + private val viewModel: PrivateStorySettingsViewModel by viewModels( + factoryProducer = { + PrivateStorySettingsViewModel.Factory(PrivateStorySettingsFragmentArgs.fromBundle(requireArguments()).distributionListId, PrivateStorySettingsRepository()) + } + ) + + private val distributionListId: DistributionListId + get() = PrivateStorySettingsFragmentArgs.fromBundle(requireArguments()).distributionListId + + override fun onResume() { + super.onResume() + viewModel.refresh() + } + + override fun bindAdapter(adapter: DSLSettingsAdapter) { + adapter.registerFactory(RecipientMappingModel.RecipientIdMappingModel::class.java, LayoutFactory({ RecipientViewHolder(it, RecipientEventListener()) }, R.layout.stories_recipient_item)) + PrivateStoryItem.register(adapter) + + val toolbar: Toolbar = requireView().findViewById(R.id.toolbar) + + viewModel.state.observe(viewLifecycleOwner) { state -> + toolbar.title = state.privateStory?.name + adapter.submitList(getConfiguration(state).toMappingModelList()) + } + } + + private fun getConfiguration(state: PrivateStorySettingsState): DSLConfiguration { + if (state.privateStory == null) { + return configure { } + } + + return configure { + customPref( + PrivateStoryItem.Model( + privateStoryItemData = state.privateStory, + onClick = { + // TODO [stories] -- is this even clickable? + } + ) + ) + + dividerPref() + sectionHeaderPref(R.string.MyStorySettingsFragment__who_can_see_this_story) + customPref( + PrivateStoryItem.AddViewerModel( + onClick = { + findNavController().safeNavigate(PrivateStorySettingsFragmentDirections.actionPrivateStorySettingsToEditStoryViewers(distributionListId)) + } + ) + ) + + state.privateStory.members.forEach { + customPref(RecipientMappingModel.RecipientIdMappingModel(it)) + } + + dividerPref() + sectionHeaderPref(R.string.MyStorySettingsFragment__replies_amp_reactions) + switchPref( + title = DSLSettingsText.from(R.string.MyStorySettingsFragment__allow_replies_amp_reactions), + summary = DSLSettingsText.from(R.string.MyStorySettingsFragment__let_people_who_can_view_your_story_react_and_reply), + isChecked = state.areRepliesAndReactionsEnabled, + onClick = { + viewModel.setRepliesAndReactionsEnabled(!state.areRepliesAndReactionsEnabled) + } + ) + + dividerPref() + clickPref( + title = DSLSettingsText.from(R.string.PrivateStorySettingsFragment__delete_private_story, DSLSettingsText.ColorModifier(ContextCompat.getColor(requireContext(), R.color.signal_alert_primary))), + onClick = { + handleDeletePrivateStory() + } + ) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return if (item.itemId == R.id.action_edit) { + val action = PrivateStorySettingsFragmentDirections.actionPrivateStorySettingsToEditStoryNameFragment(distributionListId, viewModel.getName()) + findNavController().navigate(action) + true + } else { + false + } + } + + private fun handleRemoveRecipient(recipient: Recipient) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.PrivateStorySettingsFragment__remove_s, recipient.getDisplayName(requireContext()))) + .setMessage(R.string.PrivateStorySettingsFragment__this_person_will_no_longer) + .setPositiveButton(R.string.PrivateStorySettingsFragment__remove) { _, _ -> viewModel.remove(recipient) } + .setNegativeButton(android.R.string.cancel) { _, _ -> } + .show() + } + + private fun handleDeletePrivateStory() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.PrivateStorySettingsFragment__are_you_sure) + .setMessage(R.string.PrivateStorySettingsFragment__this_action_cannot) + .setNegativeButton(android.R.string.cancel) { _, _ -> } + .setPositiveButton(R.string.delete) { _, _ -> viewModel.delete().subscribe { findNavController().popBackStack() } } + .show() + } + + inner class RecipientEventListener : RecipientViewHolder.EventListener { + override fun onClick(recipient: Recipient) { + handleRemoveRecipient(recipient) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsRepository.kt new file mode 100644 index 0000000000..747df52db5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsRepository.kt @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.stories.settings.custom + +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.database.model.DistributionListRecord +import org.thoughtcrime.securesms.recipients.RecipientId + +class PrivateStorySettingsRepository { + fun getRecord(distributionListId: DistributionListId): Single { + return Single.fromCallable { + SignalDatabase.distributionLists.getList(distributionListId) ?: error("Record does not exist.") + }.subscribeOn(Schedulers.io()) + } + + fun removeMember(distributionListId: DistributionListId, member: RecipientId): Completable { + return Completable.fromAction { + SignalDatabase.distributionLists.removeMemberFromList(distributionListId, member) + }.subscribeOn(Schedulers.io()) + } + + fun delete(distributionListId: DistributionListId): Completable { + return Completable.fromAction { + SignalDatabase.distributionLists.deleteList(distributionListId) + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsState.kt new file mode 100644 index 0000000000..b89926b7a6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsState.kt @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.stories.settings.custom + +import org.thoughtcrime.securesms.database.model.DistributionListRecord + +data class PrivateStorySettingsState( + val privateStory: DistributionListRecord? = null, + val areRepliesAndReactionsEnabled: Boolean = false +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsViewModel.kt new file mode 100644 index 0000000000..83a784c667 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsViewModel.kt @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.stories.settings.custom + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.livedata.Store + +class PrivateStorySettingsViewModel(private val distributionListId: DistributionListId, private val repository: PrivateStorySettingsRepository) : ViewModel() { + + private val store = Store(PrivateStorySettingsState()) + private val disposables = CompositeDisposable() + + val state: LiveData = store.stateLiveData + + override fun onCleared() { + disposables.clear() + } + + fun refresh() { + disposables += repository.getRecord(distributionListId) + .subscribe { record -> + store.update { it.copy(privateStory = record) } + } + } + + fun getName(): String { + return store.state.privateStory?.name ?: "" + } + + fun remove(recipient: Recipient) { + disposables += repository.removeMember(distributionListId, recipient.id) + .subscribe { + refresh() + } + } + + fun setRepliesAndReactionsEnabled(repliesAndReactionsEnabled: Boolean) { + // TODO [stories] impl + } + + fun delete(): Completable { + return repository.delete(distributionListId).observeOn(AndroidSchedulers.mainThread()) + } + + class Factory(private val privateStoryItemData: DistributionListId, private val repository: PrivateStorySettingsRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(PrivateStorySettingsViewModel(privateStoryItemData, repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/name/EditStoryNameFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/name/EditStoryNameFragment.kt new file mode 100644 index 0000000000..af80667145 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/name/EditStoryNameFragment.kt @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.stories.settings.custom.name + +import android.os.Bundle +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import androidx.appcompat.widget.Toolbar +import androidx.core.content.ContextCompat +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.airbnb.lottie.SimpleColorFilter +import com.dd.CircularProgressButton +import com.google.android.material.textfield.TextInputLayout +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.ViewUtil + +class EditStoryNameFragment : Fragment(R.layout.stories_edit_story_name_fragment) { + + private val viewModel: EditStoryNameViewModel by viewModels( + factoryProducer = { + EditStoryNameViewModel.Factory(distributionListId, EditStoryNameRepository()) + } + ) + + private val distributionListId: DistributionListId + get() = EditStoryNameFragmentArgs.fromBundle(requireArguments()).distributionListId + + private val initialName: String + get() = EditStoryNameFragmentArgs.fromBundle(requireArguments()).name + + private val lifecycleDisposable = LifecycleDisposable() + + private lateinit var saveButton: CircularProgressButton + private lateinit var storyName: EditText + private lateinit var storyNameWrapper: TextInputLayout + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + lifecycleDisposable.bindTo(viewLifecycleOwner) + + val toolbar: Toolbar = view.findViewById(R.id.toolbar) + toolbar.navigationIcon?.colorFilter = SimpleColorFilter(ContextCompat.getColor(requireContext(), R.color.signal_icon_tint_primary)) + toolbar.setNavigationOnClickListener { findNavController().popBackStack() } + + storyNameWrapper = view.findViewById(R.id.story_name_wrapper) + storyName = view.findViewById(R.id.story_name) + storyName.setText(initialName) + storyName.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + onSaveClicked() + true + } else { + false + } + } + + storyName.doAfterTextChanged { + saveButton.isEnabled = !it.isNullOrEmpty() + saveButton.alpha = if (it.isNullOrEmpty()) 0.5f else 1f + storyNameWrapper.error = null + } + + saveButton = view.findViewById(R.id.save) + saveButton.setOnClickListener { + onSaveClicked() + } + } + + override fun onPause() { + super.onPause() + ViewUtil.hideKeyboard(requireContext(), storyName) + } + + private fun onSaveClicked() { + saveButton.isClickable = false + lifecycleDisposable += viewModel.save(storyName.text).subscribeBy( + onComplete = { findNavController().popBackStack() }, + onError = { + saveButton.isClickable = true + storyNameWrapper.error = getString(R.string.CreateStoryWithViewersFragment__there_is_already_a_story_with_this_name) + } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/name/EditStoryNameRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/name/EditStoryNameRepository.kt new file mode 100644 index 0000000000..33e0f3520f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/name/EditStoryNameRepository.kt @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.stories.settings.custom.name + +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.DistributionListId + +class EditStoryNameRepository { + fun save(privateStoryId: DistributionListId, name: CharSequence): Completable { + return Completable.create { + if (privateStoryId == DistributionListId.MY_STORY) { + error("Cannot set name for My Story") + } + + if (SignalDatabase.distributionLists.setName(privateStoryId, name.toString())) { + it.onComplete() + } else { + it.onError(Exception("Could not update story name.")) + } + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/name/EditStoryNameViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/name/EditStoryNameViewModel.kt new file mode 100644 index 0000000000..76f95cb89a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/name/EditStoryNameViewModel.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.stories.settings.custom.name + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Completable +import org.thoughtcrime.securesms.database.model.DistributionListId + +class EditStoryNameViewModel(private val privateStoryId: DistributionListId, private val repository: EditStoryNameRepository) : ViewModel() { + + fun save(name: CharSequence): Completable { + return repository.save(privateStoryId, name).observeOn(AndroidSchedulers.mainThread()) + } + + class Factory(private val privateStoryId: DistributionListId, private val repository: EditStoryNameRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(EditStoryNameViewModel(privateStoryId, repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/viewers/AddViewersFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/viewers/AddViewersFragment.kt new file mode 100644 index 0000000000..2138bbed81 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/viewers/AddViewersFragment.kt @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.stories.settings.custom.viewers + +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.stories.settings.select.BaseStoryRecipientSelectionFragment + +/** + * Allows user to manage users that can view a story for a given distribution list. + */ +class AddViewersFragment : BaseStoryRecipientSelectionFragment() { + override val actionButtonLabel: Int = R.string.HideStoryFromFragment__done + override val distributionListId: DistributionListId + get() = AddViewersFragmentArgs.fromBundle(requireArguments()).distributionListId +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/hide/HideStoryFromDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/hide/HideStoryFromDialogFragment.kt new file mode 100644 index 0000000000..9e215608ca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/hide/HideStoryFromDialogFragment.kt @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.stories.settings.hide + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.DialogFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.stories.settings.select.BaseStoryRecipientSelectionFragment + +/** + * Embeds HideStoryFromFragment in a full-screen dialog. + */ +class HideStoryFromDialogFragment : DialogFragment(R.layout.fragment_container), BaseStoryRecipientSelectionFragment.Callback { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + if (savedInstanceState == null) { + childFragmentManager.beginTransaction() + .replace(R.id.fragment_container, HideStoryFromFragment()) + .commit() + } + } + + override fun exitFlow() { + dismissAllowingStateLoss() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/hide/HideStoryFromFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/hide/HideStoryFromFragment.kt new file mode 100644 index 0000000000..f83885159f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/hide/HideStoryFromFragment.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.stories.settings.hide + +import androidx.appcompat.widget.Toolbar +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.stories.settings.select.BaseStoryRecipientSelectionFragment + +/** + * Allows user to select a list of people to exclude from "My Story" + */ +class HideStoryFromFragment : BaseStoryRecipientSelectionFragment() { + override val actionButtonLabel: Int = R.string.HideStoryFromFragment__done + + override val distributionListId: DistributionListId + get() = DistributionListId.from(DistributionListId.MY_STORY_ID) + + override val toolbarTitleId: Int = R.string.HideStoryFromFragment__hide_story_from + + override fun presentTitle(toolbar: Toolbar, size: Int) = Unit +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsFragment.kt new file mode 100644 index 0000000000..abdf118ac6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsFragment.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.stories.settings.my + +import androidx.core.content.ContextCompat +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment +import org.thoughtcrime.securesms.components.settings.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.util.SpanUtil +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +class MyStorySettingsFragment : DSLSettingsFragment( + titleId = R.string.MyStorySettingsFragment__my_story +) { + + private val viewModel: MyStorySettingsViewModel by viewModels( + factoryProducer = { + MyStorySettingsViewModel.Factory(MyStorySettingsRepository()) + } + ) + + private val signalConnectionsSummary by lazy { + SpanUtil.clickSubstring( + getString(R.string.MyStorySettingsFragment__hide_your_story_from, getString(R.string.MyStorySettingsFragment__signal_connections)), + getString(R.string.MyStorySettingsFragment__signal_connections), + { + findNavController().safeNavigate(R.id.action_myStorySettings_to_signalConnectionsBottomSheet) + }, + ContextCompat.getColor(requireContext(), R.color.signal_text_primary) + ) + } + + override fun onResume() { + super.onResume() + viewModel.refresh() + } + + override fun bindAdapter(adapter: DSLSettingsAdapter) { + viewModel.state.observe(viewLifecycleOwner) { state -> + adapter.submitList(getConfiguration(state).toMappingModelList()) + } + } + + private fun getConfiguration(state: MyStorySettingsState): DSLConfiguration { + return configure { + sectionHeaderPref(R.string.MyStorySettingsFragment__who_can_see_this_story) + + clickPref( + title = DSLSettingsText.from(R.string.MyStorySettingsFragment__hide_story_from), + summary = DSLSettingsText.from(resources.getQuantityString(R.plurals.MyStorySettingsFragment__d_people, state.hiddenStoryFromCount, state.hiddenStoryFromCount)), + onClick = { + findNavController().safeNavigate(R.id.action_myStorySettings_to_hideStoryFromFragment) + } + ) + + textPref(summary = DSLSettingsText.from(signalConnectionsSummary)) + dividerPref() + sectionHeaderPref(R.string.MyStorySettingsFragment__replies_amp_reactions) + switchPref( + title = DSLSettingsText.from(R.string.MyStorySettingsFragment__allow_replies_amp_reactions), + summary = DSLSettingsText.from(R.string.MyStorySettingsFragment__let_people_who_can_view_your_story_react_and_reply), + isChecked = state.areRepliesAndReactionsEnabled, + onClick = { + viewModel.setRepliesAndReactionsEnabled(!state.areRepliesAndReactionsEnabled) + } + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsRepository.kt new file mode 100644 index 0000000000..5b5ce4f5db --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsRepository.kt @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.stories.settings.my + +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.DistributionListId + +class MyStorySettingsRepository { + + fun getHiddenRecipientCount(): Single { + return Single.fromCallable { + SignalDatabase.distributionLists.getRawMemberCount(DistributionListId.from(DistributionListId.MY_STORY_ID)) + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsState.kt new file mode 100644 index 0000000000..671a5d372a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsState.kt @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.stories.settings.my + +data class MyStorySettingsState( + val hiddenStoryFromCount: Int = 0, + val areRepliesAndReactionsEnabled: Boolean = false +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsViewModel.kt new file mode 100644 index 0000000000..4419ab8ca0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsViewModel.kt @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.stories.settings.my + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.thoughtcrime.securesms.util.livedata.Store + +class MyStorySettingsViewModel(private val repository: MyStorySettingsRepository) : ViewModel() { + private val store = Store(MyStorySettingsState()) + private val disposables = CompositeDisposable() + + val state: LiveData = store.stateLiveData + + override fun onCleared() { + disposables.clear() + } + + fun refresh() { + disposables += repository.getHiddenRecipientCount() + .subscribe { count -> store.update { it.copy(hiddenStoryFromCount = count) } } + } + + fun setRepliesAndReactionsEnabled(repliesAndReactionsEnabled: Boolean) { + } + + class Factory(private val repository: MyStorySettingsRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(MyStorySettingsViewModel(repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/SignalConnectionsBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/SignalConnectionsBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..298fc34282 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/SignalConnectionsBottomSheetDialogFragment.kt @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.stories.settings.my + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment + +class SignalConnectionsBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() { + + override val peekHeightPercentage: Float = 1f + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.stories_signal_connection_bottom_sheet, container, false) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionFragment.kt new file mode 100644 index 0000000000..fa74359c51 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionFragment.kt @@ -0,0 +1,157 @@ +package org.thoughtcrime.securesms.stories.settings.select + +import android.os.Bundle +import android.view.View +import android.widget.EditText +import androidx.appcompat.widget.Toolbar +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.google.android.material.button.MaterialButton +import org.thoughtcrime.securesms.ContactSelectionListFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.contacts.ContactsCursorLoader +import org.thoughtcrime.securesms.contacts.HeaderAction +import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.groups.SelectionLimits +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.sharing.ShareContact +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.fragments.findListener +import org.whispersystems.libsignal.util.guava.Optional +import java.util.function.Consumer + +abstract class BaseStoryRecipientSelectionFragment : Fragment(R.layout.stories_base_recipient_selection_fragment), ContactSelectionListFragment.OnContactSelectedListener, ContactSelectionListFragment.HeaderActionProvider { + + private val viewModel: BaseStoryRecipientSelectionViewModel by viewModels( + factoryProducer = { + BaseStoryRecipientSelectionViewModel.Factory(distributionListId, BaseStoryRecipientSelectionRepository()) + } + ) + + private val lifecycleDisposable = LifecycleDisposable() + + protected open val toolbarTitleId: Int = R.string.CreateStoryViewerSelectionFragment__choose_viewers + abstract val actionButtonLabel: Int + abstract val distributionListId: DistributionListId? + + private lateinit var toolbar: Toolbar + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val searchField: EditText = view.findViewById(R.id.search_field) + val actionButton: MaterialButton = view.findViewById(R.id.action_button) + + toolbar = view.findViewById(R.id.toolbar) + toolbar.setNavigationOnClickListener { + exitFlow() + } + + toolbar.setTitle(toolbarTitleId) + + lifecycleDisposable.bindTo(viewLifecycleOwner) + + actionButton.setText(actionButtonLabel) + + searchField.doAfterTextChanged { + val contactSelectionListFragment = getAttachedContactSelectionFragment() + + if (it.isNullOrEmpty()) { + contactSelectionListFragment.resetQueryFilter() + } else { + contactSelectionListFragment.setQueryFilter(it.toString()) + } + } + + actionButton.setOnClickListener { + viewModel.onAction() + } + + if (childFragmentManager.findFragmentById(R.id.fragment_container) == null) { + initializeContactSelectionFragment() + } + + viewModel.state.observe(viewLifecycleOwner) { + getAttachedContactSelectionFragment().markSelected(it.map(::ShareContact).toSet()) + presentTitle(toolbar, it.size) + } + + lifecycleDisposable += viewModel.actionObservable.subscribe { action -> + when (action) { + is BaseStoryRecipientSelectionViewModel.Action.ExitFlow -> exitFlow() + is BaseStoryRecipientSelectionViewModel.Action.GoToNextScreen -> goToNextScreen( + getAttachedContactSelectionFragment().selectedContacts.map { it.getOrCreateRecipientId(requireContext()) }.toSet() + ) + } + } + } + + protected open fun presentTitle(toolbar: Toolbar, size: Int) { + if (size == 0) { + toolbar.setTitle(R.string.CreateStoryViewerSelectionFragment__choose_viewers) + } else { + toolbar.title = resources.getQuantityString(R.plurals.SelectViewersFragment__d_viewers, size, size) + } + } + + private fun getAttachedContactSelectionFragment(): ContactSelectionListFragment { + return childFragmentManager.findFragmentById(R.id.contacts_container) as ContactSelectionListFragment + } + + protected open fun goToNextScreen(recipients: Set) { + throw UnsupportedOperationException() + } + + private fun exitFlow() { + val callback = findListener() + if (callback == null) { + findNavController().popBackStack() + } else { + callback.exitFlow() + } + } + + override fun onBeforeContactSelected(recipientId: Optional, number: String?, callback: Consumer) { + viewModel.addRecipient(recipientId.get()) + callback.accept(true) + } + + override fun onContactDeselected(recipientId: Optional, number: String?) { + viewModel.removeRecipient(recipientId.get()) + } + + override fun onSelectionChanged() = Unit + + override fun getHeaderAction(): HeaderAction { + return HeaderAction( + R.string.BaseStoryRecipientSelectionFragment__select_all, + ) { + viewModel.toggleSelectAll() + } + } + + private fun initializeContactSelectionFragment() { + val contactSelectionListFragment = ContactSelectionListFragment() + val arguments = ContactSelectionArguments( + displayMode = ContactsCursorLoader.DisplayMode.FLAG_PUSH, + isRefreshable = false, + displayRecents = false, + selectionLimits = SelectionLimits.NO_LIMITS, + canSelectSelf = false, + currentSelection = emptyList(), + displaySelectionCount = false, + displayChips = true + ) + + contactSelectionListFragment.arguments = arguments.toArgumentBundle() + + childFragmentManager.beginTransaction() + .replace(R.id.contacts_container, contactSelectionListFragment) + .commitNowAllowingStateLoss() + } + + interface Callback { + fun exitFlow() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionRepository.kt new file mode 100644 index 0000000000..5653fc2f76 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionRepository.kt @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.stories.settings.select + +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.concurrent.SignalExecutors +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.CursorUtil + +class BaseStoryRecipientSelectionRepository { + fun updateDistributionListMembership(distributionListId: DistributionListId, recipients: Set) { + SignalExecutors.BOUNDED.execute { + val currentRecipients = SignalDatabase.distributionLists.getRawMembers(distributionListId).toSet() + val oldNotNew = currentRecipients - recipients + val newNotOld = recipients - currentRecipients + + oldNotNew.forEach { + SignalDatabase.distributionLists.removeMemberFromList(distributionListId, it) + } + + newNotOld.forEach { + SignalDatabase.distributionLists.addMemberToList(distributionListId, it) + } + } + } + + fun getListMembers(distributionListId: DistributionListId): Single> { + return Single.fromCallable { + SignalDatabase.distributionLists.getRawMembers(distributionListId).toSet() + }.subscribeOn(Schedulers.io()) + } + + fun getAllSignalContacts(): Single> { + return Single.fromCallable { + SignalDatabase.recipients.getSignalContacts(false)?.use { + val recipientSet = mutableSetOf() + while (it.moveToNext()) { + recipientSet.add(RecipientId.from(CursorUtil.requireLong(it, RecipientDatabase.ID))) + } + + recipientSet + } ?: emptySet() + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionViewModel.kt new file mode 100644 index 0000000000..95cc44efe8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionViewModel.kt @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.stories.settings.select + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.kotlin.subscribeBy +import io.reactivex.rxjava3.subjects.PublishSubject +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.livedata.Store + +class BaseStoryRecipientSelectionViewModel( + private val distributionListId: DistributionListId?, + private val repository: BaseStoryRecipientSelectionRepository +) : ViewModel() { + private val store = Store(emptySet()) + private val subject = PublishSubject.create() + private val disposable = CompositeDisposable() + + var actionObservable: Observable = subject + var state: LiveData> = store.stateLiveData + + init { + if (distributionListId != null) { + disposable += repository.getListMembers(distributionListId) + .subscribe { members -> + store.update { it + members } + } + } + } + + override fun onCleared() { + disposable.clear() + } + + fun toggleSelectAll() { + disposable += repository.getAllSignalContacts().subscribeBy { allSignalRecipients -> + store.update { allSignalRecipients } + } + } + + fun addRecipient(recipientId: RecipientId) { + store.update { it + recipientId } + } + + fun removeRecipient(recipientId: RecipientId) { + store.update { it - recipientId } + } + + fun onAction() { + if (distributionListId != null) { + repository.updateDistributionListMembership(distributionListId, store.state) + subject.onNext(Action.ExitFlow) + } else { + subject.onNext(Action.GoToNextScreen(store.state)) + } + } + + sealed class Action { + data class GoToNextScreen(val recipients: Set) : Action() + object ExitFlow : Action() + } + + class Factory( + private val distributionListId: DistributionListId?, + private val repository: BaseStoryRecipientSelectionRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(BaseStoryRecipientSelectionViewModel(distributionListId, repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/PrivateStoryItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/PrivateStoryItem.kt new file mode 100644 index 0000000000..ed55cf8710 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/PrivateStoryItem.kt @@ -0,0 +1,124 @@ +package org.thoughtcrime.securesms.stories.settings.story + +import android.view.View +import android.widget.TextView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord +import org.thoughtcrime.securesms.database.model.DistributionListRecord +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.MappingViewHolder + +object PrivateStoryItem { + + fun register(mappingAdapter: MappingAdapter) { + mappingAdapter.registerFactory(NewModel::class.java, LayoutFactory(::NewViewHolder, R.layout.stories_private_story_new_item)) + mappingAdapter.registerFactory(AddViewerModel::class.java, LayoutFactory(::AddViewerViewHolder, R.layout.stories_private_story_add_viewer_item)) + mappingAdapter.registerFactory(RecipientModel::class.java, LayoutFactory(::RecipientViewHolder, R.layout.stories_private_story_recipient_item)) + mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.stories_private_story_item)) + mappingAdapter.registerFactory(PartialModel::class.java, LayoutFactory(::PartialViewHolder, R.layout.stories_private_story_item)) + } + + class NewModel( + val onClick: () -> Unit + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: NewModel): Boolean = true + } + + class AddViewerModel( + val onClick: () -> Unit + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: AddViewerModel): Boolean = true + } + + class RecipientModel( + val recipient: Recipient, + val onClick: (RecipientModel) -> Unit + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: RecipientModel): Boolean = newItem.recipient == recipient + + override fun areContentsTheSame(newItem: RecipientModel): Boolean { + return newItem.recipient.hasSameContent(recipient) && super.areContentsTheSame(newItem) + } + } + + class Model( + val privateStoryItemData: DistributionListRecord, + val onClick: (Model) -> Unit + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean { + return newItem.privateStoryItemData.id == privateStoryItemData.id + } + + override fun areContentsTheSame(newItem: Model): Boolean { + return newItem.privateStoryItemData == privateStoryItemData && + super.areContentsTheSame(newItem) + } + } + + class PartialModel( + val privateStoryItemData: DistributionListPartialRecord, + val onClick: (PartialModel) -> Unit + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: PartialModel): Boolean { + return newItem.privateStoryItemData.id == privateStoryItemData.id + } + + override fun areContentsTheSame(newItem: PartialModel): Boolean { + return newItem.privateStoryItemData == privateStoryItemData && + super.areContentsTheSame(newItem) + } + } + + private class RecipientViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val name: TextView = itemView.findViewById(R.id.label) + private val avatar: AvatarImageView = itemView.findViewById(R.id.avatar) + + override fun bind(model: RecipientModel) { + itemView.setOnClickListener { model.onClick(model) } + avatar.setRecipient(model.recipient) + + if (model.recipient.isSelf) { + name.setText(R.string.MyStorySettingsFragment__my_story) + } else { + name.text = model.recipient.getDisplayName(context) + } + } + } + + private class NewViewHolder(itemView: View) : MappingViewHolder(itemView) { + override fun bind(model: NewModel) { + itemView.setOnClickListener { model.onClick() } + } + } + + private class AddViewerViewHolder(itemView: View) : MappingViewHolder(itemView) { + override fun bind(model: AddViewerModel) { + itemView.setOnClickListener { model.onClick() } + } + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val label: TextView = itemView.findViewById(R.id.label) + + override fun bind(model: Model) { + itemView.setOnClickListener { model.onClick(model) } + label.text = model.privateStoryItemData.name + } + } + + private class PartialViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val label: TextView = itemView.findViewById(R.id.label) + + override fun bind(model: PartialModel) { + itemView.setOnClickListener { model.onClick(model) } + label.text = model.privateStoryItemData.name + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StorySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StorySettingsFragment.kt new file mode 100644 index 0000000000..6d15db8898 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StorySettingsFragment.kt @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.stories.settings.story + +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +class StorySettingsFragment : DSLSettingsFragment( + titleId = R.string.StorySettingsFragment__story_settings +) { + + private val viewModel: StorySettingsViewModel by viewModels( + factoryProducer = { + StorySettingsViewModel.Factory(StorySettingsRepository()) + } + ) + + override fun onResume() { + super.onResume() + viewModel.refresh() + } + + override fun bindAdapter(adapter: DSLSettingsAdapter) { + PrivateStoryItem.register(adapter) + + viewModel.state.observe(viewLifecycleOwner) { state -> + adapter.submitList(getConfiguration(state).toMappingModelList()) + } + } + + private fun getConfiguration(state: StorySettingsState): DSLConfiguration { + return configure { + customPref( + PrivateStoryItem.RecipientModel( + recipient = Recipient.self(), + onClick = { + findNavController().safeNavigate(R.id.action_storySettings_to_myStorySettings) + } + ) + ) + + dividerPref() + sectionHeaderPref(R.string.StorySettingsFragment__private_stories) + + customPref( + PrivateStoryItem.NewModel( + onClick = { + findNavController().safeNavigate(R.id.action_storySettings_to_newStory) + } + ) + ) + + state.privateStories.forEach { itemData -> + customPref( + PrivateStoryItem.PartialModel( + privateStoryItemData = itemData, + onClick = { + findNavController().safeNavigate(StorySettingsFragmentDirections.actionStorySettingsToPrivateStorySettings(it.privateStoryItemData.id)) + } + ) + ) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StorySettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StorySettingsRepository.kt new file mode 100644 index 0000000000..b75cd32768 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StorySettingsRepository.kt @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.stories.settings.story + +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord + +class StorySettingsRepository { + fun getPrivateStories(): Single> { + return Single.fromCallable { + SignalDatabase.distributionLists.getCustomListsForUi() + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StorySettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StorySettingsState.kt new file mode 100644 index 0000000000..c600bf1df5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StorySettingsState.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.stories.settings.story + +import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord + +data class StorySettingsState( + val privateStories: List = emptyList() +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StorySettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StorySettingsViewModel.kt new file mode 100644 index 0000000000..121d5b74d2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StorySettingsViewModel.kt @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.stories.settings.story + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.thoughtcrime.securesms.util.livedata.Store + +class StorySettingsViewModel( + private val repository: StorySettingsRepository +) : ViewModel() { + + private val store = Store(StorySettingsState()) + private val disposables = CompositeDisposable() + + val state: LiveData = store.stateLiveData + + fun refresh() { + disposables += repository.getPrivateStories().subscribe { privateStories -> + store.update { it.copy(privateStories = privateStories) } + } + } + + override fun onCleared() { + disposables.clear() + } + + class Factory( + private val repository: StorySettingsRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(StorySettingsViewModel(repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTab.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTab.kt new file mode 100644 index 0000000000..06f7d7ab2e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTab.kt @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.stories.tabs + +enum class ConversationListTab { + CHATS, + STORIES +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabRepository.kt new file mode 100644 index 0000000000..79176482ea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabRepository.kt @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.stories.tabs + +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.DatabaseObserver +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies + +class ConversationListTabRepository { + + fun getNumberOfUnreadConversations(): Observable { + return Observable.create { + val listener = DatabaseObserver.Observer { + it.onNext(SignalDatabase.threads.unreadThreadCount) + } + + ApplicationDependencies.getDatabaseObserver().registerConversationListObserver(listener) + it.setCancellable { ApplicationDependencies.getDatabaseObserver().unregisterObserver(listener) } + it.onNext(SignalDatabase.threads.unreadThreadCount) + }.subscribeOn(Schedulers.io()) + } + + fun getNumberOfUnseenStories(): Observable { + return Observable.create { + val listener = DatabaseObserver.Observer { + it.onNext(SignalDatabase.mms.unreadStoryCount) + } + + ApplicationDependencies.getDatabaseObserver().registerConversationListObserver(listener) + it.setCancellable { ApplicationDependencies.getDatabaseObserver().unregisterObserver(listener) } + it.onNext(SignalDatabase.mms.unreadStoryCount) + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsFragment.kt new file mode 100644 index 0000000000..9816bd30f6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsFragment.kt @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.stories.tabs + +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.visible +import java.text.NumberFormat + +/** + * Displays the "Chats" and "Stories" tab to a user. + */ +class ConversationListTabsFragment : Fragment(R.layout.conversation_list_tabs) { + + private val viewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() }) + + private lateinit var chatsUnreadIndicator: TextView + private lateinit var storiesUnreadIndicator: TextView + private lateinit var chatsIcon: View + private lateinit var storiesIcon: View + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + chatsUnreadIndicator = view.findViewById(R.id.chats_unread_indicator) + storiesUnreadIndicator = view.findViewById(R.id.stories_unread_indicator) + chatsIcon = view.findViewById(R.id.chats_tab_icon) + storiesIcon = view.findViewById(R.id.stories_tab_icon) + + view.findViewById(R.id.chats_tab_touch_point).setOnClickListener { + viewModel.onChatsSelected() + } + + view.findViewById(R.id.stories_tab_touch_point).setOnClickListener { + viewModel.onStoriesSelected() + } + + viewModel.state.observe(viewLifecycleOwner, this::update) + } + + private fun update(state: ConversationListTabsState) { + chatsIcon.isSelected = state.tab == ConversationListTab.CHATS + storiesIcon.isSelected = state.tab == ConversationListTab.STORIES + + chatsUnreadIndicator.visible = state.unreadChatsCount > 0 + chatsUnreadIndicator.text = formatCount(state.unreadChatsCount) + + storiesUnreadIndicator.visible = state.unreadStoriesCount > 0 + storiesUnreadIndicator.text = formatCount(state.unreadStoriesCount) + } + + private fun formatCount(count: Long): String { + if (count > 99L) { + return getString(R.string.ConversationListTabs__99p) + } + return NumberFormat.getInstance().format(count) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsState.kt new file mode 100644 index 0000000000..2ebf3c0dfd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsState.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.stories.tabs + +data class ConversationListTabsState( + val tab: ConversationListTab = ConversationListTab.CHATS, + val unreadChatsCount: Long = 0L, + val unreadStoriesCount: Long = 0L +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsViewModel.kt new file mode 100644 index 0000000000..550ca868bb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsViewModel.kt @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.stories.tabs + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.thoughtcrime.securesms.util.livedata.Store + +class ConversationListTabsViewModel(repository: ConversationListTabRepository) : ViewModel() { + private val store = Store(ConversationListTabsState()) + + val state: LiveData = store.stateLiveData + val disposables = CompositeDisposable() + + init { + disposables += repository.getNumberOfUnreadConversations().subscribe { unreadChats -> + store.update { it.copy(unreadChatsCount = unreadChats) } + } + + disposables += repository.getNumberOfUnseenStories().subscribe { unseenStories -> + store.update { it.copy(unreadStoriesCount = unseenStories) } + } + } + + override fun onCleared() { + disposables.clear() + } + + fun onChatsSelected() { + store.update { it.copy(tab = ConversationListTab.CHATS) } + } + + fun onStoriesSelected() { + store.update { it.copy(tab = ConversationListTab.STORIES) } + } + + class Factory(private val repository: ConversationListTabRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(ConversationListTabsViewModel(repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt new file mode 100644 index 0000000000..6bad3b2dd0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.stories.viewer + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatDelegate +import org.thoughtcrime.securesms.PassphraseRequiredActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.recipients.RecipientId + +class StoryViewerActivity : PassphraseRequiredActivity() { + + override fun attachBaseContext(newBase: Context) { + delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES + super.attachBaseContext(newBase) + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) + setContentView(R.layout.fragment_container) + + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_container, StoryViewerFragment.create(intent.getParcelableExtra(ARG_START_RECIPIENT_ID)!!)) + .commit() + } + } + + companion object { + private const val ARG_START_RECIPIENT_ID = "start.recipient.id" + + fun createIntent(context: Context, storyId: RecipientId): Intent { + return Intent(context, StoryViewerActivity::class.java) + .putExtra(ARG_START_RECIPIENT_ID, storyId) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt new file mode 100644 index 0000000000..bdf7602040 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.stories.viewer + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.viewpager2.widget.ViewPager2 +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageFragment + +/** + * Fragment which manages a vertical pager fragment of stories. + */ +class StoryViewerFragment : Fragment(R.layout.stories_viewer_fragment), StoryViewerPageFragment.Callback { + + private val onPageChanged = OnPageChanged() + + private lateinit var storyPager: ViewPager2 + + private val viewModel: StoryViewerViewModel by viewModels( + factoryProducer = { + StoryViewerViewModel.Factory(storyRecipientId, StoryViewerRepository()) + } + ) + + private val storyRecipientId: RecipientId + get() = requireArguments().getParcelable(ARG_START_RECIPIENT_ID)!! + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + storyPager = view.findViewById(R.id.story_item_pager) + + val adapter = StoryViewerPagerAdapter(this) + storyPager.adapter = adapter + + viewModel.state.observe(viewLifecycleOwner) { state -> + adapter.setPages(state.pages) + if (state.pages.isNotEmpty() && storyPager.currentItem != state.page) { + storyPager.setCurrentItem(state.page, state.previousPage > -1) + + if (state.page >= state.pages.size) { + requireActivity().onBackPressed() + } + } + } + } + + override fun onResume() { + super.onResume() + storyPager.registerOnPageChangeCallback(onPageChanged) + } + + override fun onPause() { + super.onPause() + storyPager.unregisterOnPageChangeCallback(onPageChanged) + } + + override fun onFinishedPosts(recipientId: RecipientId) { + viewModel.onFinishedPosts(recipientId) + } + + override fun onStoryHidden(recipientId: RecipientId) { + viewModel.onRecipientHidden() + } + + inner class OnPageChanged : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + viewModel.setSelectedPage(position) + } + } + + companion object { + private const val ARG_START_RECIPIENT_ID = "start.recipient.id" + + fun create(storyRecipientId: RecipientId): Fragment { + return StoryViewerFragment().apply { + arguments = Bundle().apply { + putParcelable(ARG_START_RECIPIENT_ID, storyRecipientId) + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerPagerAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerPagerAdapter.kt new file mode 100644 index 0000000000..6052048221 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerPagerAdapter.kt @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.stories.viewer + +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.DiffUtil +import androidx.viewpager2.adapter.FragmentStateAdapter +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageFragment + +class StoryViewerPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { + + private var pages: List = emptyList() + + fun setPages(newPages: List) { + val oldPages = pages + pages = newPages + + val callback = Callback(oldPages, pages) + DiffUtil.calculateDiff(callback).dispatchUpdatesTo(this) + } + + override fun getItemCount(): Int = pages.size + + override fun createFragment(position: Int): Fragment { + return StoryViewerPageFragment.create(pages[position]) + } + + private class Callback( + private val oldList: List, + private val newList: List + ) : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldList.size + + override fun getNewListSize(): Int = newList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return oldList[oldItemPosition] == newList[newItemPosition] + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return oldList[oldItemPosition] == newList[newItemPosition] + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt new file mode 100644 index 0000000000..7990c759c4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.stories.viewer + +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +class StoryViewerRepository { + fun getStories(): Single> { + return Single.fromCallable { + val recipients = SignalDatabase.mms.allStoriesRecipientsList + val resolved = recipients.map { Recipient.resolved(it) } + + val doNotCollapse: List = resolved + .filterNot { it.isDistributionList || it.shouldHideStory() } + .map { it.id } + + val myStory: RecipientId = SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY) + + listOf(myStory) + doNotCollapse + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerState.kt new file mode 100644 index 0000000000..2baaa2b03c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerState.kt @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.stories.viewer + +import org.thoughtcrime.securesms.recipients.RecipientId + +data class StoryViewerState( + val pages: List = emptyList(), + val previousPage: Int = -1, + val page: Int = -1 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt new file mode 100644 index 0000000000..339d44ef6c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt @@ -0,0 +1,106 @@ +package org.thoughtcrime.securesms.stories.viewer + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.livedata.Store + +class StoryViewerViewModel( + private val startRecipientId: RecipientId, + private val repository: StoryViewerRepository +) : ViewModel() { + + private val store = Store(StoryViewerState()) + private val disposables = CompositeDisposable() + + val state: LiveData = store.stateLiveData + + init { + refresh() + } + + private fun refresh() { + disposables.clear() + disposables += repository.getStories().subscribe { recipientIds -> + store.update { + val page: Int = if (it.pages.isNotEmpty()) { + val oldPage = it.page + val oldRecipient = it.pages[oldPage] + + val newPage = recipientIds.indexOf(oldRecipient) + if (newPage == -1) { + it.page + } else { + newPage + } + } else { + it.page + } + updatePages(it.copy(pages = recipientIds), page) + } + } + } + + override fun onCleared() { + disposables.clear() + } + + fun setSelectedPage(page: Int) { + store.update { + updatePages(it, page) + } + } + + fun onFinishedPosts(recipientId: RecipientId) { + store.update { + if (it.pages[it.page] == recipientId) { + updatePages(it, it.page + 1) + } else { + it + } + } + } + + fun onRecipientHidden() { + refresh() + } + + private fun updatePages(state: StoryViewerState, page: Int): StoryViewerState { + val newPage = resolvePage(page, state.pages) + val prevPage = if (newPage == state.page) { + state.previousPage + } else { + state.page + } + + return state.copy( + page = newPage, + previousPage = prevPage + ) + } + + private fun resolvePage(page: Int, recipientIds: List): Int { + return if (page > -1) { + page + } else { + val indexOfStartRecipient = recipientIds.indexOf(startRecipientId) + if (indexOfStartRecipient == -1) { + 0 + } else { + indexOfStartRecipient + } + } + } + + class Factory( + private val startRecipientId: RecipientId, + private val repository: StoryViewerRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(StoryViewerViewModel(startRecipientId, repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryDisplay.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryDisplay.kt new file mode 100644 index 0000000000..bf67c3d3a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryDisplay.kt @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.stories.viewer.page + +import android.content.res.Resources + +/** + * Given the size of our display, we render the story overlay / crop in one of 3 ways. + */ +enum class StoryDisplay { + /** + * View/Reply is underneath story content, corners are rounded, content is not cropped + */ + LARGE, + /** + * View/Reply overlays story content, corners are rounded, content is not cropped + */ + MEDIUM, + /** + * View/Reply is overlays story content, corners are not rounded, content is cropped + */ + SMALL; + + companion object { + private const val LANDSCAPE = 1f + private const val LARGE_AR = 9 / 18f + private const val SMALL_AR = 9 / 16f + + fun getStoryDisplay(resources: Resources): StoryDisplay { + val aspectRatio = resources.displayMetrics.widthPixels.toFloat() / resources.displayMetrics.heightPixels + + return when { + aspectRatio >= LANDSCAPE -> MEDIUM + aspectRatio >= LARGE_AR -> LARGE + aspectRatio <= SMALL_AR -> SMALL + else -> MEDIUM + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryPost.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryPost.kt new file mode 100644 index 0000000000..6d71527a1d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryPost.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.stories.viewer.page + +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * Each story is made up of a collection of posts + */ +class StoryPost( + val id: Long, + val sender: Recipient, + val group: Recipient?, + val distributionList: Recipient?, + val viewCount: Int, + val replyCount: Int, + val dateInMilliseconds: Long, + val durationMillis: Long, + val attachment: Attachment, + val conversationMessage: ConversationMessage +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerDialog.kt new file mode 100644 index 0000000000..db0091e609 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerDialog.kt @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.stories.viewer.page + +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * Dialogs that can be displayed and should override requests to continue playback of stories. + * This assists in solving a race condition where one dialog opens another but the dismissal of + * the first dialog resumes story playback after the new dialog requested a pause. + */ +sealed class StoryViewerDialog(val type: Type) { + data class GroupDirectReply( + val recipientId: RecipientId, + val storyId: Long + ) : StoryViewerDialog(Type.DIRECT_REPLY) + + object Forward : StoryViewerDialog(Type.FORWARD) + object Delete : StoryViewerDialog(Type.DELETE) + + enum class Type { + DIRECT_REPLY, + FORWARD, + DELETE, + CONTEXT_MENU, + VIEWS_AND_REPLIES + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt new file mode 100644 index 0000000000..6733bdfbdd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt @@ -0,0 +1,572 @@ +package org.thoughtcrime.securesms.stories.viewer.page + +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import android.widget.TextView +import androidx.cardview.widget.CardView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.view.GestureDetectorCompat +import androidx.core.view.doOnNextLayout +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import org.signal.core.util.DimensionUnit +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.components.segmentedprogressbar.SegmentedProgressBar +import org.thoughtcrime.securesms.components.segmentedprogressbar.SegmentedProgressBarListener +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto +import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto20dp +import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto +import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.conversation.colors.AvatarColor +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.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.mediapreview.MediaPreviewFragment +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu +import org.thoughtcrime.securesms.stories.viewer.reply.direct.StoryDirectReplyDialogFragment +import org.thoughtcrime.securesms.stories.viewer.reply.group.StoryGroupReplyBottomSheetDialogFragment +import org.thoughtcrime.securesms.stories.viewer.reply.tabs.StoryViewsAndRepliesDialogFragment +import org.thoughtcrime.securesms.stories.viewer.views.StoryViewsBottomSheetDialogFragment +import org.thoughtcrime.securesms.util.AvatarUtil +import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.fragments.requireListener +import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout +import org.thoughtcrime.securesms.util.visible +import java.util.Locale +import kotlin.math.abs + +class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page), MediaPreviewFragment.Events, MultiselectForwardBottomSheet.Callback { + + private lateinit var progressBar: SegmentedProgressBar + + private lateinit var callback: Callback + + private lateinit var chrome: List + private var animatorSet: AnimatorSet? = null + + private val viewModel: StoryViewerPageViewModel by viewModels( + factoryProducer = { + StoryViewerPageViewModel.Factory(storyRecipientId, StoryViewerPageRepository(requireContext())) + } + ) + + private val lifecycleDisposable = LifecycleDisposable() + + private val storyRecipientId: RecipientId + get() = requireArguments().getParcelable(ARG_STORY_RECIPIENT_ID)!! + + @SuppressLint("ClickableViewAccessibility") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + callback = requireListener() + + val closeView: View = view.findViewById(R.id.close) + val senderAvatar: AvatarImageView = view.findViewById(R.id.sender_avatar) + val groupAvatar: AvatarImageView = view.findViewById(R.id.group_avatar) + val from: TextView = view.findViewById(R.id.from) + val date: TextView = view.findViewById(R.id.date) + val moreButton: View = view.findViewById(R.id.more) + val distributionList: TextView = view.findViewById(R.id.distribution_list) + val viewsAndReplies: TextView = view.findViewById(R.id.views_and_replies_bar) + val cardWrapper: TouchInterceptingFrameLayout = view.findViewById(R.id.story_content_card_touch_interceptor) + val card: CardView = view.findViewById(R.id.story_content_card) + val caption: TextView = view.findViewById(R.id.story_caption) + val largeCaption: TextView = view.findViewById(R.id.story_large_caption) + val largeCaptionOverlay: View = view.findViewById(R.id.story_large_caption_overlay) + + progressBar = view.findViewById(R.id.progress) + + chrome = listOf( + closeView, + senderAvatar, + groupAvatar, + from, + date, + moreButton, + distributionList, + viewsAndReplies, + progressBar + ) + + senderAvatar.setFallbackPhotoProvider(FallbackPhotoProvider()) + groupAvatar.setFallbackPhotoProvider(FallbackPhotoProvider()) + + closeView.setOnClickListener { + requireActivity().onBackPressed() + } + + val gestureDetector = GestureDetectorCompat( + requireContext(), + StoryGestureListener( + cardWrapper, + progressBar, + this::startReply + ) + ) + + cardWrapper.setOnInterceptTouchEventListener { true } + cardWrapper.setOnTouchListener { _, event -> + val result = gestureDetector.onTouchEvent(event) + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + progressBar.pause() + hideChrome() + } else if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) { + resumeProgressIfNotDisplayingDialog() + showChrome() + } + + result + } + + viewsAndReplies.setOnClickListener { + startReply() + } + + moreButton.setOnClickListener(this::displayMoreContextMenu) + + progressBar.listener = object : SegmentedProgressBarListener { + override fun onPage(oldPageIndex: Int, newPageIndex: Int) { + if (oldPageIndex != newPageIndex && context != null) { + viewModel.setSelectedPostIndex(newPageIndex) + + childFragmentManager.beginTransaction() + .replace(R.id.story_content_container, createFragmentForPost(viewModel.getPostAt(newPageIndex))) + .commit() + } + } + + override fun onFinished() { + callback.onFinishedPosts(storyRecipientId) + } + } + + viewModel.state.observe(viewLifecycleOwner) { state -> + if (state.posts.isNotEmpty() && state.selectedPostIndex < state.posts.size) { + val post = state.posts[state.selectedPostIndex] + + presentViewsAndReplies(viewsAndReplies, post) + presentSenderAvatar(senderAvatar, post) + presentGroupAvatar(groupAvatar, post) + presentFrom(from, post) + presentDate(date, post) + presentDistributionList(distributionList, post) + presentCaption(caption, largeCaption, largeCaptionOverlay, post) + + if (progressBar.segmentCount != state.posts.size) { + progressBar.segmentCount = state.posts.size + progressBar.segmentDurations = state.posts.mapIndexed { index, storyPost -> index to storyPost.durationMillis }.toMap() + progressBar.start() + } + } else if (state.selectedPostIndex >= state.posts.size) { + callback.onFinishedPosts(storyRecipientId) + } + } + + lifecycleDisposable.bindTo(viewLifecycleOwner) + lifecycleDisposable += viewModel.groupDirectReplyObservable.subscribe { opt -> + if (opt.isPresent) { + progressBar.pause() + when (val sheet = opt.get()) { + is StoryViewerDialog.GroupDirectReply -> { + onStartDirectReply(sheet.storyId, sheet.recipientId) + } + } + } else { + resumeProgress() + } + } + + adjustConstraintsForScreenDimensions(viewsAndReplies, cardWrapper, card) + } + + override fun onPause() { + super.onPause() + progressBar.pause() + } + + override fun onResume() { + super.onResume() + + if (progressBar.segmentCount != 0) { + progressBar.reset() + progressBar.setPosition(viewModel.getRestartIndex()) + } + + resumeProgressIfNotDisplayingDialog() + } + + override fun onFinishForwardAction() = Unit + + override fun onDismissForwardSheet() { + viewModel.onForwardDismissed() + } + + private fun hideChrome() { + animateChrome(0f) + } + + private fun showChrome() { + animateChrome(1f) + } + + private fun animateChrome(alphaTarget: Float) { + animatorSet?.cancel() + animatorSet = AnimatorSet().apply { + playTogether( + chrome.map { + ObjectAnimator.ofFloat(it, View.ALPHA, alphaTarget) + } + ) + start() + } + } + + private fun adjustConstraintsForScreenDimensions( + viewsAndReplies: View, + cardWrapper: View, + card: CardView + ) { + val constraintSet = ConstraintSet() + constraintSet.clone(requireView() as ConstraintLayout) + + when (StoryDisplay.getStoryDisplay(resources)) { + StoryDisplay.LARGE -> { + constraintSet.connect(viewsAndReplies.id, ConstraintSet.TOP, cardWrapper.id, ConstraintSet.BOTTOM) + constraintSet.connect(viewsAndReplies.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) + card.radius = DimensionUnit.DP.toPixels(18f) + } + StoryDisplay.MEDIUM -> { + constraintSet.clear(viewsAndReplies.id, ConstraintSet.TOP) + constraintSet.connect(viewsAndReplies.id, ConstraintSet.BOTTOM, cardWrapper.id, ConstraintSet.BOTTOM) + card.radius = DimensionUnit.DP.toPixels(18f) + } + StoryDisplay.SMALL -> { + constraintSet.clear(viewsAndReplies.id, ConstraintSet.TOP) + constraintSet.connect(viewsAndReplies.id, ConstraintSet.BOTTOM, cardWrapper.id, ConstraintSet.BOTTOM) + card.radius = DimensionUnit.DP.toPixels(0f) + } + } + + constraintSet.applyTo(requireView() as ConstraintLayout) + } + + private fun resumeProgressIfNotDisplayingDialog() { + if (childFragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) { + resumeProgress() + } + } + + private fun resumeProgress() { + if (progressBar.segmentCount != 0) { + progressBar.start() + } + } + + private fun startReply() { + val replyFragment: DialogFragment = when (viewModel.getSwipeToReplyState()) { + StoryViewerPageState.ReplyState.NONE -> return + StoryViewerPageState.ReplyState.SELF -> StoryViewsBottomSheetDialogFragment.create(viewModel.getPost().id) + StoryViewerPageState.ReplyState.GROUP -> StoryGroupReplyBottomSheetDialogFragment.create(viewModel.getPost().id, viewModel.getPost().group!!.id) + StoryViewerPageState.ReplyState.PRIVATE -> StoryDirectReplyDialogFragment.create(viewModel.getPost().id) + StoryViewerPageState.ReplyState.GROUP_SELF -> StoryViewsAndRepliesDialogFragment.create(viewModel.getPost().id, viewModel.getPost().group!!.id, getViewsAndRepliesDialogStartPage()) + } + + progressBar.pause() + replyFragment.showNow(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + + private fun onStartDirectReply(storyId: Long, recipientId: RecipientId) { + progressBar.pause() + StoryDirectReplyDialogFragment.create( + storyId = storyId, + recipientId = recipientId + ).show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + + private fun getViewsAndRepliesDialogStartPage(): StoryViewsAndRepliesDialogFragment.StartPage { + return if (viewModel.getPost().replyCount > 0) { + StoryViewsAndRepliesDialogFragment.StartPage.REPLIES + } else { + StoryViewsAndRepliesDialogFragment.StartPage.VIEWS + } + } + + private fun presentDistributionList(distributionList: TextView, storyPost: StoryPost) { + distributionList.text = storyPost.distributionList?.getDisplayName(requireContext()) + distributionList.visible = storyPost.distributionList != null && !storyPost.distributionList.isMyStory + } + + @SuppressLint("SetTextI18n") + private fun presentCaption(caption: TextView, largeCaption: TextView, largeCaptionOverlay: View, storyPost: StoryPost) { + val displayBody = storyPost.conversationMessage.getDisplayBody(requireContext()) + caption.text = displayBody + largeCaption.text = displayBody + caption.visible = displayBody.isNotEmpty() + caption.requestLayout() + + caption.doOnNextLayout { + val maxLines = 5 + if (caption.lineCount > maxLines) { + val lastCharShown = caption.layout.getLineVisibleEnd(maxLines - 1) + caption.maxLines = maxLines + + val seeMore = (getString(R.string.StoryViewerPageFragment__see_more)) + + val seeMoreWidth = caption.paint.measureText(seeMore) + var offset = seeMore.length + while (true) { + val start = lastCharShown - offset + if (start < 0) { + break + } + + val widthOfRemovedChunk = caption.paint.measureText(displayBody.subSequence(start, lastCharShown).toString()) + if (widthOfRemovedChunk > seeMoreWidth) { + break + } + + offset += 1 + } + + caption.text = displayBody.substring(0, lastCharShown - offset) + seeMore + caption.setOnClickListener { + onShowCaptionOverlay(caption, largeCaption, largeCaptionOverlay) + } + } else { + caption.setOnClickListener(null) + } + } + } + + private fun onShowCaptionOverlay(caption: TextView, largeCaption: TextView, largeCaptionOverlay: View) { + caption.visible = false + largeCaption.visible = true + largeCaptionOverlay.visible = true + largeCaptionOverlay.setOnClickListener { + onHideCaptionOverlay(caption, largeCaption, largeCaptionOverlay) + } + progressBar.pause() + } + + private fun onHideCaptionOverlay(caption: TextView, largeCaption: TextView, largeCaptionOverlay: View) { + caption.visible = true + largeCaption.visible = false + largeCaptionOverlay.visible = false + largeCaptionOverlay.setOnClickListener(null) + resumeProgress() + } + + private fun presentFrom(from: TextView, storyPost: StoryPost) { + val name = if (storyPost.sender.isSelf) { + getString(R.string.StoryViewerPageFragment__you) + } else { + storyPost.sender.getDisplayName(requireContext()) + } + + if (storyPost.group != null) { + from.text = getString(R.string.StoryViewerPageFragment__s_to_s, name, storyPost.group.getDisplayName(requireContext())) + } else { + from.text = name + } + } + + private fun presentDate(date: TextView, storyPost: StoryPost) { + date.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), storyPost.dateInMilliseconds) + } + + private fun presentSenderAvatar(senderAvatar: AvatarImageView, post: StoryPost) { + AvatarUtil.loadIconIntoImageView(post.sender, senderAvatar, DimensionUnit.DP.toPixels(32f).toInt()) + } + + private fun presentGroupAvatar(groupAvatar: AvatarImageView, post: StoryPost) { + if (post.group != null) { + groupAvatar.setRecipient(post.group) + groupAvatar.visible = true + } else { + groupAvatar.visible = false + } + } + + private fun presentViewsAndReplies(viewsAndReplies: TextView, post: StoryPost) { + val views = resources.getQuantityString(R.plurals.StoryViewerFragment__d_views, post.viewCount, post.viewCount) + val replies = resources.getQuantityString(R.plurals.StoryViewerFragment__d_replies, post.replyCount, post.replyCount) + + if (Recipient.self() == post.sender) { + if (post.replyCount == 0) { + viewsAndReplies.text = views + } else { + viewsAndReplies.text = getString(R.string.StoryViewerFragment__s_s, views, replies) + } + } else if (post.replyCount > 0) { + viewsAndReplies.text = replies + } else { + + viewsAndReplies.setText(R.string.StoryViewerPageFragment__reply) + } + } + + private fun createFragmentForPost(storyPost: StoryPost): Fragment { + return MediaPreviewFragment.newInstance(storyPost.attachment, true) + } + + private fun displayMoreContextMenu(anchor: View) { + progressBar.pause() + StoryContextMenu.show( + context = requireContext(), + anchorView = anchor, + storyViewerPageState = viewModel.getStateSnapshot(), + onDismiss = { + viewModel.onDismissContextMenu() + }, + onForward = { storyPost -> + viewModel.startForward() + MultiselectForwardFragmentArgs.create( + requireContext(), + storyPost.conversationMessage.multiselectCollection.toSet(), + ) { + MultiselectForwardFragment.showBottomSheet(childFragmentManager, it) + } + }, + onGoToChat = { + startActivity(ConversationIntents.createBuilder(requireContext(), storyRecipientId, -1L).build()) + }, + onHide = { + lifecycleDisposable += viewModel.hideStory().subscribe { + callback.onStoryHidden(storyRecipientId) + } + }, + onShare = { + StoryContextMenu.share(this, it.conversationMessage.messageRecord as MediaMmsMessageRecord) + }, + onSave = { + StoryContextMenu.save(requireContext(), it.conversationMessage.messageRecord) + }, + onDelete = { + viewModel.startDelete() + lifecycleDisposable += StoryContextMenu.delete(requireContext(), setOf(it.conversationMessage.messageRecord)).subscribe { _ -> + viewModel.onDeleteDismissed() + viewModel.refresh() + } + } + ) + } + + companion object { + private const val ARG_STORY_RECIPIENT_ID = "arg.story.recipient.id" + + fun create(recipientId: RecipientId): Fragment { + return StoryViewerPageFragment().apply { + arguments = Bundle().apply { + putParcelable(ARG_STORY_RECIPIENT_ID, recipientId) + } + } + } + } + + private class StoryGestureListener( + private val container: View, + private val progress: SegmentedProgressBar, + private val onReplyToPost: () -> Unit + ) : GestureDetector.SimpleOnGestureListener() { + + override fun onDown(e: MotionEvent?): Boolean { + return true + } + + override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean { + val isSideSwipe = abs(velocityX) > abs(velocityY) + if (!isSideSwipe) { + return false + } + + if (velocityX > 0) { + onReplyToPost() + } + + return true + } + + override fun onSingleTapUp(e: MotionEvent): Boolean { + if (e.x < container.measuredWidth * 0.25) { + performLeftAction() + return true + } else if (e.x > container.measuredWidth - (container.measuredWidth * 0.25)) { + performRightAction() + return true + } + + return false + } + + private fun performLeftAction() { + if (progress.layoutDirection == View.LAYOUT_DIRECTION_RTL) { + progress.next() + } else { + progress.previous() + } + } + + private fun performRightAction() { + if (progress.layoutDirection == View.LAYOUT_DIRECTION_RTL) { + progress.previous() + } else { + progress.next() + } + } + } + + private class FallbackPhotoProvider : Recipient.FallbackPhotoProvider() { + override fun getPhotoForGroup(): FallbackContactPhoto { + return FallbackPhoto20dp(R.drawable.ic_group_outline_20) + } + + override fun getPhotoForResolvingRecipient(): FallbackContactPhoto { + throw UnsupportedOperationException("This provider does not support resolving recipients") + } + + override fun getPhotoForLocalNumber(): FallbackContactPhoto { + throw UnsupportedOperationException("This provider does not support local number") + } + + override fun getPhotoForRecipientWithName(name: String, targetSize: Int): FallbackContactPhoto { + return FixedSizeGeneratedContactPhoto(name, R.drawable.ic_profile_outline_20) + } + + override fun getPhotoForRecipientWithoutName(): FallbackContactPhoto { + return FallbackPhoto20dp(R.drawable.ic_profile_outline_20) + } + } + + private class FixedSizeGeneratedContactPhoto(name: String, fallbackResId: Int) : GeneratedContactPhoto(name, fallbackResId) { + override fun newFallbackDrawable(context: Context, color: AvatarColor, inverted: Boolean): Drawable { + return FallbackPhoto20dp(fallbackResId).asDrawable(context, color, inverted) + } + } + + override fun singleTapOnMedia(): Boolean { + return false + } + + override fun mediaNotAvailable() { + // TODO [stories] -- Display appropriate error slate + } + + interface Callback { + fun onFinishedPosts(recipientId: RecipientId) + fun onStoryHidden(recipientId: RecipientId) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt new file mode 100644 index 0000000000..6582aafcd5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt @@ -0,0 +1,149 @@ +package org.thoughtcrime.securesms.stories.viewer.page + +import android.content.Context +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.concurrent.SignalExecutors +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.database.DatabaseObserver +import org.thoughtcrime.securesms.database.NoSuchMessageException +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +class StoryViewerPageRepository(context: Context) { + + private val context = context.applicationContext + + private fun getStoryRecords(recipientId: RecipientId): Observable> { + return Observable.create { emitter -> + val recipient = Recipient.resolved(recipientId) + + fun refresh() { + val stories = if (recipient.isMyStory) { + SignalDatabase.mms.allOutgoingStories + } else { + SignalDatabase.mms.getAllStoriesFor(recipientId) + } + + val results = mutableListOf() + + while (stories.next != null) { + if (!(recipient.isMyStory && stories.current.recipient.isGroup)) { + results.add(stories.current) + } + } + + emitter.onNext(results) + } + + val storyObserver = DatabaseObserver.Observer { + refresh() + } + + ApplicationDependencies.getDatabaseObserver().registerStoryObserver(recipientId, storyObserver) + emitter.setCancellable { + ApplicationDependencies.getDatabaseObserver().unregisterObserver(storyObserver) + } + + refresh() + } + } + + private fun getStoryPostFromRecord(recipientId: RecipientId, record: MessageRecord): Observable { + return Observable.create { emitter -> + fun refresh(record: MessageRecord) { + val recipient = Recipient.resolved(recipientId) + val story = StoryPost( + id = record.id, + sender = if (record.isOutgoing) Recipient.self() else record.individualRecipient, + group = if (recipient.isGroup) recipient else null, + distributionList = if (record.recipient.isDistributionList) record.recipient else null, + viewCount = record.viewedReceiptCount, + replyCount = SignalDatabase.mms.getNumberOfStoryReplies(record.id), + dateInMilliseconds = record.dateSent, + durationMillis = getDurationMillis(record as MmsMessageRecord), + attachment = record.slideDeck.firstSlide!!.asAttachment(), + conversationMessage = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, record) + ) + + emitter.onNext(story) + } + + val recipient = Recipient.resolved(recipientId) + + val messageUpdateObserver = DatabaseObserver.MessageObserver { + if (it.mms && it.id == record.id) { + try { + val messageRecord = SignalDatabase.mms.getMessageRecord(record.id) + if (messageRecord.isRemoteDelete) { + emitter.onComplete() + } else { + refresh(messageRecord) + } + } catch (e: NoSuchMessageException) { + emitter.onComplete() + } + } + } + + ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageUpdateObserver) + + val messageInsertObserver = DatabaseObserver.MessageObserver { + refresh(SignalDatabase.mms.getMessageRecord(record.id)) + } + + if (recipient.isGroup) { + ApplicationDependencies.getDatabaseObserver().registerMessageInsertObserver(record.threadId, messageInsertObserver) + } + + emitter.setCancellable { + ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageUpdateObserver) + + if (recipient.isGroup) { + ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageInsertObserver) + } + } + + refresh(record) + } + } + + fun getStoryPostsFor(recipientId: RecipientId): Observable> { + return getStoryRecords(recipientId) + .switchMap { records -> + val posts = records.map { getStoryPostFromRecord(recipientId, it) } + if (posts.isEmpty()) { + Observable.just(emptyList()) + } else { + Observable.combineLatest(posts) { it.toList() as List } + } + }.observeOn(Schedulers.io()) + } + + private fun getDurationMillis(record: MmsMessageRecord): Long { + val slide = record.slideDeck.firstSlide!! + return if (slide.hasVideo()) { + // TODO [stories] Remove duration from this stuff... Videos will need to actually start playback before we know how long they are... + 5000 + } else { + 5000 + } + } + + fun hideStory(recipientId: RecipientId): Completable { + return Completable.fromAction { + SignalDatabase.recipients.setHideStory(recipientId, true) + }.subscribeOn(Schedulers.io()) + } + + fun markRead(storyPost: StoryPost) { + // TODO [stories] -- Implementation + SignalExecutors.BOUNDED.execute { + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageState.kt new file mode 100644 index 0000000000..b0921bec94 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageState.kt @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.stories.viewer.page + +data class StoryViewerPageState( + val posts: List = emptyList(), + val selectedPostIndex: Int = 0, + val replyState: ReplyState = ReplyState.NONE +) { + /** + * Indicates which Reply method is available when the user swipes on the dialog + */ + enum class ReplyState { + /** + * Disabled state + */ + NONE, + + /** + * Story is from self and not in a group + */ + SELF, + + /** + * Story is not from self and in a group + */ + GROUP, + + /** + * Story is not from self and not in a group + */ + PRIVATE, + + /** + * Story is from self and in a group + */ + GROUP_SELF; + + companion object { + fun resolve(isFromSelf: Boolean, isToGroup: Boolean): ReplyState { + return when { + isFromSelf && isToGroup -> GROUP_SELF + isFromSelf && !isToGroup -> SELF + !isFromSelf && isToGroup -> GROUP + !isFromSelf && !isToGroup -> PRIVATE + else -> NONE + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt new file mode 100644 index 0000000000..a8ae7dc9b6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt @@ -0,0 +1,147 @@ +package org.thoughtcrime.securesms.stories.viewer.page + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.PublishSubject +import io.reactivex.rxjava3.subjects.Subject +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.livedata.Store +import java.util.Optional +import kotlin.math.min + +/** + * Encapsulates presentation logic for displaying a collection of posts from a given user's story + */ +class StoryViewerPageViewModel( + private val recipientId: RecipientId, + private val repository: StoryViewerPageRepository +) : ViewModel() { + + private val store = Store(StoryViewerPageState()) + private val disposables = CompositeDisposable() + private val storyViewerDialogSubject: Subject> = BehaviorSubject.createDefault(Optional.empty()) + private val dismissSubject = PublishSubject.create() + + val groupDirectReplyObservable: Observable> = Observable.combineLatest(storyViewerDialogSubject, dismissSubject) { sheet, dismissed -> + if (sheet.isPresent && sheet.get().type != dismissed) { + sheet + } else { + Optional.empty() + } + }.distinctUntilChanged { previous, current -> + if (current.isPresent) { + previous == current + } else { + false + } + } + + val state: LiveData = store.stateLiveData + + fun getStateSnapshot(): StoryViewerPageState = store.state + + init { + refresh() + } + + fun refresh() { + disposables.clear() + disposables += repository.getStoryPostsFor(recipientId).subscribe { posts -> + store.update { + it.copy( + posts = posts, + replyState = resolveSwipeToReplyState(it) + ) + } + } + } + + override fun onCleared() { + disposables.clear() + } + + fun hideStory(): Completable { + return repository.hideStory(recipientId) + } + + fun setSelectedPostIndex(index: Int) { + store.update { + it.copy( + selectedPostIndex = index, + replyState = resolveSwipeToReplyState(it, index) + ) + } + } + + fun getRestartIndex(): Int { + return min(store.state.selectedPostIndex, store.state.posts.lastIndex) + } + + fun getSwipeToReplyState(): StoryViewerPageState.ReplyState { + return store.state.replyState + } + + fun getPost(): StoryPost { + return store.state.posts[store.state.selectedPostIndex] + } + + fun startDirectReply(storyId: Long, recipientId: RecipientId) { + storyViewerDialogSubject.onNext(Optional.of(StoryViewerDialog.GroupDirectReply(recipientId, storyId))) + } + + fun startForward() { + storyViewerDialogSubject.onNext(Optional.of(StoryViewerDialog.Forward)) + } + + fun startDelete() { + storyViewerDialogSubject.onNext(Optional.of(StoryViewerDialog.Delete)) + } + + fun onForwardDismissed() { + dismissSubject.onNext(StoryViewerDialog.Type.FORWARD) + } + + fun onDeleteDismissed() { + dismissSubject.onNext(StoryViewerDialog.Type.DELETE) + } + + fun onDismissContextMenu() { + dismissSubject.onNext(StoryViewerDialog.Type.CONTEXT_MENU) + } + + fun onViewsAndRepliesSheetDismissed() { + dismissSubject.onNext(StoryViewerDialog.Type.VIEWS_AND_REPLIES) + } + + fun onDirectReplyDismissed() { + dismissSubject.onNext(StoryViewerDialog.Type.DIRECT_REPLY) + } + + private fun resolveSwipeToReplyState(state: StoryViewerPageState, index: Int = state.selectedPostIndex): StoryViewerPageState.ReplyState { + if (index !in state.posts.indices) { + return StoryViewerPageState.ReplyState.NONE + } + + val post = state.posts[index] + val isFromSelf = post.sender.isSelf + val isToGroup = post.group != null + + return StoryViewerPageState.ReplyState.resolve(isFromSelf, isToGroup) + } + + fun getPostAt(index: Int): StoryPost { + return store.state.posts[index] + } + + class Factory(private val recipientId: RecipientId, private val repository: StoryViewerPageRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(StoryViewerPageViewModel(recipientId, repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/TestFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/TestFragment.kt new file mode 100644 index 0000000000..9f9d855eca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/TestFragment.kt @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.stories.viewer.page + +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.View +import androidx.appcompat.widget.AppCompatImageView +import androidx.fragment.app.Fragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.conversation.colors.AvatarColor + +class TestFragment : Fragment(R.layout.test_fragment) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + (view as AppCompatImageView).setImageDrawable(ColorDrawable(AvatarColor.random().colorInt())) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/BottomSheetBehaviorDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/BottomSheetBehaviorDelegate.kt new file mode 100644 index 0000000000..bf1c87f008 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/BottomSheetBehaviorDelegate.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.stories.viewer.reply + +import android.view.View + +interface BottomSheetBehaviorDelegate { + fun onSlide(bottomSheet: View) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/StoryViewsAndRepliesPagerChild.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/StoryViewsAndRepliesPagerChild.kt new file mode 100644 index 0000000000..388533bb15 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/StoryViewsAndRepliesPagerChild.kt @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.stories.viewer.reply + +/** + * Implemented by a Fragment that may be the child of a view-pager. + * Used to be notified of page selection changes. + */ +interface StoryViewsAndRepliesPagerChild { + fun onPageSelected(child: StoryViewsAndRepliesPagerParent.Child) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/StoryViewsAndRepliesPagerParent.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/StoryViewsAndRepliesPagerParent.kt new file mode 100644 index 0000000000..3b8c9b5ed1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/StoryViewsAndRepliesPagerParent.kt @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.stories.viewer.reply + +import java.lang.IllegalArgumentException + +/** + * Implemented by a Fragment who contains a view-pager. + * Used to notify children when the selected child changes. + */ +interface StoryViewsAndRepliesPagerParent { + val selectedChild: Child + + enum class Child { + VIEWS, + REPLIES; + + companion object { + fun forIndex(index: Int): Child { + return when (index) { + 0 -> VIEWS + 1 -> REPLIES + else -> throw IllegalArgumentException() + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReactionBar.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReactionBar.kt new file mode 100644 index 0000000000..4b8e003a79 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReactionBar.kt @@ -0,0 +1,132 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.composer + +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.annotation.SuppressLint +import android.app.Dialog +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.animation.addListener +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.emoji.EmojiImageView +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.visible + +class StoryReactionBar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : ConstraintLayout(context, attrs) { + + var callback: Callback? = null + + private var animatorSet: AnimatorSet? = null + + private val emojiVerticalTranslation = context.resources.getDimensionPixelSize(R.dimen.reaction_scrubber_anim_start_translation_y) + + init { + inflate(context, R.layout.stories_reaction_bar, this) + } + + private val background: View = findViewById(R.id.conversation_reaction_scrubber_background) + private val emojiViews: List = listOf( + findViewById(R.id.reaction_1), + findViewById(R.id.reaction_2), + findViewById(R.id.reaction_3), + findViewById(R.id.reaction_4), + findViewById(R.id.reaction_5), + findViewById(R.id.reaction_6), + findViewById(R.id.reaction_7) + ) + + init { + if (!isInEditMode) { + val emojis = SignalStore.emojiValues().reactions + emojiViews.forEachIndexed { index, emojiImageView -> + if (index == emojiViews.lastIndex) { + emojiImageView.setImageResource(R.drawable.ic_any_emoji_32) + emojiImageView.setOnClickListener { onOpenReactionPicker() } + } else { + val emoji = SignalStore.emojiValues().getPreferredVariation(emojis[index]) + emojiImageView.setImageEmoji(emoji) + emojiImageView.setOnClickListener { onEmojiSelected(emoji) } + } + } + } + } + + @SuppressLint("Recycle") + fun show() { + visible = true + + animatorSet?.cancel() + animatorSet = AnimatorSet().apply { + + playTogether( + emojiViews.flatMap { + listOf(ObjectAnimator.ofFloat(it, View.ALPHA, 1f), ObjectAnimator.ofFloat(it, View.TRANSLATION_Y, 0f)) + } + ObjectAnimator.ofFloat(background, View.ALPHA, 1f) + ) + + start() + } + } + + private fun onEmojiSelected(emoji: String) { + // TODO [stories] -- Animation / Haptics + hide() + callback?.onReactionSelected(emoji) + } + + private fun onOpenReactionPicker() { + // TODO [stories] -- Animation / Haptics + hide() + callback?.onOpenReactionPicker() + } + + @SuppressLint("Recycle") + private fun hide() { + animatorSet?.cancel() + animatorSet = AnimatorSet().apply { + + playTogether( + emojiViews.flatMap { + listOf( + ObjectAnimator.ofFloat(it, View.ALPHA, 0f), + ObjectAnimator.ofFloat(it, View.TRANSLATION_Y, emojiVerticalTranslation.toFloat()) + ) + } + ObjectAnimator.ofFloat(background, View.ALPHA, 0f) + ) + + addListener(onEnd = { + visible = false + }) + start() + } + } + + interface Callback { + fun onReactionSelected(emoji: String) + fun onOpenReactionPicker() + } + + companion object { + fun installIntoBottomSheet(context: Context, dialog: Dialog): StoryReactionBar { + val container: ViewGroup = dialog.findViewById(R.id.container) + + val oldReactionBar: StoryReactionBar? = container.findViewById(R.id.reaction_bar) + if (oldReactionBar != null) { + return oldReactionBar + } + + val reactionBar = StoryReactionBar(context) + + reactionBar.id = R.id.reaction_bar + + container.addView(reactionBar) + return reactionBar + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReplyComposer.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReplyComposer.kt new file mode 100644 index 0000000000..4113d2cd98 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReplyComposer.kt @@ -0,0 +1,177 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.composer + +import android.app.Dialog +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.FrameLayout +import android.widget.TextView +import android.widget.ViewSwitcher +import androidx.core.widget.doAfterTextChanged +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ComposeText +import org.thoughtcrime.securesms.components.InputAwareLayout +import org.thoughtcrime.securesms.components.QuoteView +import org.thoughtcrime.securesms.components.emoji.EmojiToggle +import org.thoughtcrime.securesms.components.emoji.MediaKeyboard +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.Mention +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.visible + +class StoryReplyComposer @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private val inputAwareLayout: InputAwareLayout + private val quoteView: QuoteView + private val reactionButton: View + private val privacyChrome: TextView + private val emojiDrawerToggle: EmojiToggle + private val emojiDrawer: MediaKeyboard + + val input: ComposeText + + var isRequestingEmojiDrawer: Boolean = false + private set + + var callback: Callback? = null + + init { + inflate(context, R.layout.stories_reply_to_story_composer, this) + + inputAwareLayout = findViewById(R.id.input_aware_layout) + emojiDrawerToggle = findViewById(R.id.emoji_toggle) + quoteView = findViewById(R.id.quote_view) + input = findViewById(R.id.compose_text) + reactionButton = findViewById(R.id.reaction) + privacyChrome = findViewById(R.id.private_reply_recipient) + emojiDrawer = findViewById(R.id.emoji_drawer) + + val viewSwitcher: ViewSwitcher = findViewById(R.id.reply_reaction_switch) + val reply: View = findViewById(R.id.reply) + + reply.setOnClickListener { + callback?.onSendActionClicked() + } + + input.setOnEditorActionListener { _, actionId, _ -> + when (actionId) { + EditorInfo.IME_ACTION_SEND -> { + callback?.onSendActionClicked() + true + } + else -> false + } + } + + input.doAfterTextChanged { + if (it.isNullOrEmpty()) { + viewSwitcher.displayedChild = 0 + } else { + viewSwitcher.displayedChild = 1 + } + } + + reactionButton.setOnClickListener { + callback?.onPickReactionClicked() + } + + emojiDrawerToggle.setOnClickListener { + onEmojiToggleClicked() + } + } + + fun setQuote(messageRecord: MediaMmsMessageRecord) { + quoteView.setQuote( + GlideApp.with(this), + messageRecord.dateSent, + messageRecord.recipient, + null, + false, + messageRecord.slideDeck, + null + ) + + quoteView.visible = true + } + + fun displayPrivacyChrome(recipient: Recipient) { + privacyChrome.text = context.getString(R.string.StoryReplyComposer__replying_privately_to_s, recipient.getDisplayName(context)) + privacyChrome.visible = true + } + + fun consumeInput(): Pair> { + val trimmedText = input.textTrimmed.toString() + val mentions = input.mentions + + input.setText("") + + return trimmedText to mentions + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + callback?.onHeightChanged(h) + } + + fun openEmojiSearch() { + emojiDrawer.onOpenEmojiSearch() + } + + fun onEmojiSelected(emoji: String?) { + input.insertEmoji(emoji) + } + + fun closeEmojiSearch() { + emojiDrawer.onCloseEmojiSearch() + } + + private fun onEmojiToggleClicked() { + if (!emojiDrawer.isInitialised) { + callback?.onInitializeEmojiDrawer(emojiDrawer) + emojiDrawerToggle.attach(emojiDrawer) + } + + if (inputAwareLayout.currentInput == emojiDrawer) { + isRequestingEmojiDrawer = false + inputAwareLayout.showSoftkey(input) + } else { + isRequestingEmojiDrawer = true + inputAwareLayout.hideSoftkey(input) { + inputAwareLayout.post { + inputAwareLayout.show(input, emojiDrawer) + } + } + } + } + + interface Callback { + fun onSendActionClicked() + fun onPickReactionClicked() + fun onInitializeEmojiDrawer(mediaKeyboard: MediaKeyboard) + fun onHeightChanged(height: Int) + } + + companion object { + fun installIntoBottomSheet(context: Context, dialog: Dialog): StoryReplyComposer { + val container: ViewGroup = dialog.findViewById(R.id.container) + + val oldComposer: StoryReplyComposer? = container.findViewById(R.id.input) + if (oldComposer != null) { + return oldComposer + } + + val composer = StoryReplyComposer(context) + + composer.id = R.id.input + + container.addView(composer) + return composer + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyDialogFragment.kt new file mode 100644 index 0000000000..ededb1bf7a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyDialogFragment.kt @@ -0,0 +1,152 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.direct + +import android.content.DialogInterface +import android.os.Bundle +import android.view.KeyEvent +import android.view.View +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment +import org.thoughtcrime.securesms.components.emoji.EmojiEventListener +import org.thoughtcrime.securesms.components.emoji.MediaKeyboard +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.keyboard.KeyboardPage +import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel +import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment +import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageViewModel +import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReactionBar +import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReplyComposer +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.ViewUtil + +/** + * Dialog displayed when the user decides to send a private reply to a story. + */ +class StoryDirectReplyDialogFragment : + KeyboardEntryDialogFragment(R.layout.stories_reply_to_story_fragment), + EmojiKeyboardPageFragment.Callback, + EmojiEventListener, + EmojiSearchFragment.Callback { + + private val lifecycleDisposable = LifecycleDisposable() + + private val viewModel: StoryDirectReplyViewModel by viewModels( + factoryProducer = { + StoryDirectReplyViewModel.Factory(storyId, recipientId, StoryDirectReplyRepository()) + } + ) + + private val keyboardPagerViewModel: KeyboardPagerViewModel by viewModels( + ownerProducer = { requireActivity() } + ) + + private val storyViewerPageViewModel: StoryViewerPageViewModel by viewModels( + ownerProducer = { requireParentFragment() } + ) + + private lateinit var input: StoryReplyComposer + + private val storyId: Long + get() = requireArguments().getLong(ARG_STORY_ID) + + private val recipientId: RecipientId? + get() = requireArguments().getParcelable(ARG_RECIPIENT_ID) + + override val withDim: Boolean = true + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val reactionBar: StoryReactionBar = view.findViewById(R.id.reaction_bar) + + lifecycleDisposable.bindTo(viewLifecycleOwner) + + input = view.findViewById(R.id.input) + input.callback = object : StoryReplyComposer.Callback { + override fun onSendActionClicked() { + lifecycleDisposable += viewModel.send(input.consumeInput().first) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + Toast.makeText(requireContext(), R.string.StoryDirectReplyDialogFragment__reply_sent, Toast.LENGTH_LONG).show() + dismissAllowingStateLoss() + } + } + + override fun onPickReactionClicked() { + reactionBar.show() + } + + override fun onInitializeEmojiDrawer(mediaKeyboard: MediaKeyboard) { + keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI) + mediaKeyboard.setFragmentManager(childFragmentManager) + } + + override fun onHeightChanged(height: Int) = Unit + } + + viewModel.state.observe(viewLifecycleOwner) { state -> + if (state.recipient != null) { + input.displayPrivacyChrome(state.recipient) + } + + if (state.storyRecord != null) { + input.setQuote(state.storyRecord as MediaMmsMessageRecord) + } + } + } + + override fun onResume() { + super.onResume() + + ViewUtil.focusAndShowKeyboard(input) + } + + override fun onPause() { + super.onPause() + + ViewUtil.hideKeyboard(requireContext(), input) + } + + override fun openEmojiSearch() { + input.openEmojiSearch() + } + + override fun onKeyboardHidden() { + if (!input.isRequestingEmojiDrawer) { + super.onKeyboardHidden() + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + storyViewerPageViewModel.onDirectReplyDismissed() + } + + companion object { + + private const val ARG_STORY_ID = "arg.story.id" + private const val ARG_RECIPIENT_ID = "arg.recipient.id" + + fun create(storyId: Long, recipientId: RecipientId? = null): DialogFragment { + return StoryDirectReplyDialogFragment().apply { + arguments = Bundle().apply { + putLong(ARG_STORY_ID, storyId) + putParcelable(ARG_RECIPIENT_ID, recipientId) + } + } + } + } + + override fun onEmojiSelected(emoji: String?) { + input.onEmojiSelected(emoji) + } + + override fun closeEmojiSearch() { + input.closeEmojiSearch() + } + + override fun onKeyEvent(keyEvent: KeyEvent?) = Unit +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyRepository.kt new file mode 100644 index 0000000000..e3f1c53222 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyRepository.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.direct + +import android.content.Context +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage +import org.thoughtcrime.securesms.mms.QuoteModel +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.sms.MessageSender + +class StoryDirectReplyRepository { + + fun getStoryPost(storyId: Long): Single { + return Single.fromCallable { + SignalDatabase.mms.getMessageRecord(storyId) + }.subscribeOn(Schedulers.io()) + } + + fun send(context: Context, storyId: Long, groupDirectReplyRecipientId: RecipientId?, charSequence: CharSequence): Completable { + return Completable.create { emitter -> + val message = SignalDatabase.mms.getMessageRecord(storyId) as MediaMmsMessageRecord + val (recipient, threadId) = if (groupDirectReplyRecipientId == null) { + message.recipient to message.threadId + } else { + val resolved = Recipient.resolved(groupDirectReplyRecipientId) + resolved to SignalDatabase.threads.getOrCreateThreadIdFor(resolved) + } + + val quoteAuthor: Recipient = if (message.isOutgoing) { + Recipient.self() + } else { + message.individualRecipient + } + + if (!quoteAuthor.serviceId.isPresent || !quoteAuthor.e164.isPresent) { + throw AssertionError("Bad quote author.") + } + + MessageSender.send( + context, + OutgoingMediaMessage( + recipient, + charSequence.toString(), + emptyList(), + System.currentTimeMillis(), + 0, + 0L, + false, + 0, + false, + null, + QuoteModel(message.dateSent, quoteAuthor.id, "", false, message.slideDeck.asAttachments(), null), + emptyList(), + emptyList(), + emptyList(), + emptySet(), + emptySet() + ), + threadId, + false, + null + ) { + emitter.onComplete() + } + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyState.kt new file mode 100644 index 0000000000..426378238d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyState.kt @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.direct + +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.recipients.Recipient + +data class StoryDirectReplyState( + val recipient: Recipient? = null, + val storyRecord: MessageRecord? = null +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyViewModel.kt new file mode 100644 index 0000000000..748829d6d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyViewModel.kt @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.direct + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.livedata.Store + +class StoryDirectReplyViewModel( + context: Context, + private val storyId: Long, + private val groupDirectReplyRecipientId: RecipientId?, + private val repository: StoryDirectReplyRepository +) : ViewModel() { + + private val context = context.applicationContext + + private val store = Store(StoryDirectReplyState()) + private val disposables = CompositeDisposable() + + val state: LiveData = store.stateLiveData + + init { + if (groupDirectReplyRecipientId != null) { + store.update(Recipient.live(groupDirectReplyRecipientId).liveDataResolved) { recipient, state -> + state.copy(recipient = recipient) + } + } + + disposables += repository.getStoryPost(storyId).subscribe { record -> + store.update { it.copy(storyRecord = record) } + } + } + + fun send(charSequence: CharSequence): Completable { + return repository.send(context, storyId, groupDirectReplyRecipientId, charSequence) + } + + override fun onCleared() { + super.onCleared() + disposables.clear() + } + + class Factory( + private val storyId: Long, + private val groupDirectReplyRecipientId: RecipientId?, + private val repository: StoryDirectReplyRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast( + StoryDirectReplyViewModel(ApplicationDependencies.getApplication(), storyId, groupDirectReplyRecipientId, repository) + ) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..ed012e6704 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyBottomSheetDialogFragment.kt @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.group + +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +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.recipients.RecipientId +import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageViewModel +import org.thoughtcrime.securesms.stories.viewer.reply.BottomSheetBehaviorDelegate +import org.thoughtcrime.securesms.util.LifecycleDisposable + +/** + * Wraps a StoryGroupReplyFragment in a BottomSheetDialog + */ +class StoryGroupReplyBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment(), StoryGroupReplyFragment.Callback { + + override val themeResId: Int + get() = R.style.Widget_Signal_FixedRoundedCorners_Stories + + private val storyId: Long + get() = requireArguments().getLong(ARG_STORY_ID) + + private val groupRecipientId: RecipientId + get() = requireArguments().getParcelable(ARG_GROUP_RECIPIENT_ID)!! + + override val peekHeightPercentage: Float = 1f + + private val lifecycleDisposable = LifecycleDisposable() + + private val storyViewerPageViewModel: StoryViewerPageViewModel by viewModels( + ownerProducer = { requireParentFragment() } + ) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.bottom_sheet_container, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + lifecycleDisposable.bindTo(viewLifecycleOwner) + if (savedInstanceState == null) { + childFragmentManager.beginTransaction() + .replace(R.id.fragment_container, StoryGroupReplyFragment.create(storyId, groupRecipientId)) + .commitAllowingStateLoss() + } + + val bottomSheetBehavior = (requireDialog() as BottomSheetDialog).behavior + bottomSheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) = Unit + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + childFragmentManager.fragments.forEach { + if (it is BottomSheetBehaviorDelegate) { + it.onSlide(bottomSheet) + } + } + } + }) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + storyViewerPageViewModel.onViewsAndRepliesSheetDismissed() + } + + override fun onStartDirectReply(recipientId: RecipientId) { + dismiss() + storyViewerPageViewModel.startDirectReply(storyId, recipientId) + } + + companion object { + private const val ARG_STORY_ID = "arg.story.id" + private const val ARG_GROUP_RECIPIENT_ID = "arg.group.recipient.id" + + fun create(storyId: Long, groupRecipientId: RecipientId): DialogFragment { + return StoryGroupReplyBottomSheetDialogFragment().apply { + arguments = Bundle().apply { + putLong(ARG_STORY_ID, storyId) + putParcelable(ARG_GROUP_RECIPIENT_ID, groupRecipientId) + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt new file mode 100644 index 0000000000..a421bc0517 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.group + +import android.database.Cursor +import org.signal.paging.PagedDataSource +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.recipients.Recipient +import java.lang.UnsupportedOperationException + +class StoryGroupReplyDataSource(private val parentStoryId: Long) : PagedDataSource { + override fun size(): Int { + return SignalDatabase.mms.getNumberOfStoryReplies(parentStoryId) + } + + override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList { + val results: MutableList = ArrayList(length) + SignalDatabase.mms.getStoryReplies(parentStoryId).use { cursor -> + cursor.moveToPosition(start - 1) + val reader = MmsDatabase.Reader(cursor) + while (cursor.moveToNext() && cursor.position < start + length) { + results.add(readRowFromRecord(reader.current)) + } + } + + return results + } + + override fun load(key: StoryGroupReplyItemData.Key?): StoryGroupReplyItemData? { + throw UnsupportedOperationException() + } + + override fun getKey(data: StoryGroupReplyItemData): StoryGroupReplyItemData.Key { + return data.key + } + + private fun readRowFromRecord(record: MessageRecord): StoryGroupReplyItemData { + return readMessageRecordFromCursor(record) + } + + private fun readReactionFromCursor(cursor: Cursor): StoryGroupReplyItemData { + throw NotImplementedError("TODO -- Need to know what the special story reaction record looks like.") + } + + private fun readMessageRecordFromCursor(messageRecord: MessageRecord): StoryGroupReplyItemData { + return StoryGroupReplyItemData( + key = StoryGroupReplyItemData.Key.Text(messageRecord.id), + sender = if (messageRecord.isOutgoing) Recipient.self() else messageRecord.individualRecipient, + sentAtMillis = messageRecord.dateSent, + replyBody = StoryGroupReplyItemData.ReplyBody.Text( + ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(ApplicationDependencies.getApplication(), messageRecord) + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt new file mode 100644 index 0000000000..0937056762 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt @@ -0,0 +1,308 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.group + +import android.content.ClipData +import android.os.Bundle +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.doOnNextLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.emoji.MediaKeyboard +import org.thoughtcrime.securesms.components.mention.MentionAnnotation +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.conversation.colors.Colorizer +import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerFragment +import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel +import org.thoughtcrime.securesms.keyboard.KeyboardPage +import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel +import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardCallback +import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment +import org.thoughtcrime.securesms.stories.viewer.reply.BottomSheetBehaviorDelegate +import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerChild +import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerParent +import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReactionBar +import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReplyComposer +import org.thoughtcrime.securesms.util.DeleteDialog +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.Projection +import org.thoughtcrime.securesms.util.ServiceUtil +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter +import org.thoughtcrime.securesms.util.fragments.findListener +import org.thoughtcrime.securesms.util.fragments.requireListener +import org.thoughtcrime.securesms.util.visible +import java.lang.AssertionError + +/** + * Fragment which contains UI to reply to a group story + */ +class StoryGroupReplyFragment : + Fragment(R.layout.stories_group_replies_fragment), + StoryViewsAndRepliesPagerChild, + BottomSheetBehaviorDelegate, + StoryReplyComposer.Callback, + StoryReactionBar.Callback, + EmojiKeyboardCallback, + ReactWithAnyEmojiBottomSheetDialogFragment.Callback { + + private val viewModel: StoryGroupReplyViewModel by viewModels( + factoryProducer = { + StoryGroupReplyViewModel.Factory(storyId, StoryGroupReplyRepository()) + } + ) + + private val mentionsViewModel: MentionsPickerViewModel by viewModels( + factoryProducer = { MentionsPickerViewModel.Factory() }, + ownerProducer = { requireActivity() } + ) + + private val keyboardPagerViewModel: KeyboardPagerViewModel by viewModels( + ownerProducer = { requireActivity() } + ) + + private val colorizer = Colorizer() + private val lifecycleDisposable = LifecycleDisposable() + + private val storyId: Long + get() = requireArguments().getLong(ARG_STORY_ID) + + private val groupRecipientId: RecipientId + get() = requireArguments().getParcelable(ARG_GROUP_RECIPIENT_ID)!! + + private lateinit var recyclerView: RecyclerView + private lateinit var composer: StoryReplyComposer + private lateinit var reactionBar: StoryReactionBar + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + recyclerView = view.findViewById(R.id.recycler) + composer = view.findViewById(R.id.composer) + reactionBar = view.findViewById(R.id.reaction_bar) + + lifecycleDisposable.bindTo(viewLifecycleOwner) + + val emptyNotice: View = requireView().findViewById(R.id.empty_notice) + + val adapter = PagingMappingAdapter() + val layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, true) + recyclerView.layoutManager = layoutManager + recyclerView.adapter = adapter + recyclerView.itemAnimator = null + StoryGroupReplyItem.register(adapter) + + composer.callback = this + reactionBar.callback = this + + onPageSelected(findListener()?.selectedChild ?: StoryViewsAndRepliesPagerParent.Child.REPLIES) + + viewModel.state.observe(viewLifecycleOwner) { state -> + emptyNotice.visible = state.noReplies && state.loadState == StoryGroupReplyState.LoadState.READY + colorizer.onNameColorsChanged(state.nameColors) + } + + viewModel.pagingController.observe(viewLifecycleOwner) { controller -> + adapter.setPagingController(controller) + } + + viewModel.pageData.observe(viewLifecycleOwner) { pageData -> + val isScrolledToBottom = recyclerView.canScrollVertically(-1) + adapter.submitList(getConfiguration(pageData).toMappingModelList()) { + if (isScrolledToBottom) { + recyclerView.doOnNextLayout { + recyclerView.smoothScrollToPosition(0) + } + } + } + } + + initializeMentions() + } + + override fun onDestroyView() { + super.onDestroyView() + + composer.input.setMentionQueryChangedListener(null) + composer.input.setMentionValidator(null) + } + + private fun getConfiguration(pageData: List): DSLConfiguration { + return configure { + pageData.filterNotNull().forEach { + when (it.replyBody) { + is StoryGroupReplyItemData.ReplyBody.Text -> { + customPref( + StoryGroupReplyItem.TextModel( + storyGroupReplyItemData = it, + text = it.replyBody, + nameColor = colorizer.getIncomingGroupSenderColor( + requireContext(), + it.sender + ), + onPrivateReplyClick = { model -> + requireListener().onStartDirectReply(model.storyGroupReplyItemData.sender.id) + }, + onCopyClick = { model -> + val clipData = ClipData.newPlainText(requireContext().getString(R.string.app_name), model.text.message.getDisplayBody(requireContext())) + ServiceUtil.getClipboardManager(requireContext()).setPrimaryClip(clipData) + Toast.makeText(requireContext(), R.string.StoryGroupReplyFragment__copied_to_clipboard, Toast.LENGTH_SHORT).show() + }, + onDeleteClick = { model -> + lifecycleDisposable += DeleteDialog.show(requireActivity(), setOf(model.text.message.messageRecord)).subscribe { result -> + if (result) { + throw AssertionError("We should never end up deleting a Group Thread like this.") + } + } + }, + onMentionClick = { recipientId -> + RecipientBottomSheetDialogFragment + .create(recipientId, null) + .show(childFragmentManager, null) + } + ) + ) + } + is StoryGroupReplyItemData.ReplyBody.Reaction -> { + customPref( + StoryGroupReplyItem.ReactionModel( + storyGroupReplyItemData = it, + reaction = it.replyBody, + nameColor = colorizer.getIncomingGroupSenderColor( + requireContext(), + it.sender + ) + ) + ) + } + } + } + } + } + + override fun onSlide(bottomSheet: View) { + val inputProjection = Projection.relativeToViewRoot(composer, null) + val parentProjection = Projection.relativeToViewRoot(bottomSheet.parent as ViewGroup, null) + composer.translationY = (parentProjection.height + parentProjection.y - (inputProjection.y + inputProjection.height)) + reactionBar.translationY = composer.translationY + inputProjection.release() + parentProjection.release() + } + + override fun onPageSelected(child: StoryViewsAndRepliesPagerParent.Child) { + recyclerView.isNestedScrollingEnabled = child == StoryViewsAndRepliesPagerParent.Child.REPLIES + } + + override fun onSendActionClicked() { + val (body, mentions) = composer.consumeInput() + lifecycleDisposable += StoryGroupReplySender.sendReply(requireContext(), storyId, body, mentions).subscribe() + } + + override fun onPickReactionClicked() { + reactionBar.show() + } + + override fun onEmojiSelected(emoji: String?) { + composer.onEmojiSelected(emoji) + } + + override fun onReactionSelected(emoji: String) { + lifecycleDisposable += StoryGroupReplySender.sendReaction(requireContext(), storyId, emoji).subscribe() + } + + override fun onKeyEvent(keyEvent: KeyEvent?) = Unit + + override fun onOpenReactionPicker() { + ReactWithAnyEmojiBottomSheetDialogFragment.createForStory().show(childFragmentManager, null) + } + + override fun onInitializeEmojiDrawer(mediaKeyboard: MediaKeyboard) { + keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI) + mediaKeyboard.setFragmentManager(childFragmentManager) + } + + override fun openEmojiSearch() { + composer.openEmojiSearch() + } + + override fun closeEmojiSearch() { + composer.closeEmojiSearch() + } + + override fun onReactWithAnyEmojiDialogDismissed() { + } + + override fun onReactWithAnyEmojiSelected(emoji: String) { + onReactionSelected(emoji) + } + + override fun onHeightChanged(height: Int) { + ViewUtil.setPaddingBottom(recyclerView, height) + } + + private fun initializeMentions() { + Recipient.live(groupRecipientId).observe(viewLifecycleOwner) { recipient -> + mentionsViewModel.onRecipientChange(recipient) + + composer.input.setMentionQueryChangedListener { query -> + if (recipient.isPushV2Group) { + ensureMentionsContainerFilled() + mentionsViewModel.onQueryChange(query) + } + } + + composer.input.setMentionValidator { annotations -> + if (!recipient.isPushV2Group) { + annotations + } else { + + val validRecipientIds: Set = recipient.participants + .map { r -> MentionAnnotation.idToMentionAnnotationValue(r.id) } + .toSet() + + annotations + .filter { !validRecipientIds.contains(it.value) } + .toList() + } + } + } + + mentionsViewModel.selectedRecipient.observe(viewLifecycleOwner) { recipient -> + composer.input.replaceTextWithMention(recipient.getDisplayName(requireContext()), recipient.id) + } + } + + private fun ensureMentionsContainerFilled() { + val mentionsFragment = childFragmentManager.findFragmentById(R.id.mentions_picker_container) + if (mentionsFragment == null) { + childFragmentManager + .beginTransaction() + .replace(R.id.mentions_picker_container, MentionsPickerFragment()) + .commitNowAllowingStateLoss() + } + } + + companion object { + private const val ARG_STORY_ID = "arg.story.id" + private const val ARG_GROUP_RECIPIENT_ID = "arg.group.recipient.id" + + fun create(storyId: Long, groupRecipientId: RecipientId): Fragment { + return StoryGroupReplyFragment().apply { + arguments = Bundle().apply { + putLong(ARG_STORY_ID, storyId) + putParcelable(ARG_GROUP_RECIPIENT_ID, groupRecipientId) + } + } + } + } + + interface Callback { + fun onStartDirectReply(recipientId: RecipientId) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItem.kt new file mode 100644 index 0000000000..6fa99f4067 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItem.kt @@ -0,0 +1,209 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.group + +import android.content.Context +import android.text.Spannable +import android.text.Spanned +import android.text.TextPaint +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.ColorInt +import org.signal.core.util.DimensionUnit +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.components.FromTextView +import org.thoughtcrime.securesms.components.emoji.EmojiImageView +import org.thoughtcrime.securesms.components.emoji.EmojiTextView +import org.thoughtcrime.securesms.components.mention.MentionAnnotation +import org.thoughtcrime.securesms.components.menu.ActionItem +import org.thoughtcrime.securesms.components.menu.SignalContextMenu +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.AvatarUtil +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder +import org.thoughtcrime.securesms.util.visible +import java.util.Locale + +object StoryGroupReplyItem { + + private const val NAME_COLOR_CHANGED = 1 + + fun register(mappingAdapter: MappingAdapter) { + mappingAdapter.registerFactory(TextModel::class.java, LayoutFactory(::TextViewHolder, R.layout.stories_group_text_reply_item)) + mappingAdapter.registerFactory(ReactionModel::class.java, LayoutFactory(::ReactionViewHolder, R.layout.stories_group_reaction_reply_item)) + } + + class TextModel( + val storyGroupReplyItemData: StoryGroupReplyItemData, + val text: StoryGroupReplyItemData.ReplyBody.Text, + @ColorInt val nameColor: Int, + val onPrivateReplyClick: (TextModel) -> Unit, + val onCopyClick: (TextModel) -> Unit, + val onDeleteClick: (TextModel) -> Unit, + val onMentionClick: (RecipientId) -> Unit + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: TextModel): Boolean { + return storyGroupReplyItemData.sender == newItem.storyGroupReplyItemData.sender && + storyGroupReplyItemData.sentAtMillis == newItem.storyGroupReplyItemData.sentAtMillis + } + + override fun areContentsTheSame(newItem: TextModel): Boolean { + return storyGroupReplyItemData == newItem.storyGroupReplyItemData && + storyGroupReplyItemData.sender.hasSameContent(newItem.storyGroupReplyItemData.sender) && + nameColor == newItem.nameColor && + super.areContentsTheSame(newItem) + } + + override fun getChangePayload(newItem: TextModel): Any? { + return if (nameColor != newItem.nameColor && + storyGroupReplyItemData == newItem.storyGroupReplyItemData && + storyGroupReplyItemData.sender.hasSameContent(newItem.storyGroupReplyItemData.sender) && + super.areContentsTheSame(newItem) + ) { + NAME_COLOR_CHANGED + } else { + null + } + } + } + + class ReactionModel( + val storyGroupReplyItemData: StoryGroupReplyItemData, + val reaction: StoryGroupReplyItemData.ReplyBody.Reaction, + @ColorInt val nameColor: Int + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: ReactionModel): Boolean { + return storyGroupReplyItemData.sender == newItem.storyGroupReplyItemData.sender && + storyGroupReplyItemData.sentAtMillis == newItem.storyGroupReplyItemData.sentAtMillis + } + + override fun areContentsTheSame(newItem: ReactionModel): Boolean { + return storyGroupReplyItemData == newItem.storyGroupReplyItemData && + storyGroupReplyItemData.sender.hasSameContent(newItem.storyGroupReplyItemData.sender) && + nameColor == newItem.nameColor && + super.areContentsTheSame(newItem) + } + + override fun getChangePayload(newItem: ReactionModel): Any? { + return if (nameColor != newItem.nameColor && + storyGroupReplyItemData == newItem.storyGroupReplyItemData && + storyGroupReplyItemData.sender.hasSameContent(newItem.storyGroupReplyItemData.sender) && + super.areContentsTheSame(newItem) + ) { + NAME_COLOR_CHANGED + } else { + null + } + } + } + + private class TextViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val avatar: AvatarImageView = itemView.findViewById(R.id.avatar) + private val name: FromTextView = itemView.findViewById(R.id.name) + private val body: EmojiTextView = itemView.findViewById(R.id.body) + private val date: TextView = itemView.findViewById(R.id.viewed_at) + private val dateBelow: TextView = itemView.findViewById(R.id.viewed_at_below) + + init { + body.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + if (body.lastLineWidth + date.measuredWidth > ViewUtil.dpToPx(242)) { + date.visible = false + dateBelow.visible = true + } else { + dateBelow.visible = false + date.visible = true + } + } + } + + override fun bind(model: TextModel) { + itemView.setOnLongClickListener { + displayContextMenu(model) + true + } + + name.setTextColor(model.nameColor) + if (payload.contains(NAME_COLOR_CHANGED)) { + return + } + + AvatarUtil.loadIconIntoImageView(model.storyGroupReplyItemData.sender, avatar, DimensionUnit.DP.toPixels(28f).toInt()) + name.text = resolveName(context, model.storyGroupReplyItemData.sender) + + body.movementMethod = LinkMovementMethod.getInstance() + body.text = model.text.message.getDisplayBody(context).apply { + linkifyBody(model, this) + } + + date.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), model.storyGroupReplyItemData.sentAtMillis) + dateBelow.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), model.storyGroupReplyItemData.sentAtMillis) + } + + private fun displayContextMenu(model: TextModel) { + itemView.isSelected = true + SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup) + .preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START) + .onDismiss { itemView.isSelected = false } + .show( + listOf( + ActionItem(R.drawable.ic_reply_24_solid_tinted, context.getString(R.string.StoryGroupReplyItem__private_reply)) { model.onPrivateReplyClick(model) }, + ActionItem(R.drawable.ic_copy_24_solid_tinted, context.getString(R.string.StoryGroupReplyItem__copy)) { model.onCopyClick(model) }, + ActionItem(R.drawable.ic_trash_24_solid_tinted, context.getString(R.string.StoryGroupReplyItem__delete)) { model.onDeleteClick(model) } + ) + ) + } + + private fun linkifyBody(model: TextModel, body: Spannable) { + val mentionAnnotations = MentionAnnotation.getMentionAnnotations(body) + for (annotation in mentionAnnotations) { + body.setSpan(MentionClickableSpan(model, RecipientId.from(annotation.value)), body.getSpanStart(annotation), body.getSpanEnd(annotation), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + + private class MentionClickableSpan( + private val model: TextModel, + private val mentionedRecipientId: RecipientId + ) : ClickableSpan() { + override fun onClick(widget: View) { + model.onMentionClick(mentionedRecipientId) + } + + override fun updateDrawState(ds: TextPaint) {} + } + } + + private class ReactionViewHolder(itemView: View) : MappingViewHolder(itemView) { + private val avatar: AvatarImageView = itemView.findViewById(R.id.avatar) + private val name: FromTextView = itemView.findViewById(R.id.name) + private val reaction: EmojiImageView = itemView.findViewById(R.id.reaction) + private val date: TextView = itemView.findViewById(R.id.viewed_at) + + override fun bind(model: ReactionModel) { + name.setTextColor(model.nameColor) + if (payload.contains(NAME_COLOR_CHANGED)) { + return + } + + AvatarUtil.loadIconIntoImageView(model.storyGroupReplyItemData.sender, avatar, DimensionUnit.DP.toPixels(28f).toInt()) + name.text = resolveName(context, model.storyGroupReplyItemData.sender) + reaction.setImageEmoji(model.reaction.emoji) + date.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), model.storyGroupReplyItemData.sentAtMillis) + } + } + + private fun resolveName(context: Context, recipient: Recipient): String { + return if (recipient.isSelf) { + context.getString(R.string.StoryViewerPageFragment__you) + } else { + recipient.getDisplayName(context) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItemData.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItemData.kt new file mode 100644 index 0000000000..5fe19b1326 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItemData.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.group + +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.recipients.Recipient + +data class StoryGroupReplyItemData( + val key: Key, + val sender: Recipient, + val sentAtMillis: Long, + val replyBody: ReplyBody +) { + sealed class ReplyBody { + data class Text(val message: ConversationMessage) : ReplyBody() + data class Reaction(val emoji: CharSequence) : ReplyBody() + } + + sealed class Key { + data class Text(val messageId: Long) : Key() + data class Reaction(val reactionId: Long) : Key() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyRepository.kt new file mode 100644 index 0000000000..7c22b47ea8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyRepository.kt @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.group + +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.paging.PagedData +import org.signal.paging.PagingConfig +import org.thoughtcrime.securesms.database.DatabaseObserver +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.recipients.RecipientId + +class StoryGroupReplyRepository { + + fun getPagedReplies(parentStoryId: Long): Observable> { + return Observable.create> { emitter -> + fun refresh() { + emitter.onNext(PagedData.create(StoryGroupReplyDataSource(parentStoryId), PagingConfig.Builder().build())) + } + + val observer = DatabaseObserver.Observer { + refresh() + } + + val messageObserver = DatabaseObserver.MessageObserver { + refresh() + } + + val threadId = SignalDatabase.mms.getThreadIdForMessage(parentStoryId) + + ApplicationDependencies.getDatabaseObserver().registerMessageInsertObserver(threadId, messageObserver) + ApplicationDependencies.getDatabaseObserver().registerConversationObserver(threadId, observer) + + emitter.setCancellable { + ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer) + ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver) + } + + refresh() + }.subscribeOn(Schedulers.io()) + } + + fun getStoryOwner(storyId: Long): Single { + return Single.fromCallable { + SignalDatabase.mms.getMessageRecord(storyId).individualRecipient.id + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt new file mode 100644 index 0000000000..e90162194c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.group + +import android.content.Context +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.Mention +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage +import org.thoughtcrime.securesms.sms.MessageSender + +/** + * Stateless message sender for Story Group replies and reactions. + */ +object StoryGroupReplySender { + fun sendReply(context: Context, storyId: Long, body: CharSequence, mentions: List): Completable { + return Completable.create { + + val message = SignalDatabase.mms.getMessageRecord(storyId) + val recipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId)!! + + MessageSender.send( + context, + OutgoingMediaMessage( + recipient, + body.toString(), + emptyList(), + System.currentTimeMillis(), + 0, + 0L, + false, + 0, + false, + MessageId.fromNullable(message.id, true), + null, + emptyList(), + emptyList(), + mentions, + emptySet(), + emptySet() + ), + message.threadId, + false, + null + ) { + it.onComplete() + } + }.subscribeOn(Schedulers.io()) + } + + fun sendReaction(context: Context, storyId: Long, emoji: String): Completable { + // TODO [stories] + return Completable.complete() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyState.kt new file mode 100644 index 0000000000..cd7cd1ded5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyState.kt @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.group + +import org.thoughtcrime.securesms.conversation.colors.NameColor +import org.thoughtcrime.securesms.recipients.RecipientId + +data class StoryGroupReplyState( + val noReplies: Boolean = true, + val nameColors: Map = emptyMap(), + val loadState: LoadState = LoadState.INIT +) { + enum class LoadState { + INIT, + READY + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyViewModel.kt new file mode 100644 index 0000000000..eaf5f2f309 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyViewModel.kt @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.group + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.signal.paging.PagedData +import org.signal.paging.PagingController +import org.thoughtcrime.securesms.conversation.colors.NameColors +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.livedata.Store + +class StoryGroupReplyViewModel(storyId: Long, repository: StoryGroupReplyRepository) : ViewModel() { + + private val sessionMemberCache: MutableMap> = NameColors.createSessionMembersCache() + private val store = Store(StoryGroupReplyState()) + private val disposables = CompositeDisposable() + + val state: LiveData = store.stateLiveData + + private val pagedData: MutableLiveData> = MutableLiveData() + + val pagingController: LiveData> + val pageData: LiveData> + + init { + disposables += repository.getPagedReplies(storyId).subscribe { + pagedData.postValue(it) + } + + pagingController = Transformations.map(pagedData) { it.controller } + pageData = Transformations.switchMap(pagedData) { it.data } + store.update(pageData) { data, state -> + state.copy( + noReplies = data.isEmpty(), + loadState = StoryGroupReplyState.LoadState.READY + ) + } + + disposables += repository.getStoryOwner(storyId).observeOn(AndroidSchedulers.mainThread()).subscribe { recipientId -> + store.update(NameColors.getNameColorsMapLiveData(MutableLiveData(recipientId), sessionMemberCache)) { nameColors, state -> + state.copy(nameColors = nameColors) + } + } + } + + override fun onCleared() { + disposables.clear() + } + + class Factory(private val storyId: Long, private val repository: StoryGroupReplyRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(StoryGroupReplyViewModel(storyId, repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/tabs/StoryViewsAndRepliesDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/tabs/StoryViewsAndRepliesDialogFragment.kt new file mode 100644 index 0000000000..d5f32bae8b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/tabs/StoryViewsAndRepliesDialogFragment.kt @@ -0,0 +1,154 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.tabs + +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageViewModel +import org.thoughtcrime.securesms.stories.viewer.reply.BottomSheetBehaviorDelegate +import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerChild +import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerParent +import org.thoughtcrime.securesms.stories.viewer.reply.group.StoryGroupReplyFragment +import org.thoughtcrime.securesms.util.LifecycleDisposable + +/** + * Tab based host for Views and Replies + */ +class StoryViewsAndRepliesDialogFragment : FixedRoundedCornerBottomSheetDialogFragment(), StoryViewsAndRepliesPagerParent, StoryGroupReplyFragment.Callback { + + override val themeResId: Int + get() = R.style.Widget_Signal_FixedRoundedCorners_Stories + + private val storyId: Long + get() = requireArguments().getLong(ARG_STORY_ID) + + private val groupRecipientId: RecipientId + get() = requireArguments().getParcelable(ARG_GROUP_RECIPIENT_ID)!! + + private val startPageIndex: Int + get() = requireArguments().getInt(ARG_START_PAGE) + + override val peekHeightPercentage: Float = 1f + + private lateinit var pager: ViewPager2 + + private val storyViewerPageViewModel: StoryViewerPageViewModel by viewModels( + ownerProducer = { requireParentFragment() } + ) + + override val selectedChild: StoryViewsAndRepliesPagerParent.Child + get() = StoryViewsAndRepliesPagerParent.Child.forIndex(pager.currentItem) + + private val onPageChangeCallback = PageChangeCallback() + private val lifecycleDisposable = LifecycleDisposable() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.stories_views_and_replies_fragment, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + pager = view.findViewById(R.id.pager) + + val bottomSheetBehavior = (requireDialog() as BottomSheetDialog).behavior + bottomSheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) = Unit + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + childFragmentManager.fragments.forEach { + if (it is BottomSheetBehaviorDelegate) { + it.onSlide(bottomSheet) + } + } + } + }) + + val tabs: TabLayout = view.findViewById(R.id.tab_layout) + + ViewCompat.setNestedScrollingEnabled(tabs, false) + pager.adapter = StoryViewsAndRepliesPagerAdapter(this, storyId, groupRecipientId) + pager.currentItem = startPageIndex + + TabLayoutMediator(tabs, pager) { tab, position -> + when (position) { + 0 -> tab.setText(R.string.StoryViewsAndRepliesDialogFragment__views) + 1 -> tab.setText(R.string.StoryViewsAndRepliesDialogFragment__replies) + } + }.attach() + + lifecycleDisposable.bindTo(viewLifecycleOwner) + } + + override fun onResume() { + super.onResume() + pager.registerOnPageChangeCallback(onPageChangeCallback) + } + + override fun onPause() { + super.onPause() + pager.unregisterOnPageChangeCallback(onPageChangeCallback) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + storyViewerPageViewModel.onViewsAndRepliesSheetDismissed() + } + + override fun onStartDirectReply(recipientId: RecipientId) { + dismiss() + storyViewerPageViewModel.startDirectReply(storyId, recipientId) + } + + private inner class PageChangeCallback : ViewPager2.OnPageChangeCallback() { + override fun onPageScrollStateChanged(state: Int) { + if (state == ViewPager2.SCROLL_STATE_IDLE) { + pager.requestLayout() + } + } + + override fun onPageSelected(position: Int) { + pager.post { + childFragmentManager.fragments.forEach { + if (it is StoryViewsAndRepliesPagerChild) { + it.onPageSelected(StoryViewsAndRepliesPagerParent.Child.forIndex(position)) + } + if (it is BottomSheetBehaviorDelegate) { + it.onSlide(requireView().parent as View) + } + } + } + } + } + + companion object { + private const val ARG_STORY_ID = "arg.story.id" + private const val ARG_START_PAGE = "arg.start.page" + private const val ARG_GROUP_RECIPIENT_ID = "arg.group.recipient.id" + + fun create(storyId: Long, groupRecipientId: RecipientId, startPage: StartPage): DialogFragment { + return StoryViewsAndRepliesDialogFragment().apply { + arguments = Bundle().apply { + putLong(ARG_STORY_ID, storyId) + putInt(ARG_START_PAGE, startPage.index) + putParcelable(ARG_GROUP_RECIPIENT_ID, groupRecipientId) + } + } + } + } + + enum class StartPage(val index: Int) { + VIEWS(0), + REPLIES(1) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/tabs/StoryViewsAndRepliesPagerAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/tabs/StoryViewsAndRepliesPagerAdapter.kt new file mode 100644 index 0000000000..0001eebf9d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/tabs/StoryViewsAndRepliesPagerAdapter.kt @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.stories.viewer.reply.tabs + +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.adapter.FragmentStateAdapter +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.viewer.reply.group.StoryGroupReplyFragment +import org.thoughtcrime.securesms.stories.viewer.views.StoryViewsFragment + +class StoryViewsAndRepliesPagerAdapter( + fragment: Fragment, + private val storyId: Long, + private val groupRecipientId: RecipientId +) : FragmentStateAdapter(fragment) { + override fun getItemCount(): Int = 2 + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + recyclerView.isNestedScrollingEnabled = false + } + + override fun createFragment(position: Int): Fragment { + return when (position) { + 0 -> StoryViewsFragment.create(storyId) + 1 -> StoryGroupReplyFragment.create(storyId, groupRecipientId) + else -> throw IndexOutOfBoundsException() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewItem.kt new file mode 100644 index 0000000000..b18565bb53 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewItem.kt @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.stories.viewer.views + +import android.view.View +import android.widget.TextView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder +import java.util.Locale + +/** + * UI consisting of a recipient's avatar, name, and when they viewed a story + */ +object StoryViewItem { + + fun register(mappingAdapter: MappingAdapter) { + mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.stories_story_view_item)) + } + + class Model( + val storyViewItemData: StoryViewItemData + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean { + return storyViewItemData.recipient == newItem.storyViewItemData.recipient + } + + override fun areContentsTheSame(newItem: Model): Boolean { + return storyViewItemData == newItem.storyViewItemData && + storyViewItemData.recipient.hasSameContent(newItem.storyViewItemData.recipient) && + super.areContentsTheSame(newItem) + } + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val avatarView: AvatarImageView = itemView.findViewById(R.id.avatar) + private val nameView: TextView = itemView.findViewById(R.id.name) + private val viewedAtView: TextView = itemView.findViewById(R.id.viewed_at) + + override fun bind(model: Model) { + avatarView.setAvatar(model.storyViewItemData.recipient) + nameView.text = model.storyViewItemData.recipient.getDisplayName(context) + viewedAtView.text = formatDate(model.storyViewItemData.timeViewedInMillis) + } + + private fun formatDate(dateInMilliseconds: Long): String { + return DateUtils.formatDateWithDayOfWeek(Locale.getDefault(), dateInMilliseconds) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewItemData.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewItemData.kt new file mode 100644 index 0000000000..2ef1971ec4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewItemData.kt @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.stories.viewer.views + +import org.thoughtcrime.securesms.recipients.Recipient + +data class StoryViewItemData( + val recipient: Recipient, + val timeViewedInMillis: Long +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewsBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewsBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..acaf1086d3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewsBottomSheetDialogFragment.kt @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.stories.viewer.views + +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment +import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageViewModel + +/** + * Wraps StoryViewsFragment in a BottomSheetDialog + */ +class StoryViewsBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() { + + override val themeResId: Int + get() = R.style.Widget_Signal_FixedRoundedCorners_Stories + + private val storyId: Long + get() = requireArguments().getLong(ARG_STORY_ID) + + private val storyViewerPageViewModel: StoryViewerPageViewModel by viewModels( + ownerProducer = { requireParentFragment() } + ) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.bottom_sheet_container, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + if (savedInstanceState == null) { + childFragmentManager.beginTransaction() + .replace(R.id.fragment_container, StoryViewsFragment.create(storyId)) + .commitAllowingStateLoss() + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + storyViewerPageViewModel.onViewsAndRepliesSheetDismissed() + } + + companion object { + private const val ARG_STORY_ID = "arg.story.id" + + fun create(storyId: Long): DialogFragment { + return StoryViewsBottomSheetDialogFragment().apply { + arguments = Bundle().apply { + putLong(ARG_STORY_ID, storyId) + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewsFragment.kt new file mode 100644 index 0000000000..72bb1719af --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewsFragment.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.stories.viewer.views + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerChild +import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerParent +import org.thoughtcrime.securesms.util.fragments.findListener +import org.thoughtcrime.securesms.util.visible + +/** + * Fragment that displays who viewed a given story. This is only available if + * the sender is self. + */ +class StoryViewsFragment : + DSLSettingsFragment( + layoutId = R.layout.stories_views_fragment + ), + StoryViewsAndRepliesPagerChild { + + private val viewModel: StoryViewsViewModel by viewModels( + factoryProducer = { + StoryViewsViewModel.Factory(storyId, StoryViewsRepository()) + } + ) + + private val storyId: Long + get() = requireArguments().getLong(ARG_STORY_ID) + + override fun bindAdapter(adapter: DSLSettingsAdapter) { + StoryViewItem.register(adapter) + + val emptyNotice: View = requireView().findViewById(R.id.empty_notice) + + onPageSelected(findListener()?.selectedChild ?: StoryViewsAndRepliesPagerParent.Child.VIEWS) + + viewModel.state.observe(viewLifecycleOwner) { + emptyNotice.visible = it.loadState == StoryViewsState.LoadState.READY && it.views.isEmpty() + adapter.submitList(getConfiguration(it).toMappingModelList()) + } + } + + override fun onPageSelected(child: StoryViewsAndRepliesPagerParent.Child) { + recyclerView?.isNestedScrollingEnabled = child == StoryViewsAndRepliesPagerParent.Child.VIEWS + } + + private fun getConfiguration(state: StoryViewsState): DSLConfiguration { + return configure { + state.views.forEach { + customPref(StoryViewItem.Model(it)) + } + } + } + + companion object { + private const val ARG_STORY_ID = "arg.story.id" + + fun create(storyId: Long): Fragment { + return StoryViewsFragment().apply { + arguments = Bundle().apply { + putLong(ARG_STORY_ID, storyId) + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewsRepository.kt new file mode 100644 index 0000000000..c02a897911 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewsRepository.kt @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.stories.viewer.views + +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.DatabaseObserver +import org.thoughtcrime.securesms.database.GroupReceiptDatabase +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.recipients.Recipient + +class StoryViewsRepository { + fun getViews(storyId: Long): Observable> { + return Observable.create> { emitter -> + fun refresh() { + emitter.onNext( + SignalDatabase.groupReceipts.getGroupReceiptInfo(storyId).filter { + it.status == GroupReceiptDatabase.STATUS_READ + }.map { + StoryViewItemData( + recipient = Recipient.resolved(it.recipientId), + timeViewedInMillis = it.timestamp + ) + } + ) + } + + val observer = DatabaseObserver.MessageObserver { refresh() } + + ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(observer) + emitter.setCancellable { + ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer) + } + + refresh() + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewsState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewsState.kt new file mode 100644 index 0000000000..079f4cbf0a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewsState.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.stories.viewer.views + +data class StoryViewsState( + val loadState: LoadState = LoadState.INIT, + val views: List = emptyList() +) { + enum class LoadState { + INIT, + READY + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewsViewModel.kt new file mode 100644 index 0000000000..357aa1cd1d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/views/StoryViewsViewModel.kt @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.stories.viewer.views + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.thoughtcrime.securesms.util.livedata.Store + +class StoryViewsViewModel(storyId: Long, repository: StoryViewsRepository) : ViewModel() { + + private val store = Store(StoryViewsState()) + private val disposables = CompositeDisposable() + + val state: LiveData = store.stateLiveData + + init { + disposables += repository.getViews(storyId).subscribe { data -> + store.update { + it.copy( + views = data, + loadState = StoryViewsState.LoadState.READY + ) + } + } + } + + override fun onCleared() { + disposables.clear() + } + + class Factory( + private val storyId: Long, + private val repository: StoryViewsRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(StoryViewsViewModel(storyId, repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt new file mode 100644 index 0000000000..423f9a4868 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.util + +import android.content.Context +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.core.SingleEmitter +import org.signal.core.util.concurrent.SignalExecutors +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.sms.MessageSender +import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask + +object DeleteDialog { + + fun show( + context: Context, + messageRecords: Set, + title: CharSequence = context.resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageRecords.size, messageRecords.size), + message: CharSequence? = null, + forceRemoteDelete: Boolean = false + ): Single = Single.create { emitter -> + val builder = MaterialAlertDialogBuilder(context) + + builder.setTitle(title) + builder.setMessage(message) + builder.setCancelable(true) + + if (forceRemoteDelete) { + builder.setPositiveButton(R.string.ConversationFragment_delete_for_everyone) { _, _ -> deleteForEveryone(messageRecords, emitter) } + } else { + builder.setPositiveButton(R.string.ConversationFragment_delete_for_me) { _, _ -> + DeleteProgressDialogAsyncTask(context, messageRecords, emitter::onSuccess).executeOnExecutor(SignalExecutors.BOUNDED) + } + + if (RemoteDeleteUtil.isValidSend(messageRecords, System.currentTimeMillis())) { + builder.setNeutralButton(R.string.ConversationFragment_delete_for_everyone) { _, _ -> handleDeleteForEveryone(context, messageRecords, emitter) } + } + } + + builder.setNegativeButton(android.R.string.cancel) { _, _ -> emitter.onSuccess(false) } + builder.setOnCancelListener { emitter.onSuccess(false) } + builder.show() + } + + private fun handleDeleteForEveryone(context: Context, messageRecords: Set, emitter: SingleEmitter) { + if (SignalStore.uiHints().hasConfirmedDeleteForEveryoneOnce()) { + deleteForEveryone(messageRecords, emitter) + } else { + MaterialAlertDialogBuilder(context) + .setMessage(R.string.ConversationFragment_this_message_will_be_deleted_for_everyone_in_the_conversation) + .setPositiveButton(R.string.ConversationFragment_delete_for_everyone) { _, _ -> + SignalStore.uiHints().markHasConfirmedDeleteForEveryoneOnce() + deleteForEveryone(messageRecords, emitter) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> emitter.onSuccess(false) } + .setOnCancelListener { emitter.onSuccess(false) } + .show() + } + } + + private fun deleteForEveryone(messageRecords: Set, emitter: SingleEmitter) { + SignalExecutors.BOUNDED.execute { + messageRecords.forEach { message -> + MessageSender.sendRemoteDelete(ApplicationDependencies.getApplication(), message.id, message.isMms) + } + + emitter.onSuccess(false) + } + } + + private class DeleteProgressDialogAsyncTask( + context: Context, + private val messageRecords: Set, + private val onDeletionCompleted: ((Boolean) -> Unit) + ) : ProgressDialogAsyncTask( + context, + R.string.ConversationFragment_deleting, + R.string.ConversationFragment_deleting_messages + ) { + override fun doInBackground(vararg params: Void?): Boolean { + return messageRecords.map { record -> + if (record.isMms) { + SignalDatabase.mms.deleteMessage(record.id) + } else { + SignalDatabase.sms.deleteMessage(record.id) + } + }.any { it } + } + + override fun onPostExecute(result: Boolean?) { + super.onPostExecute(result) + onDeletionCompleted(result == true) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/EnumUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/EnumUtils.kt new file mode 100644 index 0000000000..628f5f26a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/EnumUtils.kt @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.util + +/** + * Treating an Enum as a circular list, returns the "next" + * value after the caller, wrapping around to the first value + * in the enum as necessary. + */ +inline fun > T.next(): T { + val values = enumValues() + val nextOrdinal = (ordinal + 1) % values.size + + return values[nextOrdinal] +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 749e9dc7db..1191696884 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.groups.SelectionLimits; import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.keyvalue.StoryValues; import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver; import java.io.IOException; @@ -91,6 +92,9 @@ public final class FeatureFlags { private static final String CDSH = "android.cdsh"; private static final String HARDWARE_AEC_MODELS = "android.calling.hardwareAecModels"; private static final String FORCE_DEFAULT_AEC = "android.calling.forceDefaultAec"; + private static final String STORIES = "android.stories"; + private static final String STORIES_TEXT_FUNCTIONS = "android.stories.text.functions"; + private static final String STORIES_TEXT_POSTS = "android.stories.text.posts"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -134,7 +138,10 @@ public final class FeatureFlags { CHANGE_NUMBER_ENABLED, HARDWARE_AEC_MODELS, FORCE_DEFAULT_AEC, - VALENTINES_DONATE_MEGAPHONE + VALENTINES_DONATE_MEGAPHONE, + STORIES, + STORIES_TEXT_FUNCTIONS, + STORIES_TEXT_POSTS ); @VisibleForTesting @@ -209,12 +216,13 @@ public final class FeatureFlags { * These can be called on any thread, including the main thread, so be careful! * * Also note that this doesn't play well with {@link #FORCED_VALUES} -- changes there will not - * trigger changes in this map, so you'll have to do some manually hacking to get yourself in the + * trigger changes in this map, so you'll have to do some manual hacking to get yourself in the * desired test state. */ private static final Map FLAG_CHANGE_LISTENERS = new HashMap() {{ put(MESSAGE_PROCESSOR_ALARM_INTERVAL, change -> MessageProcessReceiver.startOrUpdateAlarm(ApplicationDependencies.getApplication())); put(SENDER_KEY, change -> ApplicationDependencies.getJobManager().add(new RefreshAttributesJob())); + put(STORIES, change -> ApplicationDependencies.getJobManager().add(new RefreshAttributesJob())); }}; private static final Map REMOTE_VALUES = new TreeMap<>(); @@ -429,6 +437,27 @@ public final class FeatureFlags { } } + /** + * Whether or not stories are available + */ + public static boolean stories() { + return getBoolean(STORIES, false); + } + + /** + * Whether users can apply alignment and scale to text posts + */ + public static boolean storiesTextFunctions() { + return getBoolean(STORIES_TEXT_FUNCTIONS, false); + } + + /** + * Whether the user supports sending Story text posts + */ + public static boolean storiesTextPosts() { + return getBoolean(STORIES_TEXT_POSTS, false); + } + /** * Whether or not donor badges should be displayed throughout the app. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java index a8592de9db..416b5e46dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.mms.MessageGroupContext; import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.InvalidMessageException; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.messages.SignalServiceContent; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; @@ -52,6 +53,12 @@ public final class GroupUtil { content.getSyncMessage().get().getSent().get().getMessage().getGroupContext().isPresent()) { return content.getSyncMessage().get().getSent().get().getMessage().getGroupContext().get(); + } else if (content.getStoryMessage().isPresent() && content.getStoryMessage().get().getGroupContext().isPresent()) { + try { + return SignalServiceGroupContext.create(null, content.getStoryMessage().get().getGroupContext().get()); + } catch (InvalidMessageException e) { + throw new AssertionError(e); + } } else { return null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java index 3c9ec9b93e..c64afaf97c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java @@ -10,6 +10,7 @@ import com.annimon.stream.Stream; import net.zetetic.database.sqlcipher.SQLiteDatabase; +import org.thoughtcrime.securesms.database.model.DatabaseId; import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.libsignal.util.guava.Preconditions; @@ -89,8 +90,8 @@ public final class SqlUtil { for (int i = 0; i < objects.length; i++) { if (objects[i] == null) { throw new NullPointerException("Cannot have null arg!"); - } else if (objects[i] instanceof RecipientId) { - args[i] = ((RecipientId) objects[i]).serialize(); + } else if (objects[i] instanceof DatabaseId) { + args[i] = ((DatabaseId) objects[i]).serialize(); } else { args[i] = objects[i].toString(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ThemedFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ThemedFragment.kt new file mode 100644 index 0000000000..5f06dff929 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ThemedFragment.kt @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.util + +import android.os.Bundle +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.LayoutRes +import androidx.annotation.StyleRes +import androidx.fragment.app.Fragment + +/** + * "Mixin" to theme a Fragment with the given themeResId. This is making me wish Kotlin + * had a stronger generic type system. + */ +object ThemedFragment { + + private const val UNSET = -1 + private const val THEME_RES_ID = "ThemedFragment::theme_res_id" + + @JvmStatic + val Fragment.themeResId: Int + get() = arguments?.getInt(THEME_RES_ID) ?: UNSET + + @JvmStatic + fun Fragment.themedInflate(@LayoutRes layoutId: Int, inflater: LayoutInflater, container: ViewGroup?): View? { + return if (themeResId != UNSET) { + inflater.cloneInContext(ContextThemeWrapper(inflater.context, themeResId)).inflate(layoutId, container, false) + } else { + inflater.inflate(layoutId, container, false) + } + } + + @JvmStatic + fun Fragment.withTheme(@StyleRes themeId: Int): Fragment { + arguments = (arguments ?: Bundle()).apply { + putInt(THEME_RES_ID, themeId) + } + return this + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/task/ProgressDialogAsyncTask.java b/app/src/main/java/org/thoughtcrime/securesms/util/task/ProgressDialogAsyncTask.java index e862d5d4f5..5f8b69381b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/task/ProgressDialogAsyncTask.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/task/ProgressDialogAsyncTask.java @@ -4,6 +4,8 @@ import android.app.ProgressDialog; import android.content.Context; import android.os.AsyncTask; +import androidx.annotation.CallSuper; + import java.lang.ref.WeakReference; public abstract class ProgressDialogAsyncTask extends AsyncTask { @@ -30,6 +32,7 @@ public abstract class ProgressDialogAsyncTask extends if (context != null) progress = ProgressDialog.show(context, title, message, true); } + @CallSuper @Override protected void onPostExecute(Result result) { if (progress != null) progress.dismiss(); diff --git a/app/src/main/proto/Database.proto b/app/src/main/proto/Database.proto index 368743adde..a9510c0c84 100644 --- a/app/src/main/proto/Database.proto +++ b/app/src/main/proto/Database.proto @@ -179,6 +179,7 @@ message ChatColor { message RecipientExtras { bool manuallyShownAvatar = 1; + bool hideStory = 2; } message CustomAvatar { diff --git a/app/src/main/res/color/story_pill_text_color.xml b/app/src/main/res/color/story_pill_text_color.xml new file mode 100644 index 0000000000..c8271164a7 --- /dev/null +++ b/app/src/main/res/color/story_pill_text_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-ldltr-night/ic_forward_24_tinted.xml b/app/src/main/res/drawable-ldltr-night/ic_forward_24_tinted.xml new file mode 100644 index 0000000000..ec6c6ac412 --- /dev/null +++ b/app/src/main/res/drawable-ldltr-night/ic_forward_24_tinted.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable-ldltr/ic_forward_24_tinted.xml b/app/src/main/res/drawable-ldltr/ic_forward_24_tinted.xml new file mode 100644 index 0000000000..0e77677c20 --- /dev/null +++ b/app/src/main/res/drawable-ldltr/ic_forward_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-ldrtl/ic_reply_24_solid_tinted.xml b/app/src/main/res/drawable-ldrtl/ic_reply_24_solid_tinted.xml new file mode 100644 index 0000000000..ec6c6ac412 --- /dev/null +++ b/app/src/main/res/drawable-ldrtl/ic_reply_24_solid_tinted.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable-ldrtl/ic_text_end.xml b/app/src/main/res/drawable-ldrtl/ic_text_end.xml new file mode 100644 index 0000000000..a875922fd0 --- /dev/null +++ b/app/src/main/res/drawable-ldrtl/ic_text_end.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable-ldrtl/ic_text_start.xml b/app/src/main/res/drawable-ldrtl/ic_text_start.xml new file mode 100644 index 0000000000..aa368c2013 --- /dev/null +++ b/app/src/main/res/drawable-ldrtl/ic_text_start.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/contact_selection_checkbox_dialog.xml b/app/src/main/res/drawable-night/contact_selection_checkbox_dialog.xml new file mode 100644 index 0000000000..f8146f6716 --- /dev/null +++ b/app/src/main/res/drawable-night/contact_selection_checkbox_dialog.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/ic_delete_24_tinted.xml b/app/src/main/res/drawable-night/ic_delete_24_tinted.xml new file mode 100644 index 0000000000..c255646ce7 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_delete_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_download_24.xml b/app/src/main/res/drawable-night/ic_download_24.xml new file mode 100644 index 0000000000..3d7ae93d30 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_download_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_download_24_tinted.xml b/app/src/main/res/drawable-night/ic_download_24_tinted.xml new file mode 100644 index 0000000000..6500038280 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_download_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-v21/selectable_list_item_background.xml b/app/src/main/res/drawable-v21/selectable_list_item_background.xml new file mode 100644 index 0000000000..3e27d357e2 --- /dev/null +++ b/app/src/main/res/drawable-v21/selectable_list_item_background.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avatar_story_ring.xml b/app/src/main/res/drawable/avatar_story_ring.xml new file mode 100644 index 0000000000..f21aea3ef4 --- /dev/null +++ b/app/src/main/res/drawable/avatar_story_ring.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/contact_selection_checkbox_dialog.xml b/app/src/main/res/drawable/contact_selection_checkbox_dialog.xml new file mode 100644 index 0000000000..2931ed51db --- /dev/null +++ b/app/src/main/res/drawable/contact_selection_checkbox_dialog.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/conversation_tab_icon_background.xml b/app/src/main/res/drawable/conversation_tab_icon_background.xml new file mode 100644 index 0000000000..aa4416e96e --- /dev/null +++ b/app/src/main/res/drawable/conversation_tab_icon_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_chevron_down_24.xml b/app/src/main/res/drawable/ic_chevron_down_24.xml new file mode 100644 index 0000000000..e5939d5a94 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_down_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_up_24.xml b/app/src/main/res/drawable/ic_chevron_up_24.xml new file mode 100644 index 0000000000..89e244632a --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_up_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_circle_x_24_tinted.xml b/app/src/main/res/drawable/ic_circle_x_24_tinted.xml new file mode 100644 index 0000000000..c333b4ae3a --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_x_24_tinted.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_copy_24_solid_tinted.xml b/app/src/main/res/drawable/ic_copy_24_solid_tinted.xml new file mode 100644 index 0000000000..b9ae6fe646 --- /dev/null +++ b/app/src/main/res/drawable/ic_copy_24_solid_tinted.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete_24_tinted.xml b/app/src/main/res/drawable/ic_delete_24_tinted.xml new file mode 100644 index 0000000000..a87c94dadd --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_done_button.xml b/app/src/main/res/drawable/ic_done_button.xml new file mode 100644 index 0000000000..4f57fc0967 --- /dev/null +++ b/app/src/main/res/drawable/ic_done_button.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_download_24.xml b/app/src/main/res/drawable/ic_download_24.xml new file mode 100644 index 0000000000..688b0cbd70 --- /dev/null +++ b/app/src/main/res/drawable/ic_download_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_download_24_tinted.xml b/app/src/main/res/drawable/ic_download_24_tinted.xml new file mode 100644 index 0000000000..000e495daa --- /dev/null +++ b/app/src/main/res/drawable/ic_download_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_font_bold.xml b/app/src/main/res/drawable/ic_font_bold.xml new file mode 100644 index 0000000000..c2668d49fa --- /dev/null +++ b/app/src/main/res/drawable/ic_font_bold.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_font_condensed.xml b/app/src/main/res/drawable/ic_font_condensed.xml new file mode 100644 index 0000000000..21ced7ae9b --- /dev/null +++ b/app/src/main/res/drawable/ic_font_condensed.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_font_regular.xml b/app/src/main/res/drawable/ic_font_regular.xml new file mode 100644 index 0000000000..84df945981 --- /dev/null +++ b/app/src/main/res/drawable/ic_font_regular.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_font_script.xml b/app/src/main/res/drawable/ic_font_script.xml new file mode 100644 index 0000000000..1944f4f41c --- /dev/null +++ b/app/src/main/res/drawable/ic_font_script.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_font_serif.xml b/app/src/main/res/drawable/ic_font_serif.xml new file mode 100644 index 0000000000..c501be2817 --- /dev/null +++ b/app/src/main/res/drawable/ic_font_serif.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_group_outline_24.xml b/app/src/main/res/drawable/ic_group_outline_24.xml new file mode 100644 index 0000000000..1763496ffd --- /dev/null +++ b/app/src/main/res/drawable/ic_group_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_link_24.xml b/app/src/main/res/drawable/ic_link_24.xml new file mode 100644 index 0000000000..656e8f675f --- /dev/null +++ b/app/src/main/res/drawable/ic_link_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_open_24_tinted.xml b/app/src/main/res/drawable/ic_open_24_tinted.xml new file mode 100644 index 0000000000..861378bd11 --- /dev/null +++ b/app/src/main/res/drawable/ic_open_24_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_reply_24_solid_tinted.xml b/app/src/main/res/drawable/ic_reply_24_solid_tinted.xml new file mode 100644 index 0000000000..cdd7b1a31c --- /dev/null +++ b/app/src/main/res/drawable/ic_reply_24_solid_tinted.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_text_center.xml b/app/src/main/res/drawable/ic_text_center.xml new file mode 100644 index 0000000000..e9f58bf883 --- /dev/null +++ b/app/src/main/res/drawable/ic_text_center.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_text_effect.xml b/app/src/main/res/drawable/ic_text_effect.xml new file mode 100644 index 0000000000..4a2aca471a --- /dev/null +++ b/app/src/main/res/drawable/ic_text_effect.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_text_end.xml b/app/src/main/res/drawable/ic_text_end.xml new file mode 100644 index 0000000000..aa368c2013 --- /dev/null +++ b/app/src/main/res/drawable/ic_text_end.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_text_normal.xml b/app/src/main/res/drawable/ic_text_normal.xml new file mode 100644 index 0000000000..ebb7b993f0 --- /dev/null +++ b/app/src/main/res/drawable/ic_text_normal.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_text_start.xml b/app/src/main/res/drawable/ic_text_start.xml new file mode 100644 index 0000000000..a875922fd0 --- /dev/null +++ b/app/src/main/res/drawable/ic_text_start.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_trash_24_solid_tinted.xml b/app/src/main/res/drawable/ic_trash_24_solid_tinted.xml new file mode 100644 index 0000000000..aedd6dec6f --- /dev/null +++ b/app/src/main/res/drawable/ic_trash_24_solid_tinted.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/rounded_rectangle_38.xml b/app/src/main/res/drawable/rounded_rectangle_38.xml new file mode 100644 index 0000000000..13c3127dcd --- /dev/null +++ b/app/src/main/res/drawable/rounded_rectangle_38.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_rectangle_secondary_18.xml b/app/src/main/res/drawable/rounded_rectangle_secondary_18.xml new file mode 100644 index 0000000000..5cb8959e01 --- /dev/null +++ b/app/src/main/res/drawable/rounded_rectangle_secondary_18.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selectable_list_item_background.xml b/app/src/main/res/drawable/selectable_list_item_background.xml new file mode 100644 index 0000000000..2b88d4b9cb --- /dev/null +++ b/app/src/main/res/drawable/selectable_list_item_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/stories_bullet.xml b/app/src/main/res/drawable/stories_bullet.xml new file mode 100644 index 0000000000..ead4190a52 --- /dev/null +++ b/app/src/main/res/drawable/stories_bullet.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/story_gradient_bottom.xml b/app/src/main/res/drawable/story_gradient_bottom.xml new file mode 100644 index 0000000000..5ca58cdd44 --- /dev/null +++ b/app/src/main/res/drawable/story_gradient_bottom.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/story_gradient_top.xml b/app/src/main/res/drawable/story_gradient_top.xml new file mode 100644 index 0000000000..8227b82159 --- /dev/null +++ b/app/src/main/res/drawable/story_gradient_top.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/story_pill_button_background.xml b/app/src/main/res/drawable/story_pill_button_background.xml new file mode 100644 index 0000000000..b793797d8e --- /dev/null +++ b/app/src/main/res/drawable/story_pill_button_background.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/story_pill_toggle_background.xml b/app/src/main/res/drawable/story_pill_toggle_background.xml new file mode 100644 index 0000000000..9b17d068f8 --- /dev/null +++ b/app/src/main/res/drawable/story_pill_toggle_background.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/story_text_background_button_overlay.xml b/app/src/main/res/drawable/story_text_background_button_overlay.xml new file mode 100644 index 0000000000..0f3961f7a8 --- /dev/null +++ b/app/src/main/res/drawable/story_text_background_button_overlay.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/story_text_circle_button_background_inset_5.xml b/app/src/main/res/drawable/story_text_circle_button_background_inset_5.xml new file mode 100644 index 0000000000..ba1c415997 --- /dev/null +++ b/app/src/main/res/drawable/story_text_circle_button_background_inset_5.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/story_text_circle_button_background_inset_6.xml b/app/src/main/res/drawable/story_text_circle_button_background_inset_6.xml new file mode 100644 index 0000000000..d741313b43 --- /dev/null +++ b/app/src/main/res/drawable/story_text_circle_button_background_inset_6.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tab_unread_circle.xml b/app/src/main/res/drawable/tab_unread_circle.xml new file mode 100644 index 0000000000..cf967f0710 --- /dev/null +++ b/app/src/main/res/drawable/tab_unread_circle.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/avatar_view.xml b/app/src/main/res/layout/avatar_view.xml new file mode 100644 index 0000000000..f59a2857ec --- /dev/null +++ b/app/src/main/res/layout/avatar_view.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/bottom_sheet_container.xml b/app/src/main/res/layout/bottom_sheet_container.xml new file mode 100644 index 0000000000..d89cb6e412 --- /dev/null +++ b/app/src/main/res/layout/bottom_sheet_container.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/app/src/main/res/layout/contact_search_item.xml b/app/src/main/res/layout/contact_search_item.xml new file mode 100644 index 0000000000..92ac4da9b3 --- /dev/null +++ b/app/src/main/res/layout/contact_search_item.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/contact_search_section_header.xml b/app/src/main/res/layout/contact_search_section_header.xml new file mode 100644 index 0000000000..8a060dc6df --- /dev/null +++ b/app/src/main/res/layout/contact_search_section_header.xml @@ -0,0 +1,40 @@ + + + + + + + diff --git a/app/src/main/res/layout/contact_selection_list_fragment.xml b/app/src/main/res/layout/contact_selection_list_fragment.xml index 4756a8965a..a48c9182d2 100644 --- a/app/src/main/res/layout/contact_selection_list_fragment.xml +++ b/app/src/main/res/layout/contact_selection_list_fragment.xml @@ -115,12 +115,12 @@ android:id="@+id/chipGroupScrollContainer" android:layout_width="match_parent" android:layout_height="56dp" + android:clipChildren="false" + android:clipToPadding="false" android:paddingStart="@dimen/dsl_settings_gutter" android:paddingEnd="@dimen/dsl_settings_gutter" android:scrollbars="none" android:visibility="gone" - android:clipChildren="false" - android:clipToPadding="false" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.5" app:layout_constraintStart_toStartOf="parent" @@ -161,4 +161,28 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@id/swipe_refresh" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/contacts_expand_item.xml b/app/src/main/res/layout/contacts_expand_item.xml new file mode 100644 index 0000000000..82bd3e0ab2 --- /dev/null +++ b/app/src/main/res/layout/contacts_expand_item.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_list_tabs.xml b/app/src/main/res/layout/conversation_list_tabs.xml new file mode 100644 index 0000000000..17c8ca4b20 --- /dev/null +++ b/app/src/main/res/layout/conversation_list_tabs.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_avatar_preference_item.xml b/app/src/main/res/layout/conversation_settings_avatar_preference_item.xml index da07eea335..0e41e0fb7c 100644 --- a/app/src/main/res/layout/conversation_settings_avatar_preference_item.xml +++ b/app/src/main/res/layout/conversation_settings_avatar_preference_item.xml @@ -10,7 +10,7 @@ android:layout_height="wrap_content" android:layout_gravity="center_horizontal"> - - + \ No newline at end of file diff --git a/app/src/main/res/layout/dsl_settings_bottom_sheet_no_handle.xml b/app/src/main/res/layout/dsl_settings_bottom_sheet_no_handle.xml new file mode 100644 index 0000000000..c10847331e --- /dev/null +++ b/app/src/main/res/layout/dsl_settings_bottom_sheet_no_handle.xml @@ -0,0 +1,15 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/emoji_search_fragment.xml b/app/src/main/res/layout/emoji_search_fragment.xml index a0e2807f71..65d5ba079c 100644 --- a/app/src/main/res/layout/emoji_search_fragment.xml +++ b/app/src/main/res/layout/emoji_search_fragment.xml @@ -10,7 +10,7 @@ + android:background="?mediaKeyboardBackgroundColor"> + + + + + + diff --git a/app/src/main/res/layout/keyboard_pager_emoji_page_fragment.xml b/app/src/main/res/layout/keyboard_pager_emoji_page_fragment.xml index fad2bd9edc..0aaba9926c 100644 --- a/app/src/main/res/layout/keyboard_pager_emoji_page_fragment.xml +++ b/app/src/main/res/layout/keyboard_pager_emoji_page_fragment.xml @@ -4,13 +4,13 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/signal_background_secondary"> + android:background="?mediaKeyboardBackgroundColor"> @@ -45,7 +45,7 @@ android:layout_width="match_parent" android:layout_height="@dimen/keyboard_toolbar_height" android:layout_gravity="bottom" - android:background="@color/signal_background_secondary" + android:background="?mediaKeyboardBackgroundColor" android:gravity="center_vertical" android:orientation="horizontal" app:layout_behavior="@string/hide_bottom_view_on_scroll_behavior"> diff --git a/app/src/main/res/layout/keyboard_pager_fragment.xml b/app/src/main/res/layout/keyboard_pager_fragment.xml index d0dd4062fb..f1c20e99be 100644 --- a/app/src/main/res/layout/keyboard_pager_fragment.xml +++ b/app/src/main/res/layout/keyboard_pager_fragment.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/signal_background_secondary" + android:background="?mediaKeyboardBackgroundColor" tools:layout_gravity="bottom" tools:maxHeight="339dp"> diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml index 43d7aa44d1..1700792591 100644 --- a/app/src/main/res/layout/main_activity.xml +++ b/app/src/main/res/layout/main_activity.xml @@ -1,10 +1,21 @@ - + android:orientation="vertical"> - \ No newline at end of file + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/media_selection_activity.xml b/app/src/main/res/layout/media_selection_activity.xml new file mode 100644 index 0000000000..227efea158 --- /dev/null +++ b/app/src/main/res/layout/media_selection_activity.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/multiselect_bottom_sheet.xml b/app/src/main/res/layout/multiselect_bottom_sheet.xml new file mode 100644 index 0000000000..b77fea149f --- /dev/null +++ b/app/src/main/res/layout/multiselect_bottom_sheet.xml @@ -0,0 +1,30 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/multiselect_forward_fragment.xml b/app/src/main/res/layout/multiselect_forward_fragment.xml index a285313e78..0ae40e491d 100644 --- a/app/src/main/res/layout/multiselect_forward_fragment.xml +++ b/app/src/main/res/layout/multiselect_forward_fragment.xml @@ -5,23 +5,6 @@ android:layout_height="wrap_content" android:orientation="vertical"> - - - - - + android:layout_weight="1" + android:paddingBottom="44dp" + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" /> \ No newline at end of file diff --git a/app/src/main/res/layout/multiselect_forward_fragment_bottom_bar.xml b/app/src/main/res/layout/multiselect_forward_fragment_bottom_bar.xml index 4558c67d3a..c45616ad9c 100644 --- a/app/src/main/res/layout/multiselect_forward_fragment_bottom_bar.xml +++ b/app/src/main/res/layout/multiselect_forward_fragment_bottom_bar.xml @@ -6,6 +6,16 @@ android:layout_height="wrap_content" android:layout_gravity="bottom"> + + - - - - + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_choose_group_bottom_bar.xml b/app/src/main/res/layout/stories_choose_group_bottom_bar.xml new file mode 100644 index 0000000000..82d1e6f7e1 --- /dev/null +++ b/app/src/main/res/layout/stories_choose_group_bottom_bar.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_choose_group_bottom_sheet.xml b/app/src/main/res/layout/stories_choose_group_bottom_sheet.xml new file mode 100644 index 0000000000..08ddc2821e --- /dev/null +++ b/app/src/main/res/layout/stories_choose_group_bottom_sheet.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_create_story_name_field_item.xml b/app/src/main/res/layout/stories_create_story_name_field_item.xml new file mode 100644 index 0000000000..1e146343cd --- /dev/null +++ b/app/src/main/res/layout/stories_create_story_name_field_item.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_create_with_recipients_fragment.xml b/app/src/main/res/layout/stories_create_with_recipients_fragment.xml new file mode 100644 index 0000000000..3e111fc6b6 --- /dev/null +++ b/app/src/main/res/layout/stories_create_with_recipients_fragment.xml @@ -0,0 +1,34 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_edit_story_name_fragment.xml b/app/src/main/res/layout/stories_edit_story_name_fragment.xml new file mode 100644 index 0000000000..475dd5c1de --- /dev/null +++ b/app/src/main/res/layout/stories_edit_story_name_fragment.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_group_reaction_reply_item.xml b/app/src/main/res/layout/stories_group_reaction_reply_item.xml new file mode 100644 index 0000000000..a6ae50807d --- /dev/null +++ b/app/src/main/res/layout/stories_group_reaction_reply_item.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_group_replies_fragment.xml b/app/src/main/res/layout/stories_group_replies_fragment.xml new file mode 100644 index 0000000000..d52f9186ed --- /dev/null +++ b/app/src/main/res/layout/stories_group_replies_fragment.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_group_text_reply_item.xml b/app/src/main/res/layout/stories_group_text_reply_item.xml new file mode 100644 index 0000000000..6cdb5c7226 --- /dev/null +++ b/app/src/main/res/layout/stories_group_text_reply_item.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_landing_fragment.xml b/app/src/main/res/layout/stories_landing_fragment.xml new file mode 100644 index 0000000000..c3d746a213 --- /dev/null +++ b/app/src/main/res/layout/stories_landing_fragment.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_landing_item.xml b/app/src/main/res/layout/stories_landing_item.xml new file mode 100644 index 0000000000..40c7d92c18 --- /dev/null +++ b/app/src/main/res/layout/stories_landing_item.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_landing_item_my_stories.xml b/app/src/main/res/layout/stories_landing_item_my_stories.xml new file mode 100644 index 0000000000..8a461df28a --- /dev/null +++ b/app/src/main/res/layout/stories_landing_item_my_stories.xml @@ -0,0 +1,49 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_my_stories_item.xml b/app/src/main/res/layout/stories_my_stories_item.xml new file mode 100644 index 0000000000..c8ec493c88 --- /dev/null +++ b/app/src/main/res/layout/stories_my_stories_item.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_private_story_add_viewer_item.xml b/app/src/main/res/layout/stories_private_story_add_viewer_item.xml new file mode 100644 index 0000000000..5643f183e1 --- /dev/null +++ b/app/src/main/res/layout/stories_private_story_add_viewer_item.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_private_story_item.xml b/app/src/main/res/layout/stories_private_story_item.xml new file mode 100644 index 0000000000..a25a2129a7 --- /dev/null +++ b/app/src/main/res/layout/stories_private_story_item.xml @@ -0,0 +1,34 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_private_story_new_item.xml b/app/src/main/res/layout/stories_private_story_new_item.xml new file mode 100644 index 0000000000..74cb564297 --- /dev/null +++ b/app/src/main/res/layout/stories_private_story_new_item.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_private_story_recipient_item.xml b/app/src/main/res/layout/stories_private_story_recipient_item.xml new file mode 100644 index 0000000000..61f02c62ea --- /dev/null +++ b/app/src/main/res/layout/stories_private_story_recipient_item.xml @@ -0,0 +1,32 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_reaction_bar.xml b/app/src/main/res/layout/stories_reaction_bar.xml new file mode 100644 index 0000000000..95cd20a4a2 --- /dev/null +++ b/app/src/main/res/layout/stories_reaction_bar.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_recipient_item.xml b/app/src/main/res/layout/stories_recipient_item.xml new file mode 100644 index 0000000000..aff09017aa --- /dev/null +++ b/app/src/main/res/layout/stories_recipient_item.xml @@ -0,0 +1,42 @@ + + + + + + + + diff --git a/app/src/main/res/layout/stories_reply_to_story_composer.xml b/app/src/main/res/layout/stories_reply_to_story_composer.xml new file mode 100644 index 0000000000..be70da00e9 --- /dev/null +++ b/app/src/main/res/layout/stories_reply_to_story_composer.xml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/stories_reply_to_story_composer_content.xml b/app/src/main/res/layout/stories_reply_to_story_composer_content.xml new file mode 100644 index 0000000000..c28ebe6efe --- /dev/null +++ b/app/src/main/res/layout/stories_reply_to_story_composer_content.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/stories_reply_to_story_fragment.xml b/app/src/main/res/layout/stories_reply_to_story_fragment.xml new file mode 100644 index 0000000000..40c8472d78 --- /dev/null +++ b/app/src/main/res/layout/stories_reply_to_story_fragment.xml @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_send_text_post_fragment.xml b/app/src/main/res/layout/stories_send_text_post_fragment.xml new file mode 100644 index 0000000000..4958e361bd --- /dev/null +++ b/app/src/main/res/layout/stories_send_text_post_fragment.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/stories_signal_connection_bottom_sheet.xml b/app/src/main/res/layout/stories_signal_connection_bottom_sheet.xml new file mode 100644 index 0000000000..bbba6cf342 --- /dev/null +++ b/app/src/main/res/layout/stories_signal_connection_bottom_sheet.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_story_view_item.xml b/app/src/main/res/layout/stories_story_view_item.xml new file mode 100644 index 0000000000..212cd91c5c --- /dev/null +++ b/app/src/main/res/layout/stories_story_view_item.xml @@ -0,0 +1,46 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_text_post_creation_fragment.xml b/app/src/main/res/layout/stories_text_post_creation_fragment.xml new file mode 100644 index 0000000000..690856af33 --- /dev/null +++ b/app/src/main/res/layout/stories_text_post_creation_fragment.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_text_post_link_entry_content.xml b/app/src/main/res/layout/stories_text_post_link_entry_content.xml new file mode 100644 index 0000000000..6b23c2a443 --- /dev/null +++ b/app/src/main/res/layout/stories_text_post_link_entry_content.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_text_post_link_entry_fragment.xml b/app/src/main/res/layout/stories_text_post_link_entry_fragment.xml new file mode 100644 index 0000000000..c9ebcaedc3 --- /dev/null +++ b/app/src/main/res/layout/stories_text_post_link_entry_fragment.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_text_post_link_preview.xml b/app/src/main/res/layout/stories_text_post_link_preview.xml new file mode 100644 index 0000000000..f7439e7de2 --- /dev/null +++ b/app/src/main/res/layout/stories_text_post_link_preview.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_text_post_text_entry_content.xml b/app/src/main/res/layout/stories_text_post_text_entry_content.xml new file mode 100644 index 0000000000..fe847a4259 --- /dev/null +++ b/app/src/main/res/layout/stories_text_post_text_entry_content.xml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_text_post_text_entry_fragment.xml b/app/src/main/res/layout/stories_text_post_text_entry_fragment.xml new file mode 100644 index 0000000000..7407192f1a --- /dev/null +++ b/app/src/main/res/layout/stories_text_post_text_entry_fragment.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_text_post_view.xml b/app/src/main/res/layout/stories_text_post_view.xml new file mode 100644 index 0000000000..64d2c23a5b --- /dev/null +++ b/app/src/main/res/layout/stories_text_post_view.xml @@ -0,0 +1,46 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_viewer_fragment.xml b/app/src/main/res/layout/stories_viewer_fragment.xml new file mode 100644 index 0000000000..113563cd52 --- /dev/null +++ b/app/src/main/res/layout/stories_viewer_fragment.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_viewer_fragment_page.xml b/app/src/main/res/layout/stories_viewer_fragment_page.xml new file mode 100644 index 0000000000..af1163e8c2 --- /dev/null +++ b/app/src/main/res/layout/stories_viewer_fragment_page.xml @@ -0,0 +1,240 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_views_and_replies_fragment.xml b/app/src/main/res/layout/stories_views_and_replies_fragment.xml new file mode 100644 index 0000000000..a834608b0b --- /dev/null +++ b/app/src/main/res/layout/stories_views_and_replies_fragment.xml @@ -0,0 +1,35 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_views_fragment.xml b/app/src/main/res/layout/stories_views_fragment.xml new file mode 100644 index 0000000000..d7b8190f5b --- /dev/null +++ b/app/src/main/res/layout/stories_views_fragment.xml @@ -0,0 +1,32 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/test_fragment.xml b/app/src/main/res/layout/test_fragment.xml new file mode 100644 index 0000000000..fd962451a7 --- /dev/null +++ b/app/src/main/res/layout/test_fragment.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/story_landing_menu.xml b/app/src/main/res/menu/story_landing_menu.xml new file mode 100644 index 0000000000..11555c7b8c --- /dev/null +++ b/app/src/main/res/menu/story_landing_menu.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/story_private_menu.xml b/app/src/main/res/menu/story_private_menu.xml new file mode 100644 index 0000000000..c839374f88 --- /dev/null +++ b/app/src/main/res/menu/story_private_menu.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index 65deaf375c..3ea25ede9c 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -322,7 +322,34 @@ app:exitAnim="@anim/fragment_open_exit" app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/media.xml b/app/src/main/res/navigation/media.xml index 5c5ded28ba..e4ba719fc2 100644 --- a/app/src/main/res/navigation/media.xml +++ b/app/src/main/res/navigation/media.xml @@ -13,6 +13,9 @@ + + + + + + + + + + + diff --git a/app/src/main/res/navigation/my_story_settings.xml b/app/src/main/res/navigation/my_story_settings.xml new file mode 100644 index 0000000000..b2c80a8e2b --- /dev/null +++ b/app/src/main/res/navigation/my_story_settings.xml @@ -0,0 +1,27 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/new_story.xml b/app/src/main/res/navigation/new_story.xml new file mode 100644 index 0000000000..b25cae2e8a --- /dev/null +++ b/app/src/main/res/navigation/new_story.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/private_story_settings.xml b/app/src/main/res/navigation/private_story_settings.xml new file mode 100644 index 0000000000..e48b24b694 --- /dev/null +++ b/app/src/main/res/navigation/private_story_settings.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/story_settings.xml b/app/src/main/res/navigation/story_settings.xml new file mode 100644 index 0000000000..c30811989e --- /dev/null +++ b/app/src/main/res/navigation/story_settings.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/dark_colors.xml b/app/src/main/res/values-night/dark_colors.xml index 69e4ce63c2..d4865b8584 100644 --- a/app/src/main/res/values-night/dark_colors.xml +++ b/app/src/main/res/values-night/dark_colors.xml @@ -111,6 +111,7 @@ @color/debuglog_dark_error @color/core_grey_60 + @color/core_grey_60 @color/black diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index b309c46fca..d9642cf03a 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -15,10 +15,26 @@ + + + + + + + + + + + + + + + + @@ -151,6 +167,7 @@ + @@ -221,6 +238,7 @@ + @@ -245,6 +263,8 @@ + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index ae78249641..7b84cf410e 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -42,6 +42,8 @@ @color/conversation_crimson @color/core_ultramarine + #FFCE4A40 + #99ffffff #00FFFFFF #00000000 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 3ae7f9a9cf..21f83f640d 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -88,6 +88,8 @@ 4dp 18dp 60dp + 40dp + 64dp 4dp @@ -210,6 +212,9 @@ 16dp 18dp - 48dp + + 8dp + 0dp + 0dp diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index c49ce93012..e82b1b17bc 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -17,4 +17,6 @@ + + diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml index a607429b33..d097422f3d 100644 --- a/app/src/main/res/values/integers.xml +++ b/app/src/main/res/values/integers.xml @@ -4,4 +4,7 @@ 100 150 10 + + 4 + 5000 \ No newline at end of file diff --git a/app/src/main/res/values/light_colors.xml b/app/src/main/res/values/light_colors.xml index c3edb0e4fb..a20e5a20dc 100644 --- a/app/src/main/res/values/light_colors.xml +++ b/app/src/main/res/values/light_colors.xml @@ -111,6 +111,7 @@ @color/debuglog_light_error @color/core_grey_20 + @color/core_grey_20 @color/core_ultramarine diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 93e61b927a..debc9d4800 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -196,6 +196,10 @@ Groups Phone number search Username search + + My Stories + + New Story Message %s @@ -1444,6 +1448,8 @@ You + + My Story Block @@ -1998,6 +2004,11 @@ %1$d member %1$d members + + + %1$d viewer + %1$d viewers + Signal message @@ -2063,6 +2074,8 @@ Sticker You Original message not found + + %1$s Story Scroll to the bottom @@ -2665,6 +2678,8 @@ Release channel Fetch release channel Set last version seen back 10 versions + Disable stories + All activity @@ -4357,6 +4372,227 @@ %1$s - %2$s + + + + Chats + + Stories + + 99+ + + My Stories + + Add a story + + No recent updates to show right now. + + Hide story + + Unhide story + + Forward + + Share… + + Go to chat + + Hide story? + + New story updates from %1$s won\'t appear at the top of the stories list anymore. + + Hide + + Story hidden + + Hidden stories + + + %1$d view + %1$d views + + + Forward + + %1$s\'s Story + + Delete story? + + This story will be deleted for you and everyone who received it. + + + %1$d view + %1$d views + + + + %1$d reply + %1$d replies + + + %1$s %2$s + + You + + %1$s to %2$s + + Reply + + No views yet + + No replies yet + + Reacted to the story + + Views + + Replies + + React to this story + + Replying privately to %1$s + + Private Reply + + Copy + + Delete + + Story settings + + Private stories + + New private story + + My Story + + Who can see this story + + Hide story from + + + %1$d person + %1$d people + + + Replies & reactions + + Allow replies & reactions + + Let people who can view your story react and reply + + Hide your story from specific people. By default, your story is shared with your %1$s + + Signal connections. + + Signal Connections are people you\'ve chosen to trust, either by: + + Starting a conversation + + Accepting a message request + + Having them in your system contacts + + Your connections can see your name and photo, and can see posts to "My Story" unless you hide it from them. + + Add viewer + + Delete private story + + Remove %1$s? + + This person will no longer see your story. + + Remove + + Are you sure? + + This action cannot be undone. + + Edit story name + + Story name + + Save + + Tap to add text + + Aa + + Add text + + Done adding text + + Text + + Camera + + Type or paste a URL + + Share a link with viewers of your story + + Search + + Hide story from… + + Done + + Add to story? + + Adding content to your story allows your Signal connections to view it for 24 hours. You can change who can view your story in Setttings. + + Add to story + + Edit viewers + + Share & View Stories + + You will no longer be able to share or view Stories when this option is turned off. + + Choose viewers + + Next + + + %1$d viewer + %1$d viewers + + + Name story + + Story name (required) + + Viewers + + Create + + This field is required. + + There is already a story with this name. + + Select all + + Choose your story type + + New private story + + Visible only to specific people + + Group story + + Share to an existing group + + Choose groups + + Copied to clipboard + + … See More + + Reply sent + + + + Show more diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 25d06f6756..537049bb87 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -10,6 +10,7 @@ true true false + @drawable/contact_selection_checkbox + + + + + + + + + + +