Add new My Story privacy settings.

This commit is contained in:
Cody Henthorne 2022-06-24 10:51:26 -04:00
parent ebc556801e
commit 9bc25132c3
58 changed files with 935 additions and 242 deletions

View file

@ -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())
}
}

View file

@ -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();

View file

@ -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<T : PreferenceModel<T>>(itemView: View) : Ma
class TextPreferenceViewHolder(itemView: View) : PreferenceViewHolder<TextPreference>(itemView)
class LearnMoreTextPreferenceViewHolder(itemView: View) : PreferenceViewHolder<LearnMoreTextPreference>(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<ClickPreference>(itemView) {
override fun bind(model: ClickPreference) {
super.bind(model)

View file

@ -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<TextPreference>(title = title, summary = summary)
class LearnMoreTextPreference(
override val title: DSLSettingsText?,
override val summary: DSLSettingsText?,
val onClick: () -> Unit
) : PreferenceModel<LearnMoreTextPreference>()
class DividerPreference : PreferenceModel<DividerPreference>() {
override fun areItemsTheSame(newItem: DividerPreference) = true
}

View file

@ -73,6 +73,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
private final ItemClickListener clickListener;
private final GlideRequests glideRequests;
private final Set<RecipientId> currentContacts;
private final int checkboxResource;
private final SelectedContactSet selectedContacts = new SelectedContactSet();
@ -205,14 +206,16 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
@Nullable Cursor cursor,
@Nullable ItemClickListener clickListener,
boolean multiSelect,
@NonNull Set<RecipientId> currentContacts)
@NonNull Set<RecipientId> 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<ViewH
@Override
public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) {
if (viewType == VIEW_TYPE_CONTACT) {
return new ContactViewHolder(layoutInflater.inflate(R.layout.contact_selection_list_item, parent, false), clickListener);
View view = layoutInflater.inflate(R.layout.contact_selection_list_item, parent, false);
view.findViewById(R.id.check_box).setBackgroundResource(checkboxResource);
return new ContactViewHolder(view, clickListener);
} else {
return new DividerViewHolder(layoutInflater.inflate(R.layout.contact_selection_list_divider, parent, false));
}

View file

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.contacts.selection
import android.os.Bundle
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.RecipientId
@ -15,7 +16,8 @@ data class ContactSelectionArguments(
val canSelectSelf: Boolean = selectionLimits == null,
val displayChips: Boolean = true,
val recyclerPadBottom: Int = -1,
val recyclerChildClipping: Boolean = true
val recyclerChildClipping: Boolean = true,
val checkboxResource: Int = R.drawable.contact_selection_checkbox
) {
fun toArgumentBundle(): Bundle {
@ -29,6 +31,7 @@ data class ContactSelectionArguments(
putBoolean(DISPLAY_CHIPS, displayChips)
putInt(RV_PADDING_BOTTOM, recyclerPadBottom)
putBoolean(RV_CLIP, recyclerChildClipping)
putInt(CHECKBOX_RESOURCE, checkboxResource)
putParcelableArrayList(CURRENT_SELECTION, ArrayList(currentSelection))
}
}
@ -44,5 +47,6 @@ data class ContactSelectionArguments(
const val DISPLAY_CHIPS = "display_chips"
const val RV_PADDING_BOTTOM = "recycler_view_padding_bottom"
const val RV_CLIP = "recycler_view_clipping"
const val CHECKBOX_RESOURCE = "checkbox_resource"
}
}

View file

@ -47,7 +47,7 @@ import org.thoughtcrime.securesms.stories.Stories.getHeaderAction
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.FullscreenHelper

View file

@ -6,13 +6,19 @@ import android.database.Cursor
import androidx.core.content.contentValuesOf
import org.signal.core.util.CursorUtil
import org.signal.core.util.SqlUtil
import org.signal.core.util.delete
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireObject
import org.signal.core.util.requireString
import org.signal.core.util.select
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyData
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.DistributionListRecord
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.recipients.RecipientId
@ -35,6 +41,9 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si
@JvmField
val CREATE_TABLE: Array<String> = arrayOf(ListTable.CREATE_TABLE, MembershipTable.CREATE_TABLE)
@JvmField
val CREATE_INDEXES: Array<String> = 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<DistributionListPartialRecord> {
return getAllListsForContactSelectionUiCursor(query, includeMyStory)?.use {
val results = mutableListOf<DistributionListPartialRecord>()
while (it.moveToNext()) {
results.add(
DistributionListPartialRecord(
id = DistributionListId.from(CursorUtil.requireLong(it, ListTable.ID)),
name = CursorUtil.requireString(it, ListTable.NAME),
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<RecipientId> {
return readableDatabase
.select(ListTable.RECIPIENT_ID)
.from(ListTable.TABLE_NAME)
.run()
.readToList { cursor -> RecipientId.from(cursor.requireLong(ListTable.RECIPIENT_ID)) }
}
fun getCustomListsForUi(): List<DistributionListPartialRecord> {
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<DistributionListPartialRecord>()
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<RecipientId> {
if (listId == DistributionListId.MY_STORY) {
val blockedMembers = getRawMembers(listId).toSet()
lateinit var privacyMode: DistributionListPrivacyMode
lateinit var rawMembers: List<RecipientId>
return SignalDatabase.recipients.getSignalContacts(false)?.use {
val result = mutableListOf<RecipientId>()
while (it.moveToNext()) {
val id = RecipientId.from(CursorUtil.requireLong(it, RecipientDatabase.ID))
if (!blockedMembers.contains(id)) {
result.add(id)
}
}
result
} ?: emptyList()
} else {
return getRawMembers(listId)
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<RecipientId> {
fun getRawMembers(listId: DistributionListId, privacyMode: DistributionListPrivacyMode): List<RecipientId> {
val members = mutableListOf<RecipientId>()
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()
}
}

View file

@ -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<RecipientId>) {
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)
}

View file

@ -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<Set<Runnable>> POST_TRANSACTION_TASKS = new ThreadLocal<>();
private static final ThreadLocal<Set<Runnable>> PENDING_POST_SUCCESSFUL_TRANSACTION_TASKS;
private static final ThreadLocal<Set<Runnable>> 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<Runnable> getPostTransactionTasks() {
Set<Runnable> tasks = POST_TRANSACTION_TASKS.get();
private @NonNull Set<Runnable> getPendingPostSuccessfulTransactionTasks() {
Set<Runnable> 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<Runnable> getPostSuccessfulTransactionTasks() {
Set<Runnable> 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<Runnable> tasks = getPostTransactionTasks();
for (Runnable r : new HashSet<>(tasks)) {
r.run();
}
Set<Runnable> pendingTasks = getPendingPostSuccessfulTransactionTasks();
Set<Runnable> 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<Runnable> tasks = getPostSuccessfulTransactionTasks();
for (Runnable r : new HashSet<>(tasks)) {
r.run();
}
tasks.clear();
}
public void setTransactionSuccessful() {

View file

@ -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)

View file

@ -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

View file

@ -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);

View file

@ -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
)

View file

@ -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
)

View file

@ -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<DistributionListPrivacyMode> {
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")
}
}
}
}

View file

@ -11,7 +11,16 @@ data class DistributionListRecord(
val name: String,
val distributionId: DistributionId,
val allowsReplies: Boolean,
val rawMembers: List<RecipientId>,
val members: List<RecipientId>,
val deletedAtTimestamp: Long,
val isUnknown: Boolean
)
val isUnknown: Boolean,
val privacyMode: DistributionListPrivacyMode
) {
fun getMembersToSync(): List<RecipientId> {
return when (privacyMode) {
DistributionListPrivacyMode.ALL -> emptyList()
else -> rawMembers
}
}
}

View file

@ -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());

View file

@ -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<Long> {
@ -26,6 +27,10 @@ internal fun SignalStoreValues.blobValue(key: String, default: ByteArray): Signa
return BlobValue(key, default, this.store)
}
internal fun <T : Any?> SignalStoreValues.enumValue(key: String, default: T, serializer: LongSerializer<T>): SignalStoreValueDelegate<T> {
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<T>(private val key: String, private val default: T, private val serializer: LongSerializer<T>, store: KeyValueStore) : SignalStoreValueDelegate<T>(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()
}
}

View file

@ -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);
}
<T> List<T> getList(@NonNull String key, @NonNull Serializer<T> serializer) {
<T> List<T> getList(@NonNull String key, @NonNull StringSerializer<T> 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();
}
<T> void putList(@NonNull String key, @NonNull List<T> values, @NonNull Serializer<T> serializer) {
<T> void putList(@NonNull String key, @NonNull List<T> values, @NonNull StringSerializer<T> 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<T> {
@NonNull String serialize(@NonNull T data);
T deserialize(@NonNull String data);
}
}

View file

@ -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<StorySend> {
private object StorySendSerializer : StringSerializer<StorySend> {
override fun serialize(data: StorySend): String {
return JSONObject()

View file

@ -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

View file

@ -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

View file

@ -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;
}

View file

@ -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<SyncDistributionListsMigrationJob> {
@Override
public @NonNull SyncDistributionListsMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) {
return new SyncDistributionListsMigrationJob(parameters);
}
}
}

View file

@ -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<RecipientId>, Databas
private static final char DELIMITER = ',';
public static final RecipientId UNKNOWN = RecipientId.from(UNKNOWN_ID);
public static final LongSerializer<RecipientId> SERIALIZER = new Serializer();
private final long id;
@ -212,4 +214,16 @@ public class RecipientId implements Parcelable, Comparable<RecipientId>, Databas
private static class InvalidLongRecipientIdError extends AssertionError {}
private static class InvalidStringRecipientIdError extends AssertionError {}
private static class Serializer implements LongSerializer<RecipientId> {
@Override
public Long serialize(RecipientId data) {
return data.toLong();
}
@Override
public @NonNull RecipientId deserialize(Long data) {
return RecipientId.from(data);
}
}
}

View file

@ -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();
}

View file

@ -89,9 +89,10 @@ public class StoryDistributionListRecordProcessor extends DefaultStorageRecordPr
List<SignalServiceAddress> 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<SignalServiceAddress> recipients,
@NonNull List<SignalServiceAddress> 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();
}
}

View file

@ -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()

View file

@ -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())
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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)

View file

@ -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),

View file

@ -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<Int> {
fun getPrivacyState(): Single<MyStoryPrivacyState> {
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())
}

View file

@ -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
)

View file

@ -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 <T : ViewModel> create(modelClass: Class<T>): 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() }
}
}
}

View file

@ -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<TextView>(R.id.text_1).text = SpanUtil.boldSubstring(getString(R.string.SignalConnectionsBottomSheet__signal_connections_are_people), getString(R.string.SignalConnectionsBottomSheet___signal_connections))
return view
}
}

View file

@ -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
}

View file

@ -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() {

View file

@ -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()

View file

@ -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<RecipientId>) {
fun getRecord(distributionListId: DistributionListId): Single<DistributionListRecord> {
return Single.fromCallable {
SignalDatabase.distributionLists.getList(distributionListId) ?: error("Record does not exist.")
}.subscribeOn(Schedulers.io())
}
fun updateDistributionListMembership(distributionListRecord: DistributionListRecord, recipients: Set<RecipientId>) {
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<Set<RecipientId>> {
return Single.fromCallable {
SignalDatabase.distributionLists.getRawMembers(distributionListId).toSet()
}.subscribeOn(Schedulers.io())
}
fun getAllSignalContacts(): Single<Set<RecipientId>> {
return Single.fromCallable {
SignalDatabase.recipients.getSignalContacts(false)?.use {

View file

@ -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<RecipientId> = emptySet()
)

View file

@ -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<RecipientId>())
private val store = Store(BaseStoryRecipientSelectionState(distributionListId))
private val subject = PublishSubject.create<Action>()
private val disposable = CompositeDisposable()
var actionObservable: Observable<Action> = subject
var state: LiveData<Set<RecipientId>> = store.stateLiveData
var state: LiveData<BaseStoryRecipientSelectionState> = 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))
}
}

View file

@ -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;
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android" android:enterFadeDuration="250" android:exitFadeDuration="250">
<item android:state_checked="true" android:state_enabled="false">
<layer-list>
<item>
<shape android:shape="oval">
<solid android:color="@color/signal_colorOutline" />
<stroke android:width="1.5dp" android:color="@color/signal_colorOnPrimary" />
</shape>
</item>
<item android:drawable="@drawable/ic_x_error_inverse_16" android:top="2dp" android:bottom="2dp" android:left="2dp" android:right="2dp"/>
</layer-list>
</item>
<item android:state_checked="true">
<layer-list>
<item>
<shape android:shape="oval">
<solid android:color="@color/signal_colorErrorContainerInverse" />
</shape>
</item>
<item android:drawable="@drawable/ic_x_error_inverse_16" android:top="2dp" android:bottom="2dp" android:left="2dp" android:right="2dp"/>
</layer-list>
</item>
<item android:state_checked="false">
<shape android:shape="oval">
<stroke android:width="1.5dp" android:color="@color/signal_colorOutline" />
</shape>
</item>
</selector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:fillColor="@color/signal_colorErrorInverse"
android:pathData="M12.354,4.354l-0.708,-0.708l-3.646,3.647l-3.646,-3.647l-0.708,0.708l3.647,3.646l-3.647,3.646l0.708,0.708l3.646,-3.647l3.646,3.647l0.708,-0.708l-3.647,-3.646l3.647,-3.646z"/>
</vector>

View file

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/dsl_preference_item_background"
android:minHeight="56dp">
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:importantForAccessibility="no"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@drawable/ic_advanced_24"
tools:visibility="visible" />
<org.thoughtcrime.securesms.util.views.LearnMoreTextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:textAlignment="viewStart"
android:textAppearance="@style/Signal.Text.Body"
app:layout_constraintBottom_toTopOf="@id/summary"
app:layout_constraintEnd_toStartOf="@id/icon_end"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
app:layout_goneMarginBottom="16dp"
app:layout_goneMarginStart="@dimen/dsl_settings_gutter"
tools:text="Message font size" />
<org.thoughtcrime.securesms.util.views.LearnMoreTextView
android:id="@+id/summary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:layout_marginBottom="16dp"
android:lineSpacingExtra="4sp"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.Signal.Body2"
android:textColor="@color/text_color_secondary_enabled_selector"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/icon_end"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintTop_toBottomOf="@id/title"
app:layout_goneMarginStart="@dimen/dsl_settings_gutter"
app:layout_goneMarginTop="16dp"
tools:text="Some random text to get stuff onto more than one line but not absurdly long like lorem/random"
tools:visibility="visible" />
<ImageView
android:id="@+id/icon_end"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:importantForAccessibility="no"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@drawable/ic_advanced_24"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -7,16 +7,16 @@
android:background="@drawable/dsl_preference_item_background"
android:minHeight="56dp">
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
<RadioButton
android:id="@+id/radio_widget"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:importantForAccessibility="no"
android:clickable="false"
android:theme="@style/Signal.Widget.CompoundButton.RadioButton"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@drawable/ic_advanced_24" />
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/title"
@ -27,8 +27,8 @@
android:layout_marginEnd="24dp"
android:textAppearance="@style/Signal.Text.Body"
app:layout_constraintBottom_toTopOf="@id/summary"
app:layout_constraintEnd_toStartOf="@id/radio_widget"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintEnd_toStartOf="@id/icon"
app:layout_constraintStart_toEndOf="@id/radio_widget"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
app:layout_goneMarginBottom="16dp"
@ -46,23 +46,23 @@
android:textAppearance="@style/TextAppearance.Signal.Body2"
android:textColor="@color/signal_text_secondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/radio_widget"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintEnd_toStartOf="@id/icon"
app:layout_constraintStart_toEndOf="@id/radio_widget"
app:layout_constraintTop_toBottomOf="@id/title"
app:layout_goneMarginStart="@dimen/dsl_settings_gutter"
app:layout_goneMarginTop="16dp"
tools:text="Some random text to get stuff onto more than one line but not absurdly long like lorem/random"
tools:visibility="visible" />
<RadioButton
android:id="@+id/radio_widget"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:clickable="false"
android:theme="@style/Signal.Widget.CompoundButton.RadioButton"
android:importantForAccessibility="no"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@drawable/ic_advanced_24" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -5,8 +5,12 @@
android:id="@+id/my_story_settings"
app:startDestination="@id/myStorySettings">
<fragment
android:id="@+id/hideStoryFromFragment"
android:name="org.thoughtcrime.securesms.stories.settings.hide.HideStoryFromFragment"
android:id="@+id/allExceptFragment"
android:name="org.thoughtcrime.securesms.stories.settings.privacy.AllExceptFragment"
android:label="hide_story_from_fragment" />
<fragment
android:id="@+id/onlyShareWithFragment"
android:name="org.thoughtcrime.securesms.stories.settings.privacy.OnlyShareWithFragment"
android:label="hide_story_from_fragment" />
<fragment
android:id="@+id/myStorySettings"
@ -16,8 +20,11 @@
android:id="@+id/action_myStorySettings_to_signalConnectionsBottomSheet"
app:destination="@id/signalConnectionsBottomSheet" />
<action
android:id="@+id/action_myStorySettings_to_hideStoryFromFragment"
app:destination="@id/hideStoryFromFragment" />
android:id="@+id/action_myStorySettings_to_allExceptFragment"
app:destination="@id/allExceptFragment" />
<action
android:id="@+id/action_myStorySettings_to_onlyShareWithFragment"
app:destination="@id/onlyShareWithFragment" />
</fragment>
<dialog
android:id="@+id/signalConnectionsBottomSheet"

View file

@ -43,6 +43,10 @@
<color name="signal_colorNeutralInverse">@color/signal_dark_colorNeutralInverse</color>
<color name="signal_colorNeutralVariantInverse">@color/signal_dark_colorNeutralVariantInverse</color>
<!-- Inverses -->
<color name="signal_colorErrorInverse">@color/signal_light_colorError</color>
<color name="signal_colorErrorContainerInverse">@color/signal_light_colorErrorContainer</color>
<!-- Core color alpha variants -->
<color name="signal_colorSecondaryContainer_12">#1F414659</color>
<color name="signal_colorSurface_60">#991B1C1F</color>

View file

@ -43,6 +43,10 @@
<color name="signal_colorNeutralInverse">@color/signal_light_colorNeutralInverse</color>
<color name="signal_colorNeutralVariantInverse">@color/signal_light_colorNeutralVariantInverse</color>
<!-- Inverses -->
<color name="signal_colorErrorInverse">@color/signal_dark_colorError</color>
<color name="signal_colorErrorContainerInverse">@color/signal_dark_colorErrorContainer</color>
<!-- Core color alpha variants -->
<color name="signal_colorSecondaryContainer_12">#1FDCE5F9</color>
<color name="signal_colorSurface_60">#99FBFCFF</color>

View file

@ -4638,11 +4638,30 @@
<string name="MyStorySettingsFragment__who_can_see_this_story">Who can see this story</string>
<!-- Clickable option for selecting people to hide your story from -->
<string name="MyStorySettingsFragment__hide_story_from">Hide story from</string>
<!-- Summary of clickable option displaying how many people you have hidden your story from -->
<!-- Privacy setting title for sending stories to all your signal connections -->
<string name="MyStorySettingsFragment__all_signal_connections">All Signal connections</string>
<!-- Privacy setting description for sending stories to all your signal connections -->
<string name="MyStorySettingsFragment__share_with_all_connections">Share with all connections</string>
<!-- Privacy setting title for sending stories to all except the specified connections -->
<string name="MyStorySettingsFragment__all_signal_connections_except">All Signal connections except…</string>
<!-- Privacy setting description for sending stories to all except the specified connections -->
<string name="MyStorySettingsFragment__hide_your_story_from_specific_people">Hide your story from specific people</string>
<!-- Summary of clickable option displaying how many people you have excluded from your story -->
<plurals name="MyStorySettingsFragment__d_people_excluded">
<item quantity="one">%1$d person excluded</item>
<item quantity="other">%1$d people excluded</item>
</plurals>
<!-- Privacy setting title for only sharing your story with specified connections -->
<string name="MyStorySettingsFragment__only_share_with">Only share with…</string>
<!-- Privacy setting description for only sharing your story with specified connections -->
<string name="MyStorySettingsFragment__only_share_with_selected_people">Only share with selected people</string>
<!-- Summary of clickable option displaying how many people you have included to send to in your story -->
<plurals name="MyStorySettingsFragment__d_people">
<item quantity="one">%1$d person</item>
<item quantity="other">%1$d people</item>
</plurals>
<!-- My story privacy fine print about what the privacy settings are for -->
<string name="MyStorySettingsFragment__choose_who_can_view_your_story">Choose who can view your story. Changes won\'t affect stories you\'ve already sent.</string>
<!-- Section header for options related to replies and reactions -->
<string name="MyStorySettingsFragment__replies_amp_reactions">Replies &amp; reactions</string>
<!-- Switchable option for allowing replies and reactions on your stories -->
@ -4651,10 +4670,10 @@
<string name="MyStorySettingsFragment__let_people_who_can_view_your_story_react_and_reply">Let people who can view your story react and reply</string>
<!-- Note about default sharing -->
<string name="MyStorySettingsFragment__hide_your_story_from">Hide your story from specific people. By default, your story is shared with your %1$s</string>
<!-- Signal connections linked text that opens the Signal Connections sheet -->
<string name="MyStorySettingsFragment__signal_connections">Signal connections.</string>
<!-- Signal connections bolded text in the Signal Connections sheet -->
<string name="SignalConnectionsBottomSheet___signal_connections">Signal Connections</string>
<!-- Displayed at the top of the signal connections sheet. Please remember to insert strong tag as required. -->
<string name="SignalConnectionsBottomSheet__signal_connections_are_people"><strong>Signal Connections</strong> are people you\'ve chosen to trust, either by:</string>
<string name="SignalConnectionsBottomSheet__signal_connections_are_people">Signal Connections are people you\'ve chosen to trust, either by:</string>
<!-- Signal connections sheet bullet point 1 -->
<string name="SignalConnectionsBottomSheet__starting_a_conversation">Starting a conversation</string>
<!-- Signal connections sheet bullet point 2 -->
@ -4703,8 +4722,10 @@
<string name="TextStoryPostSendFragment__search">Search</string>
<!-- Toast shown when an unexpected error occurs while sending a text story -->
<string name="TextStoryPostSendFragment__an_unexpected_error_occurred_try_again">An unexpected error occurred</string>
<!-- Title for screen allowing user to hide "My Story" entries from specific people -->
<string name="HideStoryFromFragment__hide_story_from">Hide story from…</string>
<!-- Title for screen allowing user to exclude "My Story" entries from specific people -->
<string name="ChangeMyStoryMembershipFragment__all_except">All except…</string>
<!-- Title for screen allowing user to only share "My Story" entries with specific people -->
<string name="ChangeMyStoryMembershipFragment__only_share_with">Only share with…</string>
<!-- Done button label for hide story from screen -->
<string name="HideStoryFromFragment__done">Done</string>
<!-- Dialog title for first time adding something to a story -->

View file

@ -55,11 +55,22 @@ fun Cursor.isNull(column: String): Boolean {
return CursorUtil.isNull(this, column)
}
inline fun <T> Cursor.readToList(mapper: (Cursor) -> T): List<T> {
fun <T> Cursor.requireObject(column: String, serializer: LongSerializer<T>): T {
return serializer.deserialize(CursorUtil.requireLong(this, column))
}
fun <T> Cursor.requireObject(column: String, serializer: StringSerializer<T>): T {
return serializer.deserialize(CursorUtil.requireString(this, column))
}
inline fun <T> Cursor.readToList(predicate: (T) -> Boolean = { true }, mapper: (Cursor) -> T): List<T> {
val list = mutableListOf<T>()
use {
while (moveToNext()) {
list += mapper(this)
val record = mapper(this)
if (predicate(record)) {
list += mapper(this)
}
}
}
return list

View file

@ -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 : SupportSQLiteDatabase, R> 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)
}
}

View file

@ -0,0 +1,13 @@
package org.signal.core.util
/**
* Generic serialization interface for use with database and store operations.
*/
interface Serializer<T, R> {
fun serialize(data: T): R
fun deserialize(data: R): T
}
interface StringSerializer<T> : Serializer<T, String>
interface LongSerializer<T> : Serializer<T, Long>

View file

@ -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());
}

View file

@ -165,4 +165,5 @@ message StoryDistributionListRecord {
repeated string recipientUuids = 3;
uint64 deletedAtTimestamp = 4;
bool allowsReplies = 5;
bool isBlockList = 6;
}