From 9bc25132c341f5cde11ef0a4d581df59ecee2b15 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Fri, 24 Jun 2022 10:51:26 -0400 Subject: [PATCH] Add new My Story privacy settings. --- .../securesms/database/SQLiteDatabaseTest.kt | 58 +++++ .../ContactSelectionListFragment.java | 4 +- .../components/settings/DSLSettingsAdapter.kt | 10 + .../securesms/components/settings/dsl.kt | 15 ++ .../contacts/ContactSelectionListAdapter.java | 19 +- .../selection/ContactSelectionArguments.kt | 6 +- .../forward/MultiselectForwardFragment.kt | 2 +- .../database/DistributionListDatabase.kt | 223 ++++++++++++------ .../securesms/database/RecipientDatabase.kt | 19 +- .../securesms/database/SQLiteDatabase.java | 46 +++- .../securesms/database/SignalDatabase.kt | 1 + .../helpers/SignalDatabaseMigrations.kt | 38 ++- .../database/model/DistributionListId.java | 4 + .../model/DistributionListPartialRecord.kt | 3 +- .../model/DistributionListPrivacyData.kt | 10 + .../model/DistributionListPrivacyMode.kt | 35 +++ .../database/model/DistributionListRecord.kt | 13 +- .../securesms/jobs/JobManagerFactories.java | 2 + .../keyvalue/SignalStoreValueDelegates.kt | 19 ++ .../securesms/keyvalue/SignalStoreValues.java | 10 +- .../securesms/keyvalue/StoryValues.kt | 3 +- .../v2/text/TextStoryPostCreationFragment.kt | 2 +- .../v2/text/send/TextStoryPostSendFragment.kt | 2 +- .../migrations/ApplicationMigrations.java | 7 +- .../SyncDistributionListsMigrationJob.java | 52 ++++ .../securesms/recipients/RecipientId.java | 14 ++ .../securesms/storage/StorageSyncModels.java | 9 +- .../StoryDistributionListRecordProcessor.java | 22 +- .../securesms/stories/dialogs/StoryDialogs.kt | 3 +- .../custom/PrivateStorySettingsRepository.kt | 6 +- .../custom/PrivateStorySettingsViewModel.kt | 2 +- .../settings/hide/HideStoryFromFragment.kt | 20 -- .../settings/my/MyStoryPrivacyState.kt | 5 + .../settings/my/MyStorySettingsFragment.kt | 78 ++++-- .../settings/my/MyStorySettingsRepository.kt | 18 +- .../settings/my/MyStorySettingsState.kt | 2 +- .../settings/my/MyStorySettingsViewModel.kt | 19 +- ...nalConnectionsBottomSheetDialogFragment.kt | 6 +- .../ChangeMyStoryMembershipFragment.kt | 30 +++ .../HideStoryFromDialogFragment.kt | 8 +- .../BaseStoryRecipientSelectionFragment.kt | 10 +- .../BaseStoryRecipientSelectionRepository.kt | 24 +- .../BaseStoryRecipientSelectionState.kt | 11 + .../BaseStoryRecipientSelectionViewModel.kt | 22 +- .../util/views/LearnMoreTextView.java | 3 +- .../contact_selection_exclude_checkbox.xml | 29 +++ .../res/drawable/ic_x_error_inverse_16.xml | 9 + .../layout/dsl_learn_more_preference_item.xml | 72 ++++++ .../res/layout/dsl_radio_preference_item.xml | 36 +-- .../main/res/navigation/my_story_settings.xml | 15 +- .../res/values-night/material3_colors.xml | 4 + app/src/main/res/values/material3_colors.xml | 4 + app/src/main/res/values/strings.xml | 33 ++- .../org/signal/core/util/CursorExtensions.kt | 15 +- .../core/util/SQLiteDatabaseExtensions.kt | 18 +- .../java/org/signal/core/util/Serializer.kt | 13 + .../SignalStoryDistributionListRecord.java | 13 + .../src/main/proto/StorageService.proto | 1 + 58 files changed, 935 insertions(+), 242 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPrivacyData.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPrivacyMode.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/migrations/SyncDistributionListsMigrationJob.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/settings/hide/HideStoryFromFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStoryPrivacyState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/settings/privacy/ChangeMyStoryMembershipFragment.kt rename app/src/main/java/org/thoughtcrime/securesms/stories/settings/{hide => privacy}/HideStoryFromDialogFragment.kt (75%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionState.kt create mode 100644 app/src/main/res/drawable/contact_selection_exclude_checkbox.xml create mode 100644 app/src/main/res/drawable/ic_x_error_inverse_16.xml create mode 100644 app/src/main/res/layout/dsl_learn_more_preference_item.xml create mode 100644 core-util/src/main/java/org/signal/core/util/Serializer.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/SQLiteDatabaseTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/SQLiteDatabaseTest.kt index b3f28e970..c217c9b63 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/SQLiteDatabaseTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/SQLiteDatabaseTest.kt @@ -122,4 +122,62 @@ class SQLiteDatabaseTest { assertTrue(hasRun1.get()) assertFalse(hasRun2.get()) } + + @Test + fun runPostSuccessfulTransaction_runsAndPerformsAnotherTransaction() { + val hasRun = AtomicBoolean(false) + + db.beginTransaction() + + db.runPostSuccessfulTransaction { + try { + db.beginTransaction() + hasRun.set(true) + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + assertFalse(hasRun.get()) + + db.setTransactionSuccessful() + db.endTransaction() + + assertTrue(hasRun.get()) + } + + @Test + fun runPostSuccessfulTransaction_runsAndPerformsAnotherTransactionAndRunPostNested() { + val hasRun1 = AtomicBoolean(false) + val hasRun2 = AtomicBoolean(false) + + db.beginTransaction() + + db.runPostSuccessfulTransaction { + db.beginTransaction() + + db.runPostSuccessfulTransaction { + assertTrue(hasRun1.get()) + assertFalse(hasRun2.get()) + hasRun2.set(true) + } + + assertFalse(hasRun1.get()) + hasRun1.set(true) + assertFalse(hasRun2.get()) + + db.setTransactionSuccessful() + db.endTransaction() + } + + assertFalse(hasRun1.get()) + assertFalse(hasRun2.get()) + + db.setTransactionSuccessful() + db.endTransaction() + + assertTrue(hasRun1.get()) + assertTrue(hasRun2.get()) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index 7af251a28..b43f2dfb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -70,6 +70,7 @@ 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.selection.ContactSelectionArguments; import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery; import org.thoughtcrime.securesms.groups.SelectionLimits; import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog; @@ -385,7 +386,8 @@ public final class ContactSelectionListFragment extends LoggingFragment null, new ListClickListener(), isMulti, - currentSelection); + currentSelection, + safeArguments().getInt(ContactSelectionArguments.CHECKBOX_RESOURCE, R.drawable.contact_selection_checkbox)); RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt index d075ad256..04221bbff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt @@ -22,6 +22,7 @@ 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.views.LearnMoreTextView import org.thoughtcrime.securesms.util.visible class DSLSettingsAdapter : MappingAdapter() { @@ -29,6 +30,7 @@ class DSLSettingsAdapter : MappingAdapter() { registerFactory(ClickPreference::class.java, LayoutFactory(::ClickPreferenceViewHolder, R.layout.dsl_preference_item)) registerFactory(LongClickPreference::class.java, LayoutFactory(::LongClickPreferenceViewHolder, R.layout.dsl_preference_item)) registerFactory(TextPreference::class.java, LayoutFactory(::TextPreferenceViewHolder, R.layout.dsl_preference_item)) + registerFactory(LearnMoreTextPreference::class.java, LayoutFactory(::LearnMoreTextPreferenceViewHolder, R.layout.dsl_learn_more_preference_item)) registerFactory(RadioListPreference::class.java, LayoutFactory(::RadioListPreferenceViewHolder, R.layout.dsl_preference_item)) registerFactory(MultiSelectListPreference::class.java, LayoutFactory(::MultiSelectListPreferenceViewHolder, R.layout.dsl_preference_item)) registerFactory(ExternalLinkPreference::class.java, LayoutFactory(::ExternalLinkPreferenceViewHolder, R.layout.dsl_preference_item)) @@ -91,6 +93,14 @@ abstract class PreferenceViewHolder>(itemView: View) : Ma class TextPreferenceViewHolder(itemView: View) : PreferenceViewHolder(itemView) +class LearnMoreTextPreferenceViewHolder(itemView: View) : PreferenceViewHolder(itemView) { + override fun bind(model: LearnMoreTextPreference) { + super.bind(model) + (titleView as LearnMoreTextView).setOnLinkClickListener { model.onClick() } + (summaryView as LearnMoreTextView).setOnLinkClickListener { model.onClick() } + } +} + class ClickPreferenceViewHolder(itemView: View) : PreferenceViewHolder(itemView) { override fun bind(model: ClickPreference) { super.bind(model) 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 34af584e4..5440c26a6 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 @@ -185,6 +185,15 @@ class DSLConfiguration { children.add(preference) } + fun learnMoreTextPref( + title: DSLSettingsText? = null, + summary: DSLSettingsText? = null, + onClick: () -> Unit + ) { + val preference = LearnMoreTextPreference(title, summary, onClick) + children.add(preference) + } + fun toMappingModelList(): MappingModelList = MappingModelList().apply { addAll(children) } } @@ -218,6 +227,12 @@ class TextPreference( summary: DSLSettingsText? ) : PreferenceModel(title = title, summary = summary) +class LearnMoreTextPreference( + override val title: DSLSettingsText?, + override val summary: DSLSettingsText?, + val onClick: () -> Unit +) : PreferenceModel() + class DividerPreference : PreferenceModel() { override fun areItemsTheSame(newItem: DividerPreference) = true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java index dee381b72..ed3223052 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java @@ -73,6 +73,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter currentContacts; + private final int checkboxResource; private final SelectedContactSet selectedContacts = new SelectedContactSet(); @@ -205,14 +206,16 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter currentContacts) + @NonNull Set currentContacts, + int checkboxResource) { super(context, cursor); - this.layoutInflater = LayoutInflater.from(context); - this.glideRequests = glideRequests; - this.multiSelect = multiSelect; - this.clickListener = clickListener; - this.currentContacts = currentContacts; + this.layoutInflater = LayoutInflater.from(context); + this.glideRequests = glideRequests; + this.multiSelect = multiSelect; + this.clickListener = clickListener; + this.currentContacts = currentContacts; + this.checkboxResource = checkboxResource; } @Override @@ -229,7 +232,9 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter = arrayOf(ListTable.CREATE_TABLE, MembershipTable.CREATE_TABLE) + @JvmField + val CREATE_INDEXES: Array = arrayOf(MembershipTable.CREATE_INDEX) + const val RECIPIENT_ID = ListTable.RECIPIENT_ID const val DISTRIBUTION_ID = ListTable.DISTRIBUTION_ID const val LIST_TABLE_NAME = ListTable.TABLE_NAME @@ -55,7 +64,8 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si ListTable.ID to DistributionListId.MY_STORY_ID, ListTable.NAME to DistributionId.MY_STORY.toString(), ListTable.DISTRIBUTION_ID to DistributionId.MY_STORY.toString(), - ListTable.RECIPIENT_ID to recipientId + ListTable.RECIPIENT_ID to recipientId, + ListTable.PRIVACY_MODE to DistributionListPrivacyMode.ALL.serialize() ) ) } @@ -71,8 +81,9 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si const val ALLOWS_REPLIES = "allows_replies" const val DELETION_TIMESTAMP = "deletion_timestamp" const val IS_UNKNOWN = "is_unknown" + const val PRIVACY_MODE = "privacy_mode" - const val CREATE_TABLE = """ + val CREATE_TABLE = """ CREATE TABLE $TABLE_NAME ( $ID INTEGER PRIMARY KEY AUTOINCREMENT, $NAME TEXT UNIQUE NOT NULL, @@ -80,11 +91,14 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si $RECIPIENT_ID INTEGER UNIQUE REFERENCES ${RecipientDatabase.TABLE_NAME} (${RecipientDatabase.ID}), $ALLOWS_REPLIES INTEGER DEFAULT 1, $DELETION_TIMESTAMP INTEGER DEFAULT 0, - $IS_UNKNOWN INTEGER DEFAULT 0 + $IS_UNKNOWN INTEGER DEFAULT 0, + $PRIVACY_MODE INTEGER DEFAULT ${DistributionListPrivacyMode.ONLY_WITH.serialize()} ) """ const val IS_NOT_DELETED = "$DELETION_TIMESTAMP == 0" + + val LIST_UI_PROJECTION = arrayOf(ID, NAME, RECIPIENT_ID, ALLOWS_REPLIES, IS_UNKNOWN, PRIVACY_MODE) } private object MembershipTable { @@ -93,15 +107,18 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si const val ID = "_id" const val LIST_ID = "list_id" const val RECIPIENT_ID = "recipient_id" + const val PRIVACY_MODE = "privacy_mode" 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 + $PRIVACY_MODE INTEGER DEFAULT 0 ) """ + + const val CREATE_INDEX = "CREATE UNIQUE INDEX distribution_list_member_list_id_recipient_id_privacy_mode_index ON $TABLE_NAME ($LIST_ID, $RECIPIENT_ID, $PRIVACY_MODE)" } /** @@ -119,28 +136,13 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si ) == 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), - allowsReplies = CursorUtil.requireBoolean(it, ListTable.ALLOWS_REPLIES), - recipientId = RecipientId.from(CursorUtil.requireLong(it, ListTable.RECIPIENT_ID)), - isUnknown = CursorUtil.requireBoolean(it, ListTable.IS_UNKNOWN) - ) - ) - } - - results - } ?: emptyList() + fun setPrivacyMode(distributionListId: DistributionListId, privacyMode: DistributionListPrivacyMode) { + val values = contentValuesOf(ListTable.PRIVACY_MODE to privacyMode.serialize()) + writableDatabase.update(ListTable.TABLE_NAME, values, "${ListTable.ID} = ?", SqlUtil.buildArgs(distributionListId)) } fun getAllListsForContactSelectionUiCursor(query: String?, includeMyStory: Boolean): Cursor? { val db = readableDatabase - val projection = arrayOf(ListTable.ID, ListTable.NAME, ListTable.RECIPIENT_ID, ListTable.ALLOWS_REPLIES, ListTable.IS_UNKNOWN) val where = when { query.isNullOrEmpty() && includeMyStory -> ListTable.IS_NOT_DELETED @@ -155,24 +157,32 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si else -> SqlUtil.buildArgs(SqlUtil.buildCaseInsensitiveGlobPattern(query), DistributionListId.MY_STORY_ID) } - return db.query(ListTable.TABLE_NAME, projection, where, whereArgs, null, null, null) + return db.query(ListTable.TABLE_NAME, ListTable.LIST_UI_PROJECTION, where, whereArgs, null, null, null) + } + + fun getAllListRecipients(): List { + return readableDatabase + .select(ListTable.RECIPIENT_ID) + .from(ListTable.TABLE_NAME) + .run() + .readToList { cursor -> RecipientId.from(cursor.requireLong(ListTable.RECIPIENT_ID)) } } fun getCustomListsForUi(): List { val db = readableDatabase - val projection = SqlUtil.buildArgs(ListTable.ID, ListTable.NAME, ListTable.RECIPIENT_ID, ListTable.ALLOWS_REPLIES, ListTable.IS_UNKNOWN) val selection = "${ListTable.ID} != ${DistributionListId.MY_STORY_ID} AND ${ListTable.IS_NOT_DELETED}" - return db.query(ListTable.TABLE_NAME, projection, selection, null, null, null, null)?.use { + return db.query(ListTable.TABLE_NAME, ListTable.LIST_UI_PROJECTION, selection, null, null, null, null)?.use { cursor -> val results = mutableListOf() - while (it.moveToNext()) { + while (cursor.moveToNext()) { results.add( DistributionListPartialRecord( - id = DistributionListId.from(CursorUtil.requireLong(it, ListTable.ID)), - name = CursorUtil.requireString(it, ListTable.NAME), - allowsReplies = CursorUtil.requireBoolean(it, ListTable.ALLOWS_REPLIES), - recipientId = RecipientId.from(CursorUtil.requireLong(it, ListTable.RECIPIENT_ID)), - isUnknown = CursorUtil.requireBoolean(it, ListTable.IS_UNKNOWN) + id = DistributionListId.from(CursorUtil.requireLong(cursor, ListTable.ID)), + name = CursorUtil.requireString(cursor, ListTable.NAME), + allowsReplies = CursorUtil.requireBoolean(cursor, ListTable.ALLOWS_REPLIES), + recipientId = RecipientId.from(CursorUtil.requireLong(cursor, ListTable.RECIPIENT_ID)), + isUnknown = CursorUtil.requireBoolean(cursor, ListTable.IS_UNKNOWN), + privacyMode = cursor.requireObject(ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer) ) ) } @@ -235,7 +245,8 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si allowsReplies: Boolean = true, deletionTimestamp: Long = 0L, storageId: ByteArray? = null, - isUnknown: Boolean = false + isUnknown: Boolean = false, + privacyMode: DistributionListPrivacyMode = DistributionListPrivacyMode.ONLY_WITH ): DistributionListId? { val db = writableDatabase @@ -248,6 +259,7 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si putNull(ListTable.RECIPIENT_ID) put(ListTable.DELETION_TIMESTAMP, deletionTimestamp) put(ListTable.IS_UNKNOWN, isUnknown) + put(ListTable.PRIVACY_MODE, privacyMode.serialize()) } val id = writableDatabase.insert(ListTable.TABLE_NAME, null, values) @@ -264,7 +276,7 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si SqlUtil.buildArgs(id) ) - members.forEach { addMemberToList(DistributionListId.from(id), it) } + members.forEach { addMemberToList(DistributionListId.from(id), privacyMode, it) } db.setTransactionSuccessful() @@ -311,15 +323,18 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si readableDatabase.query(ListTable.TABLE_NAME, null, "${ListTable.ID} = ? AND ${ListTable.IS_NOT_DELETED}", SqlUtil.buildArgs(listId), null, null, null).use { cursor -> return if (cursor.moveToFirst()) { val id: DistributionListId = DistributionListId.from(cursor.requireLong(ListTable.ID)) + val privacyMode: DistributionListPrivacyMode = cursor.requireObject(ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer) DistributionListRecord( id = id, name = cursor.requireNonNullString(ListTable.NAME), distributionId = DistributionId.from(cursor.requireNonNullString(ListTable.DISTRIBUTION_ID)), allowsReplies = CursorUtil.requireBoolean(cursor, ListTable.ALLOWS_REPLIES), + rawMembers = getRawMembers(id, privacyMode), members = getMembers(id), deletedAtTimestamp = 0L, - isUnknown = CursorUtil.requireBoolean(cursor, ListTable.IS_UNKNOWN) + isUnknown = CursorUtil.requireBoolean(cursor, ListTable.IS_UNKNOWN), + privacyMode = privacyMode ) } else { null @@ -331,15 +346,18 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si 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)) + val privacyMode = cursor.requireObject(ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer) DistributionListRecord( id = id, name = cursor.requireNonNullString(ListTable.NAME), distributionId = DistributionId.from(cursor.requireNonNullString(ListTable.DISTRIBUTION_ID)), allowsReplies = CursorUtil.requireBoolean(cursor, ListTable.ALLOWS_REPLIES), - members = getRawMembers(id), + rawMembers = getRawMembers(id, privacyMode), + members = emptyList(), deletedAtTimestamp = cursor.requireLong(ListTable.DELETION_TIMESTAMP), - isUnknown = CursorUtil.requireBoolean(cursor, ListTable.IS_UNKNOWN) + isUnknown = CursorUtil.requireBoolean(cursor, ListTable.IS_UNKNOWN), + privacyMode = privacyMode ) } else { null @@ -358,28 +376,36 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si } fun getMembers(listId: DistributionListId): List { - if (listId == DistributionListId.MY_STORY) { - val blockedMembers = getRawMembers(listId).toSet() + lateinit var privacyMode: DistributionListPrivacyMode + lateinit var rawMembers: List - 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) + readableDatabase.withinTransaction { + privacyMode = getPrivacyMode(listId) + rawMembers = getRawMembers(listId, privacyMode) + } + + return when (privacyMode) { + DistributionListPrivacyMode.ALL -> { + SignalDatabase.recipients + .getSignalContacts(false)!! + .readToList { it.requireObject(RecipientDatabase.ID, RecipientId.SERIALIZER) } + } + DistributionListPrivacyMode.ALL_EXCEPT -> { + SignalDatabase.recipients + .getSignalContacts(false)!! + .readToList( + predicate = { !rawMembers.contains(it) }, + mapper = { it.requireObject(RecipientDatabase.ID, RecipientId.SERIALIZER) } + ) + } + DistributionListPrivacyMode.ONLY_WITH -> rawMembers } } - fun getRawMembers(listId: DistributionListId): List { + fun getRawMembers(listId: DistributionListId, privacyMode: DistributionListPrivacyMode): List { val members = mutableListOf() - readableDatabase.query(MembershipTable.TABLE_NAME, null, "${MembershipTable.LIST_ID} = ?", SqlUtil.buildArgs(listId), null, null, null).use { cursor -> + readableDatabase.query(MembershipTable.TABLE_NAME, null, "${MembershipTable.LIST_ID} = ? AND ${MembershipTable.PRIVACY_MODE} = ?", SqlUtil.buildArgs(listId, privacyMode.serialize()), null, null, null).use { cursor -> while (cursor.moveToNext()) { members.add(RecipientId.from(cursor.requireLong(MembershipTable.RECIPIENT_ID))) } @@ -389,15 +415,35 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si } fun getMemberCount(listId: DistributionListId): Int { - return if (listId == DistributionListId.MY_STORY) { - SignalDatabase.recipients.getSignalContacts(false)?.count?.let { it - getRawMemberCount(listId) } ?: 0 - } else { - getRawMemberCount(listId) - } + return getPrivacyData(listId).memberCount } - fun getRawMemberCount(listId: DistributionListId): Int { - readableDatabase.query(MembershipTable.TABLE_NAME, SqlUtil.buildArgs("COUNT(*)"), "${MembershipTable.LIST_ID} = ?", SqlUtil.buildArgs(listId), null, null, null).use { cursor -> + fun getPrivacyData(listId: DistributionListId): DistributionListPrivacyData { + lateinit var privacyMode: DistributionListPrivacyMode + var rawMemberCount = 0 + var totalContactCount = 0 + + readableDatabase.withinTransaction { + privacyMode = getPrivacyMode(listId) + rawMemberCount = getRawMemberCount(listId, privacyMode) + totalContactCount = SignalDatabase.recipients.getSignalContactsCount(false) + } + + val memberCount = when (privacyMode) { + DistributionListPrivacyMode.ALL -> totalContactCount + DistributionListPrivacyMode.ALL_EXCEPT -> totalContactCount - rawMemberCount + DistributionListPrivacyMode.ONLY_WITH -> rawMemberCount + } + + return DistributionListPrivacyData( + privacyMode = privacyMode, + rawMemberCount = rawMemberCount, + memberCount = memberCount + ) + } + + private fun getRawMemberCount(listId: DistributionListId, privacyMode: DistributionListPrivacyMode): Int { + readableDatabase.query(MembershipTable.TABLE_NAME, SqlUtil.buildArgs("COUNT(*)"), "${MembershipTable.LIST_ID} = ? AND ${MembershipTable.PRIVACY_MODE} = ?", SqlUtil.buildArgs(listId, privacyMode.serialize()), null, null, null).use { cursor -> return if (cursor.moveToFirst()) { cursor.getInt(0) } else { @@ -406,24 +452,46 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si } } - fun removeMemberFromList(listId: DistributionListId, member: RecipientId) { - writableDatabase.delete(MembershipTable.TABLE_NAME, "${MembershipTable.LIST_ID} = ? AND ${MembershipTable.RECIPIENT_ID} = ?", SqlUtil.buildArgs(listId, member)) + private fun getPrivacyMode(listId: DistributionListId): DistributionListPrivacyMode { + return readableDatabase + .select(ListTable.PRIVACY_MODE) + .from(ListTable.TABLE_NAME) + .where("${ListTable.ID} = ?", listId.serialize()) + .run() + .use { + if (it.moveToFirst()) { + it.requireObject(ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer) + } else { + DistributionListPrivacyMode.ONLY_WITH + } + } } - fun addMemberToList(listId: DistributionListId, member: RecipientId) { + fun removeMemberFromList(listId: DistributionListId, privacyMode: DistributionListPrivacyMode, member: RecipientId) { + writableDatabase.delete(MembershipTable.TABLE_NAME, "${MembershipTable.LIST_ID} = ? AND ${MembershipTable.RECIPIENT_ID} = ? AND ${MembershipTable.PRIVACY_MODE} = ?", SqlUtil.buildArgs(listId, member, privacyMode.serialize())) + } + + fun addMemberToList(listId: DistributionListId, privacyMode: DistributionListPrivacyMode, member: RecipientId) { val values = ContentValues().apply { put(MembershipTable.LIST_ID, listId.serialize()) put(MembershipTable.RECIPIENT_ID, member.serialize()) + put(MembershipTable.PRIVACY_MODE, privacyMode.serialize()) } writableDatabase.insert(MembershipTable.TABLE_NAME, null, values) } + fun removeAllMembers(listId: DistributionListId) { + writableDatabase + .delete(MembershipTable.TABLE_NAME) + .where("${MembershipTable.LIST_ID} = ?", listId.serialize()) + .run() + } + 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)) } @@ -487,12 +555,19 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si throw AssertionError("Should never try to insert My Story") } + val privacyMode: DistributionListPrivacyMode = when { + insert.isBlockList && insert.recipients.isEmpty() -> DistributionListPrivacyMode.ALL + insert.isBlockList -> DistributionListPrivacyMode.ALL_EXCEPT + else -> DistributionListPrivacyMode.ONLY_WITH + } + createList( name = insert.name, members = insert.recipients.map(RecipientId::from), distributionId = distributionId, allowsReplies = insert.allowsReplies(), deletionTimestamp = insert.deletedAtTimestamp, + privacyMode = privacyMode, storageId = insert.id.raw ) } @@ -526,12 +601,18 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si return } - writableDatabase.beginTransaction() - try { + val privacyMode: DistributionListPrivacyMode = when { + update.new.isBlockList && update.new.recipients.isEmpty() -> DistributionListPrivacyMode.ALL + update.new.isBlockList -> DistributionListPrivacyMode.ALL_EXCEPT + else -> DistributionListPrivacyMode.ONLY_WITH + } + + writableDatabase.withinTransaction { val listTableValues = contentValuesOf( ListTable.ALLOWS_REPLIES to update.new.allowsReplies(), ListTable.NAME to update.new.name, - ListTable.IS_UNKNOWN to false + ListTable.IS_UNKNOWN to false, + ListTable.PRIVACY_MODE to privacyMode.serialize() ) writableDatabase.update( @@ -541,22 +622,18 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si SqlUtil.buildArgs(distributionId.toString()) ) - val currentlyInDistributionList = getRawMembers(distributionListId).toSet() + val currentlyInDistributionList = getRawMembers(distributionListId, privacyMode).toSet() val shouldBeInDistributionList = update.new.recipients.map(RecipientId::from).toSet() val toRemove = currentlyInDistributionList - shouldBeInDistributionList val toAdd = shouldBeInDistributionList - currentlyInDistributionList toRemove.forEach { - removeMemberFromList(distributionListId, it) + removeMemberFromList(distributionListId, privacyMode, it) } toAdd.forEach { - addMemberToList(distributionListId, it) + addMemberToList(distributionListId, privacyMode, it) } - - writableDatabase.setTransactionSuccessful() - } finally { - writableDatabase.endTransaction() } } 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 72fdd430f..91a5213ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt @@ -27,6 +27,7 @@ import org.signal.core.util.requireNonNullString import org.signal.core.util.requireString import org.signal.core.util.select import org.signal.core.util.update +import org.signal.core.util.withinTransaction import org.signal.libsignal.protocol.IdentityKey import org.signal.libsignal.protocol.InvalidKeyException import org.signal.libsignal.zkgroup.InvalidInputException @@ -821,6 +822,15 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : } } + fun markNeedsSync(recipientIds: Collection) { + writableDatabase + .withinTransaction { + for (recipientId in recipientIds) { + markNeedsSync(recipientId) + } + } + } + fun markNeedsSync(recipientId: RecipientId) { rotateStorageId(recipientId) ApplicationDependencies.getDatabaseObserver().notifyRecipientChanged(recipientId) @@ -2301,6 +2311,14 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : } fun getSignalContacts(includeSelf: Boolean): Cursor? { + return getSignalContacts(includeSelf, "$SORT_NAME, $SYSTEM_JOINED_NAME, $SEARCH_PROFILE_NAME, $USERNAME, $PHONE") + } + + fun getSignalContactsCount(includeSelf: Boolean): Int { + return getSignalContacts(includeSelf)?.count ?: 0 + } + + fun getSignalContacts(includeSelf: Boolean, orderBy: String? = null): Cursor? { val searchSelection = ContactSearchSelection.Builder() .withRegistered(true) .withGroups(false) @@ -2308,7 +2326,6 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : .build() val selection = searchSelection.where val args = searchSelection.args - val orderBy = "$SORT_NAME, $SYSTEM_JOINED_NAME, $SEARCH_PROFILE_NAME, $USERNAME, $PHONE" return readableDatabase.query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SQLiteDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SQLiteDatabase.java index 0a30e256e..25088b402 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SQLiteDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SQLiteDatabase.java @@ -48,9 +48,14 @@ public class SQLiteDatabase implements SupportSQLiteDatabase { private final net.zetetic.database.sqlcipher.SQLiteDatabase wrapped; private final Tracer tracer; - private static final ThreadLocal> POST_TRANSACTION_TASKS = new ThreadLocal<>(); + private static final ThreadLocal> PENDING_POST_SUCCESSFUL_TRANSACTION_TASKS; + private static final ThreadLocal> POST_SUCCESSFUL_TRANSACTION_TASKS; + static { - POST_TRANSACTION_TASKS.set(new LinkedHashSet<>()); + PENDING_POST_SUCCESSFUL_TRANSACTION_TASKS = new ThreadLocal<>(); + POST_SUCCESSFUL_TRANSACTION_TASKS = new ThreadLocal<>(); + + PENDING_POST_SUCCESSFUL_TRANSACTION_TASKS.set(new LinkedHashSet<>()); } public SQLiteDatabase(net.zetetic.database.sqlcipher.SQLiteDatabase wrapped) { @@ -125,7 +130,7 @@ public class SQLiteDatabase implements SupportSQLiteDatabase { */ public void runPostSuccessfulTransaction(@NonNull Runnable task) { if (wrapped.inTransaction()) { - getPostTransactionTasks().add(task); + getPendingPostSuccessfulTransactionTasks().add(task); } else { task.run(); } @@ -137,18 +142,29 @@ public class SQLiteDatabase implements SupportSQLiteDatabase { */ public void runPostSuccessfulTransaction(@NonNull String dedupeKey, @NonNull Runnable task) { if (wrapped.inTransaction()) { - getPostTransactionTasks().add(new DedupedRunnable(dedupeKey, task)); + getPendingPostSuccessfulTransactionTasks().add(new DedupedRunnable(dedupeKey, task)); } else { task.run(); } } - private @NonNull Set getPostTransactionTasks() { - Set tasks = POST_TRANSACTION_TASKS.get(); + private @NonNull Set getPendingPostSuccessfulTransactionTasks() { + Set tasks = PENDING_POST_SUCCESSFUL_TRANSACTION_TASKS.get(); if (tasks == null) { tasks = new LinkedHashSet<>(); - POST_TRANSACTION_TASKS.set(tasks); + PENDING_POST_SUCCESSFUL_TRANSACTION_TASKS.set(tasks); + } + + return tasks; + } + + private @NonNull Set getPostSuccessfulTransactionTasks() { + Set tasks = POST_SUCCESSFUL_TRANSACTION_TASKS.get(); + + if (tasks == null) { + tasks = new LinkedHashSet<>(); + POST_SUCCESSFUL_TRANSACTION_TASKS.set(tasks); } return tasks; @@ -278,16 +294,16 @@ public class SQLiteDatabase implements SupportSQLiteDatabase { @Override public void onCommit() { - Set tasks = getPostTransactionTasks(); - for (Runnable r : new HashSet<>(tasks)) { - r.run(); - } + Set pendingTasks = getPendingPostSuccessfulTransactionTasks(); + Set tasks = getPostSuccessfulTransactionTasks(); tasks.clear(); + tasks.addAll(pendingTasks); + pendingTasks.clear(); } @Override public void onRollback() { - getPostTransactionTasks().clear(); + getPendingPostSuccessfulTransactionTasks().clear(); } }); }); @@ -297,6 +313,12 @@ public class SQLiteDatabase implements SupportSQLiteDatabase { public void endTransaction() { trace("endTransaction()", wrapped::endTransaction); traceLockEnd(); + + Set tasks = getPostSuccessfulTransactionTasks(); + for (Runnable r : new HashSet<>(tasks)) { + r.run(); + } + tasks.clear(); } public void setTransactionSuccessful() { 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 1110f1765..c6b4f3c2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt @@ -131,6 +131,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data executeStatements(db, NotificationProfileDatabase.CREATE_INDEXES) executeStatements(db, DonationReceiptDatabase.CREATE_INDEXS) db.execSQL(StorySendsDatabase.CREATE_INDEX) + executeStatements(db, DistributionListDatabase.CREATE_INDEXES) executeStatements(db, MessageSendLogDatabase.CREATE_TRIGGERS) executeStatements(db, ReactionDatabase.CREATE_TRIGGERS) 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 a30cf5ec6..6a6c2d2b6 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 @@ -201,8 +201,9 @@ object SignalDatabaseMigrations { private const val GROUP_STORY_REPLY_CLEANUP = 145 private const val REMOTE_MEGAPHONE = 146 private const val QUOTE_INDEX = 147 + private const val MY_STORY_PRIVACY_MODE = 148 - const val DATABASE_VERSION = 147 + const val DATABASE_VERSION = 148 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -2622,6 +2623,41 @@ object SignalDatabaseMigrations { """ ) } + + if (oldVersion < MY_STORY_PRIVACY_MODE) { + db.execSQL("ALTER TABLE distribution_list ADD COLUMN privacy_mode INTEGER DEFAULT 0") + db.execSQL("UPDATE distribution_list SET privacy_mode = 1 WHERE _id = 1") + + db.execSQL( + """ + CREATE TABLE distribution_list_member_tmp ( + _id INTEGER PRIMARY KEY AUTOINCREMENT, + list_id INTEGER NOT NULL REFERENCES distribution_list (_id) ON DELETE CASCADE, + recipient_id INTEGER NOT NULL REFERENCES recipient (_id), + privacy_mode INTEGER DEFAULT 0 + ) + """ + ) + + db.execSQL( + """ + INSERT INTO distribution_list_member_tmp + SELECT + _id, + list_id, + recipient_id, + 0 + FROM distribution_list_member + """ + ) + + db.execSQL("DROP TABLE distribution_list_member") + db.execSQL("ALTER TABLE distribution_list_member_tmp RENAME TO distribution_list_member") + + db.execSQL("UPDATE distribution_list_member SET privacy_mode = 1 WHERE list_id = 1") + + db.execSQL("CREATE UNIQUE INDEX distribution_list_member_list_id_recipient_id_privacy_mode_index ON distribution_list_member (list_id, recipient_id, privacy_mode)") + } } @JvmStatic 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 index 0515812d9..5d96a8ad9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListId.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListId.java @@ -48,6 +48,10 @@ public final class DistributionListId implements DatabaseId, Parcelable { this.id = id; } + public boolean isMyStory() { + return equals(MY_STORY); + } + @Override public void writeToParcel(Parcel dest, int flags) { dest.writeLong(id); 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 index 2f9aa53f7..5f25debae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPartialRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPartialRecord.kt @@ -7,5 +7,6 @@ data class DistributionListPartialRecord( val name: CharSequence, val recipientId: RecipientId, val allowsReplies: Boolean, - val isUnknown: Boolean + val isUnknown: Boolean, + val privacyMode: DistributionListPrivacyMode ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPrivacyData.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPrivacyData.kt new file mode 100644 index 000000000..a1c4848c7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPrivacyData.kt @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.database.model + +/** + * Data needed to know how a distribution privacy settings are configured. + */ +data class DistributionListPrivacyData( + val privacyMode: DistributionListPrivacyMode, + val rawMemberCount: Int, + val memberCount: Int +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPrivacyMode.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPrivacyMode.kt new file mode 100644 index 000000000..f06d433d6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPrivacyMode.kt @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.database.model + +import org.signal.core.util.LongSerializer + +/** + * A list can explicit ([ONLY_WITH]) where only members of the list can send or exclusionary ([ALL_EXCEPT]) where + * all connections are sent the story except for those members of the list. [ALL] is all of your Signal Connections. + */ +enum class DistributionListPrivacyMode(private val code: Long) { + ONLY_WITH(0), + ALL_EXCEPT(1), + ALL(2); + + val isBlockList: Boolean + get() = this != ONLY_WITH + + fun serialize(): Long { + return code + } + + companion object Serializer : LongSerializer { + override fun serialize(data: DistributionListPrivacyMode): Long { + return data.serialize() + } + + override fun deserialize(data: Long): DistributionListPrivacyMode { + return when (data) { + ONLY_WITH.code -> ONLY_WITH + ALL_EXCEPT.code -> ALL_EXCEPT + ALL.code -> ALL + else -> throw AssertionError("Unknown privacy mode: $data") + } + } + } +} 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 index 50a46c455..4b7f46ec5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListRecord.kt @@ -11,7 +11,16 @@ data class DistributionListRecord( val name: String, val distributionId: DistributionId, val allowsReplies: Boolean, + val rawMembers: List, val members: List, val deletedAtTimestamp: Long, - val isUnknown: Boolean -) + val isUnknown: Boolean, + val privacyMode: DistributionListPrivacyMode +) { + fun getMembersToSync(): List { + return when (privacyMode) { + DistributionListPrivacyMode.ALL -> emptyList() + else -> rawMembers + } + } +} 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 0b99355f5..4455da317 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.migrations.EmojiDownloadMigrationJob; import org.thoughtcrime.securesms.migrations.KbsEnclaveMigrationJob; import org.thoughtcrime.securesms.migrations.LegacyMigrationJob; import org.thoughtcrime.securesms.migrations.MigrationCompleteJob; +import org.thoughtcrime.securesms.migrations.SyncDistributionListsMigrationJob; import org.thoughtcrime.securesms.migrations.PassingMigrationJob; import org.thoughtcrime.securesms.migrations.PinOptOutMigration; import org.thoughtcrime.securesms.migrations.PinReminderMigrationJob; @@ -206,6 +207,7 @@ public final class JobManagerFactories { put(KbsEnclaveMigrationJob.KEY, new KbsEnclaveMigrationJob.Factory()); put(LegacyMigrationJob.KEY, new LegacyMigrationJob.Factory()); put(MigrationCompleteJob.KEY, new MigrationCompleteJob.Factory()); + put(SyncDistributionListsMigrationJob.KEY, new SyncDistributionListsMigrationJob.Factory()); put(PinOptOutMigration.KEY, new PinOptOutMigration.Factory()); put(PinReminderMigrationJob.KEY, new PinReminderMigrationJob.Factory()); put(PniAccountInitializationMigrationJob.KEY, new PniAccountInitializationMigrationJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt index a9360cd69..354816c76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.keyvalue +import org.signal.core.util.LongSerializer import kotlin.reflect.KProperty internal fun SignalStoreValues.longValue(key: String, default: Long): SignalStoreValueDelegate { @@ -26,6 +27,10 @@ internal fun SignalStoreValues.blobValue(key: String, default: ByteArray): Signa return BlobValue(key, default, this.store) } +internal fun SignalStoreValues.enumValue(key: String, default: T, serializer: LongSerializer): SignalStoreValueDelegate { + return KeyValueEnumValue(key, default, serializer, this.store) +} + /** * Kotlin delegate that serves as a base for all other value types. This allows us to only expose this sealed * class to callers and protect the individual implementations as private behind the various extension functions. @@ -102,3 +107,17 @@ private class BlobValue(private val key: String, private val default: ByteArray, values.beginWrite().putBlob(key, value).apply() } } + +private class KeyValueEnumValue(private val key: String, private val default: T, private val serializer: LongSerializer, store: KeyValueStore) : SignalStoreValueDelegate(store) { + override fun getValue(values: KeyValueStore): T { + return if (values.containsKey(key)) { + serializer.deserialize(values.getLong(key, 0)) + } else { + default + } + } + + override fun setValue(values: KeyValueStore, value: T) { + values.beginWrite().putLong(key, serializer.serialize(value)).apply() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java index 5c1cf90f0..39a6c759e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java @@ -4,6 +4,7 @@ import androidx.annotation.NonNull; import com.google.protobuf.InvalidProtocolBufferException; +import org.signal.core.util.StringSerializer; import org.thoughtcrime.securesms.database.model.databaseprotos.SignalStoreList; import java.util.Collections; @@ -50,7 +51,7 @@ abstract class SignalStoreValues { return store.getBlob(key, defaultValue); } - List getList(@NonNull String key, @NonNull Serializer serializer) { + List getList(@NonNull String key, @NonNull StringSerializer serializer) { byte[] blob = getBlob(key, null); if (blob == null) { return Collections.emptyList(); @@ -93,7 +94,7 @@ abstract class SignalStoreValues { store.beginWrite().putString(key, value).apply(); } - void putList(@NonNull String key, @NonNull List values, @NonNull Serializer serializer) { + void putList(@NonNull String key, @NonNull List values, @NonNull StringSerializer serializer) { putBlob(key, SignalStoreList.newBuilder() .addAllContents(values.stream() .map(serializer::serialize) @@ -105,9 +106,4 @@ abstract class SignalStoreValues { void remove(@NonNull String key) { store.beginWrite().remove(key).apply(); } - - interface Serializer { - @NonNull String serialize(@NonNull T data); - T deserialize(@NonNull String data); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt index 5ebe2e57e..205aa071a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.keyvalue import org.json.JSONObject +import org.signal.core.util.StringSerializer import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.groups.GroupId @@ -62,7 +63,7 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) { return storySends.filter { it.timestamp >= activeCutoffTimestamp } } - private object StorySendSerializer : Serializer { + private object StorySendSerializer : StringSerializer { override fun serialize(data: StorySend): String { return JSONObject() 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 index 4f1247ef1..3d989ff3a 100644 --- 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 @@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendReposi import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendResult import org.thoughtcrime.securesms.stories.StoryTextPostView import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs -import org.thoughtcrime.securesms.stories.settings.hide.HideStoryFromDialogFragment +import org.thoughtcrime.securesms.stories.settings.privacy.HideStoryFromDialogFragment import org.thoughtcrime.securesms.util.LifecycleDisposable import org.thoughtcrime.securesms.util.navigation.safeNavigate 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 index 7b12f822a..04c8b8ad1 100644 --- 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 @@ -29,7 +29,7 @@ import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs import org.thoughtcrime.securesms.stories.settings.create.CreateStoryFlowDialogFragment import org.thoughtcrime.securesms.stories.settings.create.CreateStoryWithViewersFragment -import org.thoughtcrime.securesms.stories.settings.hide.HideStoryFromDialogFragment +import org.thoughtcrime.securesms.stories.settings.privacy.HideStoryFromDialogFragment import org.thoughtcrime.securesms.util.BottomSheetUtil import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.LifecycleDisposable diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index 479686fb4..4cc06ec6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -102,9 +102,10 @@ public class ApplicationMigrations { static final int PNI_IDENTITY_3 = 58; static final int STORY_DISTRIBUTION_LIST_SYNC = 59; static final int EMOJI_VERSION_7 = 60; + static final int MY_STORY_PRIVACY_MODE = 61; } - public static final int CURRENT_VERSION = 60; + public static final int CURRENT_VERSION = 61; /** * This *must* be called after the {@link JobManager} has been instantiated, but *before* the call @@ -446,6 +447,10 @@ public class ApplicationMigrations { jobs.put(Version.EMOJI_VERSION_7, new EmojiDownloadMigrationJob()); } + if (lastSeenVersion < Version.MY_STORY_PRIVACY_MODE) { + jobs.put(Version.MY_STORY_PRIVACY_MODE, new SyncDistributionListsMigrationJob()); + } + return jobs; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/SyncDistributionListsMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/SyncDistributionListsMigrationJob.java new file mode 100644 index 000000000..1b2e753a2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/SyncDistributionListsMigrationJob.java @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; + +/** + * Marks all distribution lists as needing to be synced with storage service. + */ +public final class SyncDistributionListsMigrationJob extends MigrationJob { + + public static final String KEY = "SyncDistributionListsMigrationJob"; + + SyncDistributionListsMigrationJob() { + this(new Parameters.Builder().build()); + } + + private SyncDistributionListsMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + SignalDatabase.recipients().markNeedsSync(SignalDatabase.distributionLists().getAllListRecipients()); + StorageSyncHelper.scheduleSyncForDataChange(); + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull SyncDistributionListsMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new SyncDistributionListsMigrationJob(parameters); + } + } +} 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 48f14355d..a795eeeff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java @@ -12,6 +12,7 @@ import androidx.annotation.Nullable; import com.annimon.stream.Stream; import org.signal.core.util.DatabaseId; +import org.signal.core.util.LongSerializer; import org.thoughtcrime.securesms.util.DelimiterUtil; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.signalservice.api.push.ServiceId; @@ -29,6 +30,7 @@ public class RecipientId implements Parcelable, Comparable, Databas private static final char DELIMITER = ','; public static final RecipientId UNKNOWN = RecipientId.from(UNKNOWN_ID); + public static final LongSerializer SERIALIZER = new Serializer(); private final long id; @@ -212,4 +214,16 @@ public class RecipientId implements Parcelable, Comparable, Databas private static class InvalidLongRecipientIdError extends AssertionError {} private static class InvalidStringRecipientIdError extends AssertionError {} + + private static class Serializer implements LongSerializer { + @Override + public Long serialize(RecipientId data) { + return data.toLong(); + } + + @Override + public @NonNull RecipientId deserialize(Long data) { + return RecipientId.from(data); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java index 986710b1e..0629289f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java @@ -4,16 +4,16 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.annimon.stream.Stream; -import com.google.protobuf.ByteString; import org.signal.libsignal.zkgroup.groups.GroupMasterKey; import org.thoughtcrime.securesms.database.IdentityDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.DistributionListId; -import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord; +import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode; import org.thoughtcrime.securesms.database.model.DistributionListRecord; import org.thoughtcrime.securesms.database.model.RecipientRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues; import org.thoughtcrime.securesms.recipients.Recipient; @@ -31,6 +31,7 @@ import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState; +import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -193,13 +194,15 @@ public final class StorageSyncModels { return new SignalStoryDistributionListRecord.Builder(rawStorageId, recipient.getSyncExtras().getStorageProto()) .setIdentifier(UuidUtil.toByteArray(record.getDistributionId().asUuid())) .setName(record.getName()) - .setRecipients(record.getMembers().stream() + .setRecipients(record.getMembersToSync() + .stream() .map(Recipient::resolved) .filter(Recipient::hasServiceId) .map(Recipient::requireServiceId) .map(SignalServiceAddress::new) .collect(Collectors.toList())) .setAllowsReplies(record.getAllowsReplies()) + .setIsBlockList(record.getPrivacyMode().isBlockList()) .build(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.java index 5551fc554..16381e1ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.java @@ -89,9 +89,10 @@ public class StoryDistributionListRecordProcessor extends DefaultStorageRecordPr List recipients = remote.getRecipients(); long deletedAtTimestamp = remote.getDeletedAtTimestamp(); boolean allowsReplies = remote.allowsReplies(); + boolean isBlockList = remote.isBlockList(); - boolean matchesRemote = doParamsMatch(remote, unknownFields, identifier, name, recipients, deletedAtTimestamp, allowsReplies); - boolean matchesLocal = doParamsMatch(local, unknownFields, identifier, name, recipients, deletedAtTimestamp, allowsReplies); + boolean matchesRemote = doParamsMatch(remote, unknownFields, identifier, name, recipients, deletedAtTimestamp, allowsReplies, isBlockList); + boolean matchesLocal = doParamsMatch(local, unknownFields, identifier, name, recipients, deletedAtTimestamp, allowsReplies, isBlockList); if (matchesRemote) { return remote; @@ -104,6 +105,7 @@ public class StoryDistributionListRecordProcessor extends DefaultStorageRecordPr .setRecipients(recipients) .setDeletedAtTimestamp(deletedAtTimestamp) .setAllowsReplies(allowsReplies) + .setIsBlockList(isBlockList) .build(); } } @@ -131,14 +133,16 @@ public class StoryDistributionListRecordProcessor extends DefaultStorageRecordPr @Nullable byte[] unknownFields, @Nullable byte[] identifier, @Nullable String name, - @NonNull List recipients, + @NonNull List recipients, long deletedAtTimestamp, - boolean allowsReplies) { + boolean allowsReplies, + boolean isBlockList) { return Arrays.equals(unknownFields, record.serializeUnknownFields()) && - Arrays.equals(identifier, record.getIdentifier()) && - Objects.equals(name, record.getName()) && - Objects.equals(recipients, record.getRecipients()) && - deletedAtTimestamp == record.getDeletedAtTimestamp() && - allowsReplies == record.allowsReplies(); + Arrays.equals(identifier, record.getIdentifier()) && + Objects.equals(name, record.getName()) && + Objects.equals(recipients, record.getRecipients()) && + deletedAtTimestamp == record.getDeletedAtTimestamp() && + allowsReplies == record.allowsReplies() && + isBlockList == record.isBlockList(); } } 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 index b856fc3f0..8416ad1f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryDialogs.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.stories.dialogs import android.content.Context import android.view.View +import android.widget.Toast import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel @@ -33,7 +34,7 @@ object StoryDialogs { .setPositiveButton(R.string.StoryDialogs__add_to_story) { _, _ -> onAddToStory.invoke() } - .setNeutralButton(R.string.StoryDialogs__edit_viewers) { _, _ -> onEditViewers.invoke() } + .setNeutralButton(R.string.StoryDialogs__edit_viewers) { _, _ -> Toast.makeText(context, "New flow coming soon", Toast.LENGTH_SHORT).show() } .setNegativeButton(android.R.string.cancel) { _, _ -> onCancel.invoke() } .setCancelable(false) .show() 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 index 386c8b72e..9a1c5037e 100644 --- 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 @@ -16,10 +16,10 @@ class PrivateStorySettingsRepository { }.subscribeOn(Schedulers.io()) } - fun removeMember(distributionListId: DistributionListId, member: RecipientId): Completable { + fun removeMember(distributionListRecord: DistributionListRecord, member: RecipientId): Completable { return Completable.fromAction { - SignalDatabase.distributionLists.removeMemberFromList(distributionListId, member) - Stories.onStorySettingsChanged(distributionListId) + SignalDatabase.distributionLists.removeMemberFromList(distributionListRecord.id, distributionListRecord.privacyMode, member) + Stories.onStorySettingsChanged(distributionListRecord.id) }.subscribeOn(Schedulers.io()) } 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 index c86267793..c4197bf99 100644 --- 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 @@ -39,7 +39,7 @@ class PrivateStorySettingsViewModel(private val distributionListId: Distribution } fun remove(recipient: Recipient) { - disposables += repository.removeMember(distributionListId, recipient.id) + disposables += repository.removeMember(store.state.privateStory!!, recipient.id) .subscribe { refresh() } 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 deleted file mode 100644 index f83885159..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/hide/HideStoryFromFragment.kt +++ /dev/null @@ -1,20 +0,0 @@ -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/MyStoryPrivacyState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStoryPrivacyState.kt new file mode 100644 index 000000000..5f8249872 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStoryPrivacyState.kt @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.stories.settings.my + +import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode + +data class MyStoryPrivacyState(val privacyMode: DistributionListPrivacyMode? = null, val connectionCount: Int = 0) 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 index abdf118ac..f88387e76 100644 --- 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 @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.stories.settings.my -import androidx.core.content.ContextCompat +import android.os.Bundle +import android.view.View import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import org.thoughtcrime.securesms.R @@ -9,28 +10,22 @@ 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.database.model.DistributionListPrivacyMode +import org.thoughtcrime.securesms.util.LifecycleDisposable 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 viewModel: MyStorySettingsViewModel by viewModels() - 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) - ) + private lateinit var lifecycleDisposable: LifecycleDisposable + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + lifecycleDisposable = LifecycleDisposable() + lifecycleDisposable.bindTo(viewLifecycleOwner) + super.onViewCreated(view, savedInstanceState) } override fun onResume() { @@ -48,16 +43,57 @@ class MyStorySettingsFragment : DSLSettingsFragment( 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)), + radioPref( + title = DSLSettingsText.from(R.string.MyStorySettingsFragment__all_signal_connections), + summary = DSLSettingsText.from(R.string.MyStorySettingsFragment__share_with_all_connections), + isChecked = state.myStoryPrivacyState.privacyMode == DistributionListPrivacyMode.ALL, onClick = { - findNavController().safeNavigate(R.id.action_myStorySettings_to_hideStoryFromFragment) + lifecycleDisposable += viewModel.setMyStoryPrivacyMode(DistributionListPrivacyMode.ALL) + .subscribe() + } + ) + + val exceptText = if (state.myStoryPrivacyState.privacyMode == DistributionListPrivacyMode.ALL_EXCEPT) { + DSLSettingsText.from(resources.getQuantityString(R.plurals.MyStorySettingsFragment__d_people_excluded, state.myStoryPrivacyState.connectionCount, state.myStoryPrivacyState.connectionCount)) + } else { + DSLSettingsText.from(R.string.MyStorySettingsFragment__hide_your_story_from_specific_people) + } + + radioPref( + title = DSLSettingsText.from(R.string.MyStorySettingsFragment__all_signal_connections_except), + summary = exceptText, + isChecked = state.myStoryPrivacyState.privacyMode == DistributionListPrivacyMode.ALL_EXCEPT, + onClick = { + lifecycleDisposable += viewModel.setMyStoryPrivacyMode(DistributionListPrivacyMode.ALL_EXCEPT) + .subscribe { findNavController().safeNavigate(R.id.action_myStorySettings_to_allExceptFragment) } + } + ) + + val onlyWithText = if (state.myStoryPrivacyState.privacyMode == DistributionListPrivacyMode.ONLY_WITH) { + DSLSettingsText.from(resources.getQuantityString(R.plurals.MyStorySettingsFragment__d_people, state.myStoryPrivacyState.connectionCount, state.myStoryPrivacyState.connectionCount)) + } else { + DSLSettingsText.from(R.string.MyStorySettingsFragment__only_share_with_selected_people) + } + + radioPref( + title = DSLSettingsText.from(R.string.MyStorySettingsFragment__only_share_with), + summary = onlyWithText, + isChecked = state.myStoryPrivacyState.privacyMode == DistributionListPrivacyMode.ONLY_WITH, + onClick = { + lifecycleDisposable += viewModel.setMyStoryPrivacyMode(DistributionListPrivacyMode.ONLY_WITH) + .subscribe { findNavController().safeNavigate(R.id.action_myStorySettings_to_onlyShareWithFragment) } + } + ) + + learnMoreTextPref( + summary = DSLSettingsText.from(R.string.MyStorySettingsFragment__choose_who_can_view_your_story), + onClick = { + findNavController().safeNavigate(R.id.action_myStorySettings_to_signalConnectionsBottomSheet) } ) - textPref(summary = DSLSettingsText.from(signalConnectionsSummary)) dividerPref() + sectionHeaderPref(R.string.MyStorySettingsFragment__replies_amp_reactions) switchPref( title = DSLSettingsText.from(R.string.MyStorySettingsFragment__allow_replies_amp_reactions), 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 index 608a814f9..a0a55c279 100644 --- 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 @@ -5,13 +5,27 @@ 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.DistributionListPrivacyData +import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode import org.thoughtcrime.securesms.stories.Stories class MyStorySettingsRepository { - fun getHiddenRecipientCount(): Single { + fun getPrivacyState(): Single { return Single.fromCallable { - SignalDatabase.distributionLists.getRawMemberCount(DistributionListId.MY_STORY) + val privacyData: DistributionListPrivacyData = SignalDatabase.distributionLists.getPrivacyData(DistributionListId.MY_STORY) + + MyStoryPrivacyState( + privacyMode = privacyData.privacyMode, + connectionCount = if (privacyData.privacyMode == DistributionListPrivacyMode.ALL_EXCEPT) privacyData.rawMemberCount else privacyData.memberCount + ) + }.subscribeOn(Schedulers.io()) + } + + fun setPrivacyMode(privacyMode: DistributionListPrivacyMode): Completable { + return Completable.fromAction { + SignalDatabase.distributionLists.setPrivacyMode(DistributionListId.MY_STORY, privacyMode) + Stories.onStorySettingsChanged(DistributionListId.MY_STORY) }.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 index 671a5d372..1640e4212 100644 --- 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 @@ -1,6 +1,6 @@ package org.thoughtcrime.securesms.stories.settings.my data class MyStorySettingsState( - val hiddenStoryFromCount: Int = 0, + val myStoryPrivacyState: MyStoryPrivacyState = MyStoryPrivacyState(), 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 index 451decc79..cb6060d26 100644 --- 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 @@ -2,13 +2,14 @@ package org.thoughtcrime.securesms.stories.settings.my 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.DistributionListPrivacyMode import org.thoughtcrime.securesms.util.livedata.Store -class MyStorySettingsViewModel(private val repository: MyStorySettingsRepository) : ViewModel() { +class MyStorySettingsViewModel @JvmOverloads constructor(private val repository: MyStorySettingsRepository = MyStorySettingsRepository()) : ViewModel() { private val store = Store(MyStorySettingsState()) private val disposables = CompositeDisposable() @@ -20,8 +21,8 @@ class MyStorySettingsViewModel(private val repository: MyStorySettingsRepository fun refresh() { disposables.clear() - disposables += repository.getHiddenRecipientCount() - .subscribe { count -> store.update { it.copy(hiddenStoryFromCount = count) } } + disposables += repository.getPrivacyState() + .subscribe { myStoryPrivacyState -> store.update { it.copy(myStoryPrivacyState = myStoryPrivacyState) } } disposables += repository.getRepliesAndReactionsEnabled() .subscribe { repliesAndReactionsEnabled -> store.update { it.copy(areRepliesAndReactionsEnabled = repliesAndReactionsEnabled) } } } @@ -32,9 +33,13 @@ class MyStorySettingsViewModel(private val repository: MyStorySettingsRepository .subscribe { refresh() } } - class Factory(private val repository: MyStorySettingsRepository) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return modelClass.cast(MyStorySettingsViewModel(repository)) as T + fun setMyStoryPrivacyMode(privacyMode: DistributionListPrivacyMode): Completable { + return if (privacyMode == state.value!!.myStoryPrivacyState.privacyMode) { + Completable.complete() + } else { + repository.setPrivacyMode(privacyMode) + .observeOn(AndroidSchedulers.mainThread()) + .doOnComplete { refresh() } } } } 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 index 298fc3428..b95e9cde6 100644 --- 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 @@ -4,14 +4,18 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.TextView import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment +import org.thoughtcrime.securesms.util.SpanUtil 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) + val view = inflater.inflate(R.layout.stories_signal_connection_bottom_sheet, container, false) + view.findViewById(R.id.text_1).text = SpanUtil.boldSubstring(getString(R.string.SignalConnectionsBottomSheet__signal_connections_are_people), getString(R.string.SignalConnectionsBottomSheet___signal_connections)) + return view } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/privacy/ChangeMyStoryMembershipFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/privacy/ChangeMyStoryMembershipFragment.kt new file mode 100644 index 000000000..40ad0b79b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/privacy/ChangeMyStoryMembershipFragment.kt @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.stories.settings.privacy + +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 + +abstract class ChangeMyStoryMembershipFragment : BaseStoryRecipientSelectionFragment() { + override val actionButtonLabel: Int = R.string.HideStoryFromFragment__done + + override val distributionListId: DistributionListId + get() = DistributionListId.from(DistributionListId.MY_STORY_ID) + + override fun presentTitle(toolbar: Toolbar, size: Int) = Unit +} + +/** + * Allows user to select a list of people to exclude from "My Story" + */ +class AllExceptFragment : ChangeMyStoryMembershipFragment() { + override val toolbarTitleId: Int = R.string.ChangeMyStoryMembershipFragment__all_except + override val checkboxResource: Int = R.drawable.contact_selection_exclude_checkbox +} + +/** + * Allows user to select a list of people to include for "My Story" + */ +class OnlyShareWithFragment : ChangeMyStoryMembershipFragment() { + override val toolbarTitleId: Int = R.string.ChangeMyStoryMembershipFragment__only_share_with +} 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/privacy/HideStoryFromDialogFragment.kt similarity index 75% rename from app/src/main/java/org/thoughtcrime/securesms/stories/settings/hide/HideStoryFromDialogFragment.kt rename to app/src/main/java/org/thoughtcrime/securesms/stories/settings/privacy/HideStoryFromDialogFragment.kt index 9e215608c..13e9476a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/hide/HideStoryFromDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/privacy/HideStoryFromDialogFragment.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.stories.settings.hide +package org.thoughtcrime.securesms.stories.settings.privacy import android.os.Bundle import android.view.View @@ -16,11 +16,7 @@ class HideStoryFromDialogFragment : DialogFragment(R.layout.fragment_container), } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - if (savedInstanceState == null) { - childFragmentManager.beginTransaction() - .replace(R.id.fragment_container, HideStoryFromFragment()) - .commit() - } + // TODO [stories] replace with new bottom sheet } override fun exitFlow() { 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 index baccda41a..eb121c894 100644 --- 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 @@ -36,6 +36,7 @@ abstract class BaseStoryRecipientSelectionFragment : Fragment(R.layout.stories_b protected open val toolbarTitleId: Int = R.string.CreateStoryViewerSelectionFragment__choose_viewers abstract val actionButtonLabel: Int abstract val distributionListId: DistributionListId? + protected open val checkboxResource: Int = R.drawable.contact_selection_checkbox private lateinit var toolbar: Toolbar private lateinit var searchField: EditText @@ -75,8 +76,10 @@ abstract class BaseStoryRecipientSelectionFragment : Fragment(R.layout.stories_b } viewModel.state.observe(viewLifecycleOwner) { - getAttachedContactSelectionFragment().markSelected(it.map(::ShareContact).toSet()) - presentTitle(toolbar, it.size) + if (it.distributionListId == null || it.privateStory != null) { + getAttachedContactSelectionFragment().markSelected(it.selection.map(::ShareContact).toSet()) + presentTitle(toolbar, it.selection.size) + } } lifecycleDisposable += viewModel.actionObservable.subscribe { action -> @@ -144,7 +147,8 @@ abstract class BaseStoryRecipientSelectionFragment : Fragment(R.layout.stories_b canSelectSelf = false, currentSelection = emptyList(), displaySelectionCount = false, - displayChips = true + displayChips = true, + checkboxResource = checkboxResource ) contactSelectionListFragment.arguments = arguments.toArgumentBundle() 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 index effc964fd..ed52df486 100644 --- 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 @@ -7,34 +7,36 @@ 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.database.model.DistributionListRecord import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.stories.Stories class BaseStoryRecipientSelectionRepository { - fun updateDistributionListMembership(distributionListId: DistributionListId, recipients: Set) { + + fun getRecord(distributionListId: DistributionListId): Single { + return Single.fromCallable { + SignalDatabase.distributionLists.getList(distributionListId) ?: error("Record does not exist.") + }.subscribeOn(Schedulers.io()) + } + + fun updateDistributionListMembership(distributionListRecord: DistributionListRecord, recipients: Set) { SignalExecutors.BOUNDED.execute { - val currentRecipients = SignalDatabase.distributionLists.getRawMembers(distributionListId).toSet() + val currentRecipients = SignalDatabase.distributionLists.getRawMembers(distributionListRecord.id, distributionListRecord.privacyMode).toSet() val oldNotNew = currentRecipients - recipients val newNotOld = recipients - currentRecipients oldNotNew.forEach { - SignalDatabase.distributionLists.removeMemberFromList(distributionListId, it) + SignalDatabase.distributionLists.removeMemberFromList(distributionListRecord.id, distributionListRecord.privacyMode, it) } newNotOld.forEach { - SignalDatabase.distributionLists.addMemberToList(distributionListId, it) + SignalDatabase.distributionLists.addMemberToList(distributionListRecord.id, distributionListRecord.privacyMode, it) } - Stories.onStorySettingsChanged(distributionListId) + Stories.onStorySettingsChanged(distributionListRecord.id) } } - 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionState.kt new file mode 100644 index 000000000..9448b5cae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionState.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.stories.settings.select + +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.database.model.DistributionListRecord +import org.thoughtcrime.securesms.recipients.RecipientId + +data class BaseStoryRecipientSelectionState( + val distributionListId: DistributionListId?, + val privateStory: DistributionListRecord? = null, + val selection: Set = emptySet() +) 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 index 64be3fb35..3eca57203 100644 --- 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 @@ -9,6 +9,7 @@ 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.database.model.DistributionListPrivacyMode import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.livedata.Store @@ -16,18 +17,19 @@ class BaseStoryRecipientSelectionViewModel( private val distributionListId: DistributionListId?, private val repository: BaseStoryRecipientSelectionRepository ) : ViewModel() { - private val store = Store(emptySet()) + private val store = Store(BaseStoryRecipientSelectionState(distributionListId)) private val subject = PublishSubject.create() private val disposable = CompositeDisposable() var actionObservable: Observable = subject - var state: LiveData> = store.stateLiveData + var state: LiveData = store.stateLiveData init { if (distributionListId != null) { - disposable += repository.getListMembers(distributionListId) - .subscribe { members -> - store.update { it + members } + disposable += repository.getRecord(distributionListId) + .subscribe { record -> + val startingSelection = if (record.privacyMode == DistributionListPrivacyMode.ALL_EXCEPT) record.rawMembers else record.members + store.update { it.copy(privateStory = record, selection = it.selection + startingSelection) } } } } @@ -38,24 +40,24 @@ class BaseStoryRecipientSelectionViewModel( fun toggleSelectAll() { disposable += repository.getAllSignalContacts().subscribeBy { allSignalRecipients -> - store.update { allSignalRecipients } + store.update { it.copy(selection = allSignalRecipients) } } } fun addRecipient(recipientId: RecipientId) { - store.update { it + recipientId } + store.update { it.copy(selection = it.selection + recipientId) } } fun removeRecipient(recipientId: RecipientId) { - store.update { it - recipientId } + store.update { it.copy(selection = it.selection - recipientId) } } fun onAction() { if (distributionListId != null) { - repository.updateDistributionListMembership(distributionListId, store.state) + repository.updateDistributionListMembership(store.state.privateStory!!, store.state.selection) subject.onNext(Action.ExitFlow) } else { - subject.onNext(Action.GoToNextScreen(store.state)) + subject.onNext(Action.GoToNextScreen(store.state.selection)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/views/LearnMoreTextView.java b/app/src/main/java/org/thoughtcrime/securesms/util/views/LearnMoreTextView.java index 943774403..3158f618d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/views/LearnMoreTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/views/LearnMoreTextView.java @@ -16,6 +16,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.widget.AppCompatTextView; +import androidx.core.content.ContextCompat; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.util.CommunicationActions; @@ -42,7 +43,7 @@ public class LearnMoreTextView extends AppCompatTextView { private void init() { setMovementMethod(LinkMovementMethod.getInstance()); setLinkTextInternal(R.string.LearnMoreTextView_learn_more); - setLinkColor(ThemeUtil.getThemedColor(getContext(), R.attr.colorAccent)); + setLinkColor(ContextCompat.getColor(getContext(), R.color.signal_colorOnSurface)); visible = true; } diff --git a/app/src/main/res/drawable/contact_selection_exclude_checkbox.xml b/app/src/main/res/drawable/contact_selection_exclude_checkbox.xml new file mode 100644 index 000000000..9fd0f2460 --- /dev/null +++ b/app/src/main/res/drawable/contact_selection_exclude_checkbox.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_x_error_inverse_16.xml b/app/src/main/res/drawable/ic_x_error_inverse_16.xml new file mode 100644 index 000000000..bcced65a1 --- /dev/null +++ b/app/src/main/res/drawable/ic_x_error_inverse_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/dsl_learn_more_preference_item.xml b/app/src/main/res/layout/dsl_learn_more_preference_item.xml new file mode 100644 index 000000000..ef43bcb1b --- /dev/null +++ b/app/src/main/res/layout/dsl_learn_more_preference_item.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dsl_radio_preference_item.xml b/app/src/main/res/layout/dsl_radio_preference_item.xml index 924e03379..105c2c38e 100644 --- a/app/src/main/res/layout/dsl_radio_preference_item.xml +++ b/app/src/main/res/layout/dsl_radio_preference_item.xml @@ -7,16 +7,16 @@ android:background="@drawable/dsl_preference_item_background" android:minHeight="56dp"> - + app:layout_constraintTop_toTopOf="parent" /> - + app:layout_constraintTop_toTopOf="parent" + tools:srcCompat="@drawable/ic_advanced_24" /> \ No newline at end of file diff --git a/app/src/main/res/navigation/my_story_settings.xml b/app/src/main/res/navigation/my_story_settings.xml index b2c80a8e2..298f735c3 100644 --- a/app/src/main/res/navigation/my_story_settings.xml +++ b/app/src/main/res/navigation/my_story_settings.xml @@ -5,8 +5,12 @@ android:id="@+id/my_story_settings" app:startDestination="@id/myStorySettings"> + + android:id="@+id/action_myStorySettings_to_allExceptFragment" + app:destination="@id/allExceptFragment" /> + @color/signal_dark_colorNeutralInverse @color/signal_dark_colorNeutralVariantInverse + + @color/signal_light_colorError + @color/signal_light_colorErrorContainer + #1F414659 #991B1C1F diff --git a/app/src/main/res/values/material3_colors.xml b/app/src/main/res/values/material3_colors.xml index 44c489fb6..52945af6b 100644 --- a/app/src/main/res/values/material3_colors.xml +++ b/app/src/main/res/values/material3_colors.xml @@ -43,6 +43,10 @@ @color/signal_light_colorNeutralInverse @color/signal_light_colorNeutralVariantInverse + + @color/signal_dark_colorError + @color/signal_dark_colorErrorContainer + #1FDCE5F9 #99FBFCFF diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 909a411b4..57f6aaa71 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4638,11 +4638,30 @@ Who can see this story Hide story from - + + All Signal connections + + Share with all connections + + All Signal connections except… + + Hide your story from specific people + + + %1$d person excluded + %1$d people excluded + + + Only share with… + + Only share with selected people + %1$d person %1$d people + + Choose who can view your story. Changes won\'t affect stories you\'ve already sent. Replies & reactions @@ -4651,10 +4670,10 @@ 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 - Signal Connections are people you\'ve chosen to trust, either by: + Signal Connections are people you\'ve chosen to trust, either by: Starting a conversation @@ -4703,8 +4722,10 @@ Search An unexpected error occurred - - Hide story from… + + All except… + + Only share with… Done diff --git a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt index a597c4936..2ffe8722a 100644 --- a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt @@ -55,11 +55,22 @@ fun Cursor.isNull(column: String): Boolean { return CursorUtil.isNull(this, column) } -inline fun Cursor.readToList(mapper: (Cursor) -> T): List { +fun Cursor.requireObject(column: String, serializer: LongSerializer): T { + return serializer.deserialize(CursorUtil.requireLong(this, column)) +} + +fun Cursor.requireObject(column: String, serializer: StringSerializer): T { + return serializer.deserialize(CursorUtil.requireString(this, column)) +} + +inline fun Cursor.readToList(predicate: (T) -> Boolean = { true }, mapper: (Cursor) -> T): List { val list = mutableListOf() use { while (moveToNext()) { - list += mapper(this) + val record = mapper(this) + if (predicate(record)) { + list += mapper(this) + } } } return list diff --git a/core-util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt b/core-util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt index b8e5ae94d..e5912d20c 100644 --- a/core-util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt @@ -7,6 +7,23 @@ import androidx.core.content.contentValuesOf import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteQueryBuilder +/** + * Begins a transaction on the `this` database, runs the provided [block] providing the `this` value as it's argument + * within the transaction, and then ends the transaction successfully. + * + * @return The value returned by [block] if any + */ +fun T.withinTransaction(block: (T) -> R): R { + beginTransaction() + try { + val toReturn = block(this) + setTransactionSuccessful() + return toReturn + } finally { + endTransaction() + } +} + fun SupportSQLiteDatabase.getTableRowCount(table: String): Int { return this.query("SELECT COUNT(*) FROM $table").use { if (it.moveToFirst()) { @@ -224,4 +241,3 @@ class DeleteBuilderPart2( return db.delete(tableName, where, whereArgs) } } - diff --git a/core-util/src/main/java/org/signal/core/util/Serializer.kt b/core-util/src/main/java/org/signal/core/util/Serializer.kt new file mode 100644 index 000000000..d8c5385ed --- /dev/null +++ b/core-util/src/main/java/org/signal/core/util/Serializer.kt @@ -0,0 +1,13 @@ +package org.signal.core.util + +/** + * Generic serialization interface for use with database and store operations. + */ +interface Serializer { + fun serialize(data: T): R + fun deserialize(data: R): T +} + +interface StringSerializer : Serializer + +interface LongSerializer : Serializer diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStoryDistributionListRecord.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStoryDistributionListRecord.java index 91132dc9a..df3e526a1 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStoryDistributionListRecord.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStoryDistributionListRecord.java @@ -74,6 +74,10 @@ public class SignalStoryDistributionListRecord implements SignalRecord { return proto.getAllowsReplies(); } + public boolean isBlockList() { + return proto.getIsBlockList(); + } + @Override public String describeDiff(SignalRecord other) { if (other instanceof SignalStoryDistributionListRecord) { @@ -104,6 +108,10 @@ public class SignalStoryDistributionListRecord implements SignalRecord { diff.add("AllowsReplies"); } + if (this.isBlockList() != that.isBlockList()) { + diff.add("BlockList"); + } + return diff.toString(); } else { return "Different class. " + getClass().getSimpleName() + " | " + other.getClass().getSimpleName(); @@ -166,6 +174,11 @@ public class SignalStoryDistributionListRecord implements SignalRecord { return this; } + public Builder setIsBlockList(boolean isBlockList) { + builder.setIsBlockList(isBlockList); + return this; + } + public SignalStoryDistributionListRecord build() { return new SignalStoryDistributionListRecord(id, builder.build()); } diff --git a/libsignal/service/src/main/proto/StorageService.proto b/libsignal/service/src/main/proto/StorageService.proto index 8202330d5..fa56dd182 100644 --- a/libsignal/service/src/main/proto/StorageService.proto +++ b/libsignal/service/src/main/proto/StorageService.proto @@ -165,4 +165,5 @@ message StoryDistributionListRecord { repeated string recipientUuids = 3; uint64 deletedAtTimestamp = 4; bool allowsReplies = 5; + bool isBlockList = 6; }