Copione merged onto master

master
blallo 2020-10-08 00:00:24 +02:00
commit c55f460a25
124 changed files with 2831 additions and 851 deletions

View File

@ -80,8 +80,8 @@ protobuf {
}
}
def canonicalVersionCode = 715
def canonicalVersionName = "4.72.6"
def canonicalVersionCode = 716
def canonicalVersionName = "4.73.0"
def postFixSize = 10
def abiPostFix = ['universal' : 0,
@ -132,9 +132,10 @@ android {
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
buildConfigField "String", "KBS_ENCLAVE_NAME", "\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\""
buildConfigField "String", "KBS_SERVICE_ID", "\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\""
buildConfigField "String", "KBS_MRENCLAVE", "\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\""
buildConfigField "KbsEnclave", "KBS_ENCLAVE", "new KbsEnclave(\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\"," +
"\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")";
buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new KbsEnclave[0]"
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X0=\""
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
@ -214,8 +215,10 @@ android {
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\""
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
buildConfigField "String", "CDS_MRENCLAVE", "\"bd123560b01c8fa92935bc5ae15cd2064e5c45215f23f0bd40364d521329d2ad\""
buildConfigField "String", "KBS_ENCLAVE_NAME", "\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\""
buildConfigField "String", "KBS_SERVICE_ID", "\"038c40bbbacdc873caa81ac793bb75afde6dfe436a99ab1f15e3f0cbb7434ced\""
buildConfigField "KbsEnclave", "KBS_ENCLAVE", "new KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " +
"\"038c40bbbacdc873caa81ac793bb75afde6dfe436a99ab1f15e3f0cbb7434ced\", " +
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")"
buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new KbsEnclave[0]"
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=\""
}

View File

@ -223,6 +223,14 @@
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="sgnl"
android:host="signal.group" />
</intent-filter>
<intent-filter android:autoVerify="true"
tools:targetApi="23">
<action android:name="android.intent.action.VIEW" />

View File

@ -0,0 +1,49 @@
package org.thoughtcrime.securesms;
import androidx.annotation.NonNull;
import java.util.Objects;
/**
* Used in our {@link BuildConfig} to tie together the various attributes of a KBS instance. This
* is sitting in the root directory so it can be accessed by the build config.
*/
public final class KbsEnclave {
private final String enclaveName;
private final String serviceId;
private final String mrEnclave;
public KbsEnclave(@NonNull String enclaveName, @NonNull String serviceId, @NonNull String mrEnclave) {
this.enclaveName = enclaveName;
this.serviceId = serviceId;
this.mrEnclave = mrEnclave;
}
public @NonNull String getMrEnclave() {
return mrEnclave;
}
public @NonNull String getEnclaveName() {
return enclaveName;
}
public @NonNull String getServiceId() {
return serviceId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
KbsEnclave enclave = (KbsEnclave) o;
return enclaveName.equals(enclave.enclaveName) &&
serviceId.equals(enclave.serviceId) &&
mrEnclave.equals(enclave.mrEnclave);
}
@Override
public int hashCode() {
return Objects.hash(enclaveName, serviceId, mrEnclave);
}
}

View File

@ -815,10 +815,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
} else {
menu.findItem(R.id.menu_distribution_conversation).setChecked(true);
}
inflater.inflate(R.menu.conversation_active_group_options, menu);
} else if (isActiveV2Group || isActiveGroup) {
inflater.inflate(R.menu.conversation_active_group_options, menu);
}
inflater.inflate(R.menu.conversation_active_group_options, menu);
}
inflater.inflate(R.menu.conversation, menu);
@ -865,7 +864,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
if (isActiveV2Group) {
hideMenuItem(menu, R.id.menu_mute_notifications);
hideMenuItem(menu, R.id.menu_conversation_settings);
} else if (isActiveGroup) {
} else if (isGroupConversation()) {
hideMenuItem(menu, R.id.menu_conversation_settings);
}
@ -2227,6 +2226,10 @@ public class ConversationActivity extends PassphraseRequiredActivity
private Drafts getDraftsForCurrentState() {
Drafts drafts = new Drafts();
if (recipient.get().isGroup() && !recipient.get().isActiveGroup()) {
return drafts;
}
if (!Util.isEmpty(composeText)) {
drafts.add(new Draft(Draft.TEXT, composeText.getTextTrimmed().toString()));
List<Mention> draftMentions = composeText.getMentions();
@ -3036,7 +3039,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
messageRequestBottomView.setBlockOnClickListener(v -> onMessageRequestBlockClicked(viewModel));
messageRequestBottomView.setUnblockOnClickListener(v -> onMessageRequestUnblockClicked(viewModel));
viewModel.getRecipient().observe(this, this::presentMessageRequestBottomViewTo);
viewModel.getMessageData().observe(this, this::presentMessageRequestBottomViewTo);
viewModel.getMessageRequestDisplayState().observe(this, this::presentMessageRequestDisplayState);
viewModel.getFailures().observe(this, this::showGroupChangeErrorToast);
viewModel.getMessageRequestStatus().observe(this, status -> {
@ -3081,7 +3084,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
{
reactionOverlay.setOnToolbarItemClickedListener(toolbarListener);
reactionOverlay.setOnHideListener(onHideListener);
reactionOverlay.show(this, maskTarget, messageRecord, inputAreaHeight());
reactionOverlay.show(this, maskTarget, recipient.get(), messageRecord, inputAreaHeight());
}
@Override
@ -3449,10 +3452,10 @@ public class ConversationActivity extends PassphraseRequiredActivity
}
}
private void presentMessageRequestBottomViewTo(@Nullable Recipient recipient) {
if (recipient == null) return;
private void presentMessageRequestBottomViewTo(@Nullable MessageRequestViewModel.MessageData messageData) {
if (messageData == null) return;
messageRequestBottomView.setRecipient(recipient);
messageRequestBottomView.setMessageData(messageData);
}
private static class KeyboardImageDetails {

View File

@ -226,7 +226,10 @@ public class ConversationFragment extends LoggingFragment {
new ConversationItemSwipeCallback(
conversationMessage -> actionMode == null &&
MenuState.canReplyToMessage(MenuState.isActionMessage(conversationMessage.getMessageRecord()), conversationMessage.getMessageRecord(), messageRequestViewModel.shouldShowMessageRequest()),
MenuState.canReplyToMessage(recipient.get(),
MenuState.isActionMessage(conversationMessage.getMessageRecord()),
conversationMessage.getMessageRecord(),
messageRequestViewModel.shouldShowMessageRequest()),
this::handleReplyMessage
).attachToRecyclerView(list);
@ -573,7 +576,7 @@ public class ConversationFragment extends LoggingFragment {
return;
}
MenuState menuState = MenuState.getMenuState(Stream.of(messages).map(ConversationMessage::getMessageRecord).collect(Collectors.toSet()), messageRequestViewModel.shouldShowMessageRequest());
MenuState menuState = MenuState.getMenuState(recipient.get(), Stream.of(messages).map(ConversationMessage::getMessageRecord).collect(Collectors.toSet()), messageRequestViewModel.shouldShowMessageRequest());
menu.findItem(R.id.menu_context_forward).setVisible(menuState.shouldShowForwardAction());
menu.findItem(R.id.menu_context_reply).setVisible(menuState.shouldShowReplyAction());
@ -662,53 +665,7 @@ public class ConversationFragment extends LoggingFragment {
private void handleDeleteMessages(final Set<ConversationMessage> conversationMessages) {
Set<MessageRecord> messageRecords = Stream.of(conversationMessages).map(ConversationMessage::getMessageRecord).collect(Collectors.toSet());
if (FeatureFlags.remoteDelete()) {
buildRemoteDeleteConfirmationDialog(messageRecords).show();
} else {
buildLegacyDeleteConfirmationDialog(messageRecords).show();
}
}
private AlertDialog.Builder buildLegacyDeleteConfirmationDialog(Set<MessageRecord> messageRecords) {
int messagesCount = messageRecords.size();
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
builder.setIconAttribute(R.attr.dialog_alert_icon);
builder.setTitle(getActivity().getResources().getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messagesCount, messagesCount));
builder.setMessage(getActivity().getResources().getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messagesCount, messagesCount));
builder.setCancelable(true);
builder.setPositiveButton(R.string.delete, (dialog, which) -> {
new ProgressDialogAsyncTask<Void, Void, Void>(getActivity(),
R.string.ConversationFragment_deleting,
R.string.ConversationFragment_deleting_messages)
{
@Override
protected Void doInBackground(Void... voids) {
for (MessageRecord messageRecord : messageRecords) {
boolean threadDeleted;
if (messageRecord.isMms()) {
threadDeleted = DatabaseFactory.getMmsDatabase(getActivity()).deleteMessage(messageRecord.getId());
} else {
threadDeleted = DatabaseFactory.getSmsDatabase(getActivity()).deleteMessage(messageRecord.getId());
}
if (threadDeleted) {
threadId = -1;
conversationViewModel.clearThreadId();
messageCountsViewModel.clearThreadId();
listener.setThreadId(threadId);
}
}
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
});
builder.setNegativeButton(android.R.string.cancel, null);
return builder;
buildRemoteDeleteConfirmationDialog(messageRecords).show();
}
private AlertDialog.Builder buildRemoteDeleteConfirmationDialog(Set<MessageRecord> messageRecords) {
@ -769,7 +726,7 @@ public class ConversationFragment extends LoggingFragment {
deleteForEveryone.run();
} else {
new AlertDialog.Builder(requireActivity())
.setMessage(R.string.ConversationFragment_this_message_will_be_permanently_deleted_for_everyone)
.setMessage(R.string.ConversationFragment_this_message_will_be_deleted_for_everyone_in_the_conversation)
.setPositiveButton(R.string.ConversationFragment_delete_for_everyone, (dialog, which) -> {
SignalStore.uiHints().markHasConfirmedDeleteForEveryoneOnce();
deleteForEveryone.run();
@ -1223,11 +1180,12 @@ public class ConversationFragment extends LoggingFragment {
MessageRecord messageRecord = conversationMessage.getMessageRecord();
if (messageRecord.isSecure() &&
!messageRecord.isRemoteDelete() &&
!messageRecord.isUpdate() &&
!recipient.get().isBlocked() &&
!messageRequestViewModel.shouldShowMessageRequest() &&
if (messageRecord.isSecure() &&
!messageRecord.isRemoteDelete() &&
!messageRecord.isUpdate() &&
!recipient.get().isBlocked() &&
!messageRequestViewModel.shouldShowMessageRequest() &&
(!recipient.get().isGroup() || recipient.get().isActiveGroup()) &&
((ConversationAdapter) list.getAdapter()).getSelectedItems().isEmpty())
{
isReacting = true;

View File

@ -5,9 +5,7 @@ import android.animation.AnimatorSet;
import android.app.Activity;
import android.content.Context;
import android.graphics.PointF;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.util.AttributeSet;
import android.view.HapticFeedbackConstants;
@ -24,7 +22,6 @@ import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.core.content.ContextCompat;
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
import com.annimon.stream.Stream;
@ -60,6 +57,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
private final PointF lastSeenDownPoint = new PointF();
private Activity activity;
private Recipient conversationRecipient;
private MessageRecord messageRecord;
private OverlayState overlayState = OverlayState.HIDDEN;
@ -145,15 +143,21 @@ public final class ConversationReactionOverlay extends RelativeLayout {
maskView.setTargetParentTranslationY(translationY);
}
public void show(@NonNull Activity activity, @NonNull View maskTarget, @NonNull MessageRecord messageRecord, int maskPaddingBottom) {
public void show(@NonNull Activity activity,
@NonNull View maskTarget,
@NonNull Recipient conversationRecipient,
@NonNull MessageRecord messageRecord,
int maskPaddingBottom)
{
if (overlayState != OverlayState.HIDDEN) {
return;
}
this.messageRecord = messageRecord;
overlayState = OverlayState.UNINITAILIZED;
selected = -1;
this.messageRecord = messageRecord;
this.conversationRecipient = conversationRecipient;
overlayState = OverlayState.UNINITAILIZED;
selected = -1;
setupToolbarMenuItems();
setupSelectedEmoji();
@ -498,7 +502,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
}
private void setupToolbarMenuItems() {
MenuState menuState = MenuState.getMenuState(Collections.singleton(messageRecord), false);
MenuState menuState = MenuState.getMenuState(conversationRecipient, Collections.singleton(messageRecord), false);
toolbar.getMenu().findItem(R.id.action_copy).setVisible(menuState.shouldShowCopyAction());
toolbar.getMenu().findItem(R.id.action_download).setVisible(menuState.shouldShowSaveAttachmentAction());

View File

@ -5,6 +5,7 @@ import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.Set;
@ -50,7 +51,8 @@ final class MenuState {
return copy;
}
static MenuState getMenuState(@NonNull Set<MessageRecord> messageRecords,
static MenuState getMenuState(@NonNull Recipient conversationRecipient,
@NonNull Set<MessageRecord> messageRecords,
boolean shouldShowMessageRequest)
{
@ -102,20 +104,21 @@ final class MenuState {
((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null)
.shouldShowForwardAction(!actionMessage && !sharedContact && !viewOnce && !remoteDelete)
.shouldShowDetailsAction(!actionMessage)
.shouldShowReplyAction(canReplyToMessage(actionMessage, messageRecord, shouldShowMessageRequest));
.shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest));
}
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText)
.build();
}
static boolean canReplyToMessage(boolean actionMessage, @NonNull MessageRecord messageRecord, boolean isDisplayingMessageRequest) {
return !actionMessage &&
!messageRecord.isRemoteDelete() &&
!messageRecord.isPending() &&
!messageRecord.isFailed() &&
!isDisplayingMessageRequest &&
messageRecord.isSecure() &&
static boolean canReplyToMessage(@NonNull Recipient conversationRecipient, boolean actionMessage, @NonNull MessageRecord messageRecord, boolean isDisplayingMessageRequest) {
return !actionMessage &&
!messageRecord.isRemoteDelete() &&
!messageRecord.isPending() &&
!messageRecord.isFailed() &&
!isDisplayingMessageRequest &&
messageRecord.isSecure() &&
(!conversationRecipient.isGroup() || conversationRecipient.isActiveGroup()) &&
!messageRecord.getRecipient().isBlocked();
}

View File

@ -15,7 +15,6 @@ import net.sqlcipher.database.SQLiteConstraintException;
import net.sqlcipher.database.SQLiteDatabase;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.groups.GroupMasterKey;
import org.signal.zkgroup.profiles.ProfileKey;
import org.signal.zkgroup.profiles.ProfileKeyCredential;
@ -23,6 +22,7 @@ import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.contacts.avatars.ContactColors;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper.RecordUpdate;
import org.thoughtcrime.securesms.storage.StorageSyncModels;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.CursorUtil;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.SqlUtil;
import org.thoughtcrime.securesms.util.StringUtil;
@ -61,6 +62,7 @@ import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
@ -128,7 +130,7 @@ public class RecipientDatabase extends Database {
private static final String IDENTITY_KEY = "identity_key";
private static final String[] RECIPIENT_PROJECTION = new String[] {
UUID, USERNAME, PHONE, EMAIL, GROUP_ID, GROUP_TYPE,
ID, UUID, USERNAME, PHONE, EMAIL, GROUP_ID, GROUP_TYPE,
BLOCKED, MESSAGE_RINGTONE, CALL_RINGTONE, MESSAGE_VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, MESSAGE_EXPIRATION_TIME, REGISTERED,
PROFILE_KEY, PROFILE_KEY_CREDENTIAL,
SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, SYSTEM_CONTACT_URI,
@ -144,20 +146,13 @@ public class RecipientDatabase extends Database {
private static final String[] ID_PROJECTION = new String[]{ID};
private static final String[] SEARCH_PROJECTION = new String[]{ID, SYSTEM_DISPLAY_NAME, PHONE, EMAIL, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, REGISTERED, "COALESCE(" + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ") AS " + SEARCH_PROFILE_NAME, "COALESCE(" + nullIfEmpty(SYSTEM_DISPLAY_NAME) + ", " + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ", " + nullIfEmpty(USERNAME) + ") AS " + SORT_NAME};
public static final String[] SEARCH_PROJECTION_NAMES = new String[]{ID, SYSTEM_DISPLAY_NAME, PHONE, EMAIL, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, REGISTERED, SEARCH_PROFILE_NAME, SORT_NAME};
static final String[] TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
private static final String[] TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
.map(columnName -> TABLE_NAME + "." + columnName)
.toList().toArray(new String[0]);
private static final String[] MENTION_SEARCH_PROJECTION = new String[]{ID, removeWhitespace("COALESCE(" + nullIfEmpty(SYSTEM_DISPLAY_NAME) + ", " + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ", " + nullIfEmpty(USERNAME) + ", " + nullIfEmpty(PHONE) + ")") + " AS " + SORT_NAME};
static final String[] TYPED_RECIPIENT_PROJECTION_NO_ID = Arrays.copyOfRange(TYPED_RECIPIENT_PROJECTION, 1, TYPED_RECIPIENT_PROJECTION.length);
private static final String[] RECIPIENT_FULL_PROJECTION = Stream.of(
new String[] { TABLE_NAME + "." + ID,
TABLE_NAME + "." + STORAGE_PROTO },
TYPED_RECIPIENT_PROJECTION,
new String[] {
IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.VERIFIED + " AS " + IDENTITY_STATUS,
IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.IDENTITY_KEY + " AS " + IDENTITY_KEY
}).flatMap(Stream::of).toArray(String[]::new);
private static final String[] MENTION_SEARCH_PROJECTION = new String[]{ID, removeWhitespace("COALESCE(" + nullIfEmpty(SYSTEM_DISPLAY_NAME) + ", " + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ", " + nullIfEmpty(USERNAME) + ", " + nullIfEmpty(PHONE) + ")") + " AS " + SORT_NAME};
public static final String[] CREATE_INDEXS = new String[] {
"CREATE INDEX IF NOT EXISTS recipient_dirty_index ON " + TABLE_NAME + " (" + DIRTY + ");",
@ -595,11 +590,10 @@ public class RecipientDatabase extends Database {
public @NonNull RecipientSettings getRecipientSettings(@NonNull RecipientId id) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
String table = TABLE_NAME + " LEFT OUTER JOIN " + IdentityDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + ID + " = " + IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.RECIPIENT_ID;
String query = TABLE_NAME + "." + ID + " = ?";
String query = ID + " = ?";
String[] args = new String[] { id.serialize() };
try (Cursor cursor = database.query(table, RECIPIENT_FULL_PROJECTION, query, args, null, null, null)) {
try (Cursor cursor = database.query(TABLE_NAME, RECIPIENT_PROJECTION, query, args, null, null, null)) {
if (cursor != null && cursor.moveToNext()) {
return getRecipientSettings(context, cursor);
} else {
@ -674,6 +668,20 @@ public class RecipientDatabase extends Database {
return null;
}
public void markNeedsSync(@NonNull Collection<RecipientId> recipientIds) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
for (RecipientId recipientId : recipientIds) {
markDirty(recipientId, DirtyState.UPDATE);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
public void markNeedsSync(@NonNull RecipientId recipientId) {
markDirty(recipientId, DirtyState.UPDATE);
}
@ -696,6 +704,10 @@ public class RecipientDatabase extends Database {
} finally {
db.endTransaction();
}
for (RecipientId id : storageIds.keySet()) {
Recipient.live(id).refresh();
}
}
public void applyStorageSyncUpdates(@NonNull Collection<SignalContactRecord> contactInserts,
@ -770,7 +782,7 @@ public class RecipientDatabase extends Database {
}
}
threadDatabase.setArchived(recipientId, insert.isArchived());
threadDatabase.applyStorageSyncUpdate(recipientId, insert);
needsRefresh.add(recipientId);
}
@ -810,12 +822,12 @@ public class RecipientDatabase extends Database {
Optional<IdentityRecord> newIdentityRecord = identityDatabase.getIdentity(recipientId);
if ((newIdentityRecord.isPresent() && newIdentityRecord.get().getVerifiedStatus() == IdentityDatabase.VerifiedStatus.VERIFIED) &&
(!oldIdentityRecord.isPresent() || oldIdentityRecord.get().getVerifiedStatus() != IdentityDatabase.VerifiedStatus.VERIFIED))
if ((newIdentityRecord.isPresent() && newIdentityRecord.get().getVerifiedStatus() == VerifiedStatus.VERIFIED) &&
(!oldIdentityRecord.isPresent() || oldIdentityRecord.get().getVerifiedStatus() != VerifiedStatus.VERIFIED))
{
IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), true, true);
} else if ((newIdentityRecord.isPresent() && newIdentityRecord.get().getVerifiedStatus() != IdentityDatabase.VerifiedStatus.VERIFIED) &&
(oldIdentityRecord.isPresent() && oldIdentityRecord.get().getVerifiedStatus() == IdentityDatabase.VerifiedStatus.VERIFIED))
} else if ((newIdentityRecord.isPresent() && newIdentityRecord.get().getVerifiedStatus() != VerifiedStatus.VERIFIED) &&
(oldIdentityRecord.isPresent() && oldIdentityRecord.get().getVerifiedStatus() == VerifiedStatus.VERIFIED))
{
IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), false, true);
}
@ -823,7 +835,7 @@ public class RecipientDatabase extends Database {
Log.w(TAG, "Failed to process identity key during update! Skipping.", e);
}
threadDatabase.setArchived(recipientId, update.getNew().isArchived());
threadDatabase.applyStorageSyncUpdate(recipientId, update.getNew());
needsRefresh.add(recipientId);
}
@ -832,7 +844,7 @@ public class RecipientDatabase extends Database {
Recipient recipient = Recipient.externalGroup(context, GroupId.v1orThrow(insert.getGroupId()));
threadDatabase.setArchived(recipient.getId(), insert.isArchived());
threadDatabase.applyStorageSyncUpdate(recipient.getId(), insert);
needsRefresh.add(recipient.getId());
}
@ -846,7 +858,7 @@ public class RecipientDatabase extends Database {
Recipient recipient = Recipient.externalGroup(context, GroupId.v1orThrow(update.getOld().getGroupId()));
threadDatabase.setArchived(recipient.getId(), update.getNew().isArchived());
threadDatabase.applyStorageSyncUpdate(recipient.getId(), update.getNew());
needsRefresh.add(recipient.getId());
}
@ -874,7 +886,7 @@ public class RecipientDatabase extends Database {
ApplicationDependencies.getJobManager().add(new RequestGroupV2InfoJob(groupId));
threadDatabase.setArchived(recipient.getId(), insert.isArchived());
threadDatabase.applyStorageSyncUpdate(recipient.getId(), insert);
needsRefresh.add(recipient.getId());
}
@ -889,7 +901,7 @@ public class RecipientDatabase extends Database {
GroupMasterKey masterKey = update.getOld().getMasterKeyOrThrow();
Recipient recipient = Recipient.externalGroup(context, GroupId.v2(masterKey));
threadDatabase.setArchived(recipient.getId(), update.getNew().isArchived());
threadDatabase.applyStorageSyncUpdate(recipient.getId(), update.getNew());
needsRefresh.add(recipient.getId());
}
@ -938,6 +950,8 @@ public class RecipientDatabase extends Database {
ApplicationDependencies.getJobManager().add(new RefreshAttributesJob());
}
DatabaseFactory.getThreadDatabase(context).applyStorageSyncUpdate(Recipient.self().getId(), update);
Recipient.self().live().refresh();
}
@ -1050,12 +1064,20 @@ public class RecipientDatabase extends Database {
private List<RecipientSettings> getRecipientSettingsForSync(@Nullable String query, @Nullable String[] args) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String table = TABLE_NAME + " LEFT OUTER JOIN " + IdentityDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + ID + " = " + IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.RECIPIENT_ID
+ " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + GROUP_ID + " = " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.GROUP_ID;
String table = TABLE_NAME + " LEFT OUTER JOIN " + IdentityDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + ID + " = " + IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.RECIPIENT_ID
+ " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + GROUP_ID + " = " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.GROUP_ID
+ " LEFT OUTER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID;
List<RecipientSettings> out = new ArrayList<>();
String[] columns = Stream.of(RECIPIENT_FULL_PROJECTION,
new String[]{GroupDatabase.TABLE_NAME + "." + GroupDatabase.V2_MASTER_KEY }).flatMap(Stream::of).toArray(String[]::new);
String[] columns = Stream.of(TYPED_RECIPIENT_PROJECTION,
new String[]{ RecipientDatabase.TABLE_NAME + "." + STORAGE_PROTO,
GroupDatabase.TABLE_NAME + "." + GroupDatabase.V2_MASTER_KEY,
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ARCHIVED,
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.READ,
IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.VERIFIED + " AS " + IDENTITY_STATUS,
IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.IDENTITY_KEY + " AS " + IDENTITY_KEY })
.flatMap(Stream::of)
.toArray(String[]::new);
try (Cursor cursor = db.query(table, columns, query, args, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
@ -1155,23 +1177,6 @@ public class RecipientDatabase extends Database {
int groupsV2CapabilityValue = CursorUtil.requireInt(cursor, GROUPS_V2_CAPABILITY);
String storageKeyRaw = CursorUtil.requireString(cursor, STORAGE_SERVICE_ID);
int mentionSettingId = CursorUtil.requireInt(cursor, MENTION_SETTING);
String storageProtoRaw = CursorUtil.getString(cursor, STORAGE_PROTO).orNull();
Optional<String> identityKeyRaw = CursorUtil.getString(cursor, IDENTITY_KEY);
Optional<Integer> identityStatusRaw = CursorUtil.getInt(cursor, IDENTITY_STATUS);
int masterKeyIndex = cursor.getColumnIndex(GroupDatabase.V2_MASTER_KEY);
GroupMasterKey groupMasterKey = null;
try {
if (masterKeyIndex != -1) {
byte[] blob = cursor.getBlob(masterKeyIndex);
if (blob != null) {
groupMasterKey = new GroupMasterKey(blob);
}
}
} catch (InvalidInputException e) {
throw new AssertionError(e);
}
MaterialColor color;
byte[] profileKey = null;
@ -1202,30 +1207,58 @@ public class RecipientDatabase extends Database {
}
}
byte[] storageKey = storageKeyRaw != null ? Base64.decodeOrThrow(storageKeyRaw) : null;
byte[] identityKey = identityKeyRaw.transform(Base64::decodeOrThrow).orNull();
byte[] storageProto = storageProtoRaw != null ? Base64.decodeOrThrow(storageProtoRaw) : null;
byte[] storageKey = storageKeyRaw != null ? Base64.decodeOrThrow(storageKeyRaw) : null;
IdentityDatabase.VerifiedStatus identityStatus = identityStatusRaw.transform(IdentityDatabase.VerifiedStatus::forState).or(IdentityDatabase.VerifiedStatus.DEFAULT);
return new RecipientSettings(RecipientId.from(id), uuid, username, e164, email, groupId, groupMasterKey, GroupType.fromId(groupType), blocked, muteUntil,
return new RecipientSettings(RecipientId.from(id),
uuid,
username,
e164,
email,
groupId,
GroupType.fromId(groupType),
blocked,
muteUntil,
VibrateState.fromId(messageVibrateState),
VibrateState.fromId(callVibrateState),
Util.uri(messageRingtone), Util.uri(callRingtone),
color, defaultSubscriptionId, expireMessages,
Util.uri(messageRingtone),
Util.uri(callRingtone),
color,
defaultSubscriptionId,
expireMessages,
RegisteredState.fromId(registeredState),
profileKey, profileKeyCredential,
systemDisplayName, systemContactPhoto,
systemPhoneLabel, systemContactUri,
ProfileName.fromParts(profileGivenName, profileFamilyName), signalProfileAvatar,
AvatarHelper.hasAvatar(context, RecipientId.from(id)), profileSharing, lastProfileFetch,
notificationChannel, UnidentifiedAccessMode.fromMode(unidentifiedAccessMode),
profileKey,
profileKeyCredential,
systemDisplayName,
systemContactPhoto,
systemPhoneLabel,
systemContactUri,
ProfileName.fromParts(profileGivenName, profileFamilyName),
signalProfileAvatar,
AvatarHelper.hasAvatar(context, RecipientId.from(id)),
profileSharing,
lastProfileFetch,
notificationChannel,
UnidentifiedAccessMode.fromMode(unidentifiedAccessMode),
forceSmsSelection,
Recipient.Capability.deserialize(uuidCapabilityValue),
Recipient.Capability.deserialize(groupsV2CapabilityValue),
InsightsBannerTier.fromId(insightsBannerTier),
storageKey, identityKey, identityStatus, MentionSetting.fromId(mentionSettingId),
storageProto);
storageKey,
MentionSetting.fromId(mentionSettingId),
getSyncExtras(cursor));
}
private static @NonNull RecipientSettings.SyncExtras getSyncExtras(@NonNull Cursor cursor) {
String storageProtoRaw = CursorUtil.getString(cursor, STORAGE_PROTO).orNull();
byte[] storageProto = storageProtoRaw != null ? Base64.decodeOrThrow(storageProtoRaw) : null;
boolean archived = CursorUtil.getBoolean(cursor, ThreadDatabase.ARCHIVED).or(false);
boolean forcedUnread = CursorUtil.getInt(cursor, ThreadDatabase.READ).transform(status -> status == ThreadDatabase.ReadStatus.FORCED_UNREAD.serialize()).or(false);
GroupMasterKey groupMasterKey = CursorUtil.getBlob(cursor, GroupDatabase.V2_MASTER_KEY).transform(GroupUtil::requireMasterKey).orNull();
byte[] identityKey = CursorUtil.getString(cursor, IDENTITY_KEY).transform(Base64::decodeOrThrow).orNull();
VerifiedStatus identityStatus = CursorUtil.getInt(cursor, IDENTITY_STATUS).transform(VerifiedStatus::forState).or(VerifiedStatus.DEFAULT);
return new RecipientSettings.SyncExtras(storageProto, groupMasterKey, identityKey, identityStatus, archived, forcedUnread);
}
public BulkOperationsHandle beginBulkSystemContactUpdate() {
@ -1406,7 +1439,7 @@ public class RecipientDatabase extends Database {
valuesToSet.putNull(PROFILE_KEY_CREDENTIAL);
valuesToSet.put(UNIDENTIFIED_ACCESS_MODE, UnidentifiedAccessMode.UNKNOWN.getMode());
SqlUtil.UpdateQuery updateQuery = SqlUtil.buildTrueUpdateQuery(selection, args, valuesToCompare);
SqlUtil.Query updateQuery = SqlUtil.buildTrueUpdateQuery(selection, args, valuesToCompare);
if (update(updateQuery, valuesToSet)) {
markDirty(id, DirtyState.UPDATE);
@ -1455,7 +1488,7 @@ public class RecipientDatabase extends Database {
values.put(PROFILE_KEY_CREDENTIAL, Base64.encodeBytes(profileKeyCredential.serialize()));
SqlUtil.UpdateQuery updateQuery = SqlUtil.buildTrueUpdateQuery(selection, args, values);
SqlUtil.Query updateQuery = SqlUtil.buildTrueUpdateQuery(selection, args, values);
if (update(updateQuery, values)) {
// TODO [greyson] If we sync this in future, mark dirty
@ -2172,6 +2205,10 @@ public class RecipientDatabase extends Database {
} finally {
db.endTransaction();
}
for (RecipientId id : keys.keySet()) {
Recipient.live(id).refresh();
}
}
public void clearDirtyState(@NonNull List<RecipientId> recipients) {
@ -2230,9 +2267,7 @@ public class RecipientDatabase extends Database {
* query such that this will only return true if a row was *actually* updated.
*/
private boolean update(@NonNull RecipientId id, @NonNull ContentValues contentValues) {
String selection = ID + " = ?";
String[] args = new String[]{id.serialize()};
SqlUtil.UpdateQuery updateQuery = SqlUtil.buildTrueUpdateQuery(selection, args, contentValues);
SqlUtil.Query updateQuery = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(id), contentValues);
return update(updateQuery, contentValues);
}
@ -2242,7 +2277,7 @@ public class RecipientDatabase extends Database {
* <p>
* This will only return true if a row was *actually* updated with respect to the where clause of the {@param updateQuery}.
*/
private boolean update(@NonNull SqlUtil.UpdateQuery updateQuery, @NonNull ContentValues contentValues) {
private boolean update(@NonNull SqlUtil.Query updateQuery, @NonNull ContentValues contentValues) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
return database.update(TABLE_NAME, contentValues, updateQuery.getWhere(), updateQuery.getWhereArgs()) > 0;
@ -2529,7 +2564,6 @@ public class RecipientDatabase extends Database {
private final String e164;
private final String email;
private final GroupId groupId;
private final GroupMasterKey groupMasterKey;
private final GroupType groupType;
private final boolean blocked;
private final long muteUntil;
@ -2559,10 +2593,8 @@ public class RecipientDatabase extends Database {
private final Recipient.Capability groupsV2Capability;
private final InsightsBannerTier insightsBannerTier;
private final byte[] storageId;
private final byte[] identityKey;
private final IdentityDatabase.VerifiedStatus identityStatus;
private final MentionSetting mentionSetting;
private final byte[] storageProto;
private final SyncExtras syncExtras;
RecipientSettings(@NonNull RecipientId id,
@Nullable UUID uuid,
@ -2570,7 +2602,6 @@ public class RecipientDatabase extends Database {
@Nullable String e164,
@Nullable String email,
@Nullable GroupId groupId,
@Nullable GroupMasterKey groupMasterKey,
@NonNull GroupType groupType,
boolean blocked,
long muteUntil,
@ -2600,10 +2631,8 @@ public class RecipientDatabase extends Database {
Recipient.Capability groupsV2Capability,
@NonNull InsightsBannerTier insightsBannerTier,
@Nullable byte[] storageId,
@Nullable byte[] identityKey,
@NonNull IdentityDatabase.VerifiedStatus identityStatus,
@NonNull MentionSetting mentionSetting,
@Nullable byte[] storageProto)
@NonNull SyncExtras syncExtras)
{
this.id = id;
this.uuid = uuid;
@ -2611,7 +2640,6 @@ public class RecipientDatabase extends Database {
this.e164 = e164;
this.email = email;
this.groupId = groupId;
this.groupMasterKey = groupMasterKey;
this.groupType = groupType;
this.blocked = blocked;
this.muteUntil = muteUntil;
@ -2641,10 +2669,8 @@ public class RecipientDatabase extends Database {
this.groupsV2Capability = groupsV2Capability;
this.insightsBannerTier = insightsBannerTier;
this.storageId = storageId;
this.identityKey = identityKey;
this.identityStatus = identityStatus;
this.mentionSetting = mentionSetting;
this.storageProto = storageProto;
this.syncExtras = syncExtras;
}
public RecipientId getId() {
@ -2671,13 +2697,6 @@ public class RecipientDatabase extends Database {
return groupId;
}
/**
* Only read populated for sync.
*/
public @Nullable GroupMasterKey getGroupMasterKey() {
return groupMasterKey;
}
public @NonNull GroupType getGroupType() {
return groupType;
}
@ -2794,20 +2813,64 @@ public class RecipientDatabase extends Database {
return storageId;
}
public @Nullable byte[] getIdentityKey() {
return identityKey;
}
public @NonNull IdentityDatabase.VerifiedStatus getIdentityStatus() {
return identityStatus;
}
public @NonNull MentionSetting getMentionSetting() {
return mentionSetting;
}
public @Nullable byte[] getStorageProto() {
return storageProto;
public @NonNull SyncExtras getSyncExtras() {
return syncExtras;
}
/**
* A bundle of data that's only necessary when syncing to storage service, not for a
* {@link Recipient}.
*/
public static class SyncExtras {
private final byte[] storageProto;
private final GroupMasterKey groupMasterKey;
private final byte[] identityKey;
private final VerifiedStatus identityStatus;
private final boolean archived;
private final boolean forcedUnread;
public SyncExtras(@Nullable byte[] storageProto,
@Nullable GroupMasterKey groupMasterKey,
@Nullable byte[] identityKey,
@NonNull VerifiedStatus identityStatus,
boolean archived,
boolean forcedUnread)
{
this.storageProto = storageProto;
this.groupMasterKey = groupMasterKey;
this.identityKey = identityKey;
this.identityStatus = identityStatus;
this.archived = archived;
this.forcedUnread = forcedUnread;
}
public @Nullable byte[] getStorageProto() {
return storageProto;
}
public @Nullable GroupMasterKey getGroupMasterKey() {
return groupMasterKey;
}
public boolean isArchived() {
return archived;
}
public @Nullable byte[] getIdentityKey() {
return identityKey;
}
public @NonNull VerifiedStatus getIdentityStatus() {
return identityStatus;
}
public boolean isForcedUnread() {
return forcedUnread;
}
}
}

View File

@ -32,8 +32,6 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import net.sqlcipher.database.SQLiteDatabase;
import org.jsoup.helper.StringUtil;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedMember;
import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
@ -56,11 +54,15 @@ import org.thoughtcrime.securesms.util.SqlUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.api.storage.SignalAccountRecord;
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record;
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record;
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
@ -70,7 +72,6 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
public class ThreadDatabase extends Database {
@ -102,7 +103,7 @@ public class ThreadDatabase extends Database {
public static final String LAST_SEEN = "last_seen";
public static final String HAS_SENT = "has_sent";
private static final String LAST_SCROLLED = "last_scrolled";
private static final String PINNED = "pinned";
static final String PINNED = "pinned";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
DATE + " INTEGER DEFAULT 0, " +
@ -144,7 +145,7 @@ public class ThreadDatabase extends Database {
.toList();
private static final List<String> COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION = Stream.concat(Stream.concat(Stream.of(TYPED_THREAD_PROJECTION),
Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION)),
Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION_NO_ID)),
Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION))
.toList();
@ -405,6 +406,7 @@ public class ThreadDatabase extends Database {
List<MarkedMessageInfo> smsRecords = new LinkedList<>();
List<MarkedMessageInfo> mmsRecords = new LinkedList<>();
boolean needsSync = false;
db.beginTransaction();
@ -417,6 +419,8 @@ public class ThreadDatabase extends Database {
}
for (long threadId : threadIds) {
ThreadRecord previous = getThreadRecord(threadId);
smsRecords.addAll(DatabaseFactory.getSmsDatabase(context).setMessagesReadSince(threadId, sinceTimestamp));
mmsRecords.addAll(DatabaseFactory.getMmsDatabase(context).setMessagesReadSince(threadId, sinceTimestamp));
@ -427,7 +431,12 @@ public class ThreadDatabase extends Database {
contentValues.put(UNREAD_COUNT, unreadCount);
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[]{threadId + ""});
db.update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(threadId));
if (previous != null && previous.isForcedUnread()) {
DatabaseFactory.getRecipientDatabase(context).markNeedsSync(previous.getRecipient().getId());
needsSync = true;
}
}
db.setTransactionSuccessful();
@ -437,6 +446,11 @@ public class ThreadDatabase extends Database {
notifyConversationListeners(new HashSet<>(threadIds));
notifyConversationListListeners();
if (needsSync) {
StorageSyncHelper.scheduleSyncForDataChange();
}
return Util.concatenatedList(smsRecords, mmsRecords);
}
@ -445,19 +459,22 @@ public class ThreadDatabase extends Database {
db.beginTransaction();
try {
ContentValues contentValues = new ContentValues();
List<RecipientId> recipientIds = getRecipientIdsForThreadIds(threadIds);
SqlUtil.Query query = SqlUtil.buildCollectionQuery(ID, threadIds);
ContentValues contentValues = new ContentValues();
contentValues.put(READ, ReadStatus.FORCED_UNREAD.serialize());
for (long threadId : threadIds) {
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] { String.valueOf(threadId) });
}
db.update(TABLE_NAME, contentValues, query.getWhere(), query.getWhereArgs());
DatabaseFactory.getRecipientDatabase(context).markNeedsSync(recipientIds);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
notifyConversationListListeners();
StorageSyncHelper.scheduleSyncForDataChange();
notifyConversationListListeners();
}
}
@ -949,6 +966,20 @@ public class ThreadDatabase extends Database {
return Recipient.resolved(id);
}
public @NonNull List<RecipientId> getRecipientIdsForThreadIds(Collection<Long> threadIds) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
SqlUtil.Query query = SqlUtil.buildCollectionQuery(ID, threadIds);
List<RecipientId> ids = new ArrayList<>(threadIds.size());
try (Cursor cursor = db.query(TABLE_NAME, new String[] { RECIPIENT_ID }, query.getWhere(), query.getWhereArgs(), null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
ids.add(RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)));
}
}
return ids;
}
public boolean hasThread(@NonNull RecipientId recipientId) {
return getThreadIdIfExistsFor(recipientId) > -1;
}
@ -964,16 +995,56 @@ public class ThreadDatabase extends Database {
}
void updateReadState(long threadId) {
int unreadCount = DatabaseFactory.getMmsSmsDatabase(context).getUnreadCount(threadId);
ThreadRecord previous = getThreadRecord(threadId);
int unreadCount = DatabaseFactory.getMmsSmsDatabase(context).getUnreadCount(threadId);
ContentValues contentValues = new ContentValues();
contentValues.put(READ, unreadCount == 0);
contentValues.put(READ, unreadCount == 0 ? ReadStatus.READ.serialize() : ReadStatus.UNREAD.serialize());
contentValues.put(UNREAD_COUNT, unreadCount);
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues,ID_WHERE,
new String[] {String.valueOf(threadId)});
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(threadId));
notifyConversationListListeners();
if (previous != null && previous.isForcedUnread()) {
DatabaseFactory.getRecipientDatabase(context).markNeedsSync(previous.getRecipient().getId());
StorageSyncHelper.scheduleSyncForDataChange();
}
}
public void applyStorageSyncUpdate(@NonNull RecipientId recipientId, @NonNull SignalContactRecord record) {
applyStorageSyncUpdate(recipientId, record.isArchived(), record.isForcedUnread());
}
public void applyStorageSyncUpdate(@NonNull RecipientId recipientId, @NonNull SignalGroupV1Record record) {
applyStorageSyncUpdate(recipientId, record.isArchived(), record.isForcedUnread());
}
public void applyStorageSyncUpdate(@NonNull RecipientId recipientId, @NonNull SignalGroupV2Record record) {
applyStorageSyncUpdate(recipientId, record.isArchived(), record.isForcedUnread());
}
public void applyStorageSyncUpdate(@NonNull RecipientId recipientId, @NonNull SignalAccountRecord record) {
applyStorageSyncUpdate(recipientId, record.isNoteToSelfArchived(), record.isNoteToSelfForcedUnread());
}
private void applyStorageSyncUpdate(@NonNull RecipientId recipientId, boolean archived, boolean forcedUnread) {
ContentValues values = new ContentValues();
values.put(ARCHIVED, archived);
if (forcedUnread) {
values.put(READ, ReadStatus.FORCED_UNREAD.serialize());
} else {
Long threadId = getThreadIdFor(recipientId);
if (threadId != null) {
int unreadCount = DatabaseFactory.getMmsSmsDatabase(context).getUnreadCount(threadId);
values.put(READ, unreadCount == 0 ? ReadStatus.READ.serialize() : ReadStatus.UNREAD.serialize());
values.put(UNREAD_COUNT, unreadCount);
}
}
databaseHelper.getWritableDatabase().update(TABLE_NAME, values, RECIPIENT_ID + " = ?", SqlUtil.buildArgs(recipientId));
}
public boolean update(long threadId, boolean unarchive) {
@ -1404,7 +1475,7 @@ public class ThreadDatabase extends Database {
}
}
private enum ReadStatus {
enum ReadStatus {
READ(1), UNREAD(0), FORCED_UNREAD(2);
private final int value;

View File

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database.model;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.google.protobuf.ByteString;
@ -81,7 +82,11 @@ final class GroupsV2UpdateMessageProducer {
}
}
List<UpdateDescription> describeChanges(@NonNull DecryptedGroupChange change) {
List<UpdateDescription> describeChanges(@Nullable DecryptedGroup previousGroupState, @NonNull DecryptedGroupChange change) {
if (DecryptedGroup.getDefaultInstance().equals(previousGroupState)) {
previousGroupState = null;
}
List<UpdateDescription> updates = new LinkedList<>();
if (change.getEditor().isEmpty() || UuidUtil.UNKNOWN_UUID.equals(UuidUtil.fromByteString(change.getEditor()))) {
@ -96,7 +101,7 @@ final class GroupsV2UpdateMessageProducer {
describeUnknownEditorNewTimer(change, updates);
describeUnknownEditorNewAttributeAccess(change, updates);
describeUnknownEditorNewMembershipAccess(change, updates);
describeUnknownEditorNewGroupInviteLinkAccess(change, updates);
describeUnknownEditorNewGroupInviteLinkAccess(previousGroupState, change, updates);
describeRequestingMembers(change, updates);
describeUnknownEditorRequestingMembersApprovals(change, updates);
describeUnknownEditorRequestingMembersDeletes(change, updates);
@ -119,7 +124,7 @@ final class GroupsV2UpdateMessageProducer {
describeNewTimer(change, updates);
describeNewAttributeAccess(change, updates);
describeNewMembershipAccess(change, updates);
describeNewGroupInviteLinkAccess(change, updates);
describeNewGroupInviteLinkAccess(previousGroupState, change, updates);
describeRequestingMembers(change, updates);
describeRequestingMembersApprovals(change, updates);
describeRequestingMembersDeletes(change, updates);
@ -509,7 +514,16 @@ final class GroupsV2UpdateMessageProducer {
}
}
private void describeNewGroupInviteLinkAccess(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
private void describeNewGroupInviteLinkAccess(@Nullable DecryptedGroup previousGroupState,
@NonNull DecryptedGroupChange change,
@NonNull List<UpdateDescription> updates)
{
AccessControl.AccessRequired previousAccessControl = null;
if (previousGroupState != null) {
previousAccessControl = previousGroupState.getAccessControl().getAddFromInviteLink();
}
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
boolean groupLinkEnabled = false;
@ -517,17 +531,33 @@ final class GroupsV2UpdateMessageProducer {
case ANY:
groupLinkEnabled = true;
if (editorIsYou) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_on_the_group_link_with_admin_approval_off)));
if (previousAccessControl == AccessControl.AccessRequired.ADMINISTRATOR) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_off_admin_approval_for_the_group_link)));
} else {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_on_the_group_link_with_admin_approval_off)));
}
} else {
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_turned_on_the_group_link_with_admin_approval_off, editor)));
if (previousAccessControl == AccessControl.AccessRequired.ADMINISTRATOR) {
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_turned_off_admin_approval_for_the_group_link, editor)));
} else {
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_turned_on_the_group_link_with_admin_approval_off, editor)));
}
}
break;
case ADMINISTRATOR:
groupLinkEnabled = true;
if (editorIsYou) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_on_the_group_link_with_admin_approval_on)));
if (previousAccessControl == AccessControl.AccessRequired.ANY) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_on_admin_approval_for_the_group_link)));
} else {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_on_the_group_link_with_admin_approval_on)));
}
} else {
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_turned_on_the_group_link_with_admin_approval_on, editor)));
if (previousAccessControl == AccessControl.AccessRequired.ANY) {
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_turned_on_admin_approval_for_the_group_link, editor)));
} else {
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_turned_on_the_group_link_with_admin_approval_on, editor)));
}
}
break;
case UNSATISFIABLE:
@ -548,13 +578,30 @@ final class GroupsV2UpdateMessageProducer {
}
}
private void describeUnknownEditorNewGroupInviteLinkAccess(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
private void describeUnknownEditorNewGroupInviteLinkAccess(@Nullable DecryptedGroup previousGroupState,
@NonNull DecryptedGroupChange change,
@NonNull List<UpdateDescription> updates)
{
AccessControl.AccessRequired previousAccessControl = null;
if (previousGroupState != null) {
previousAccessControl = previousGroupState.getAccessControl().getAddFromInviteLink();
}
switch (change.getNewInviteLinkAccess()) {
case ANY:
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_link_has_been_turned_on_with_admin_approval_off)));
if (previousAccessControl == AccessControl.AccessRequired.ADMINISTRATOR) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_admin_approval_for_the_group_link_has_been_turned_off)));
} else {
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_link_has_been_turned_on_with_admin_approval_off)));
}
break;
case ADMINISTRATOR:
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_link_has_been_turned_on_with_admin_approval_on)));
if (previousAccessControl == AccessControl.AccessRequired.ANY) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_admin_approval_for_the_group_link_has_been_turned_on)));
} else {
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_link_has_been_turned_on_with_admin_approval_on)));
}
break;
case UNSATISFIABLE:
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_link_has_been_turned_off)));

View File

@ -177,7 +177,7 @@ public abstract class MessageRecord extends DisplayRecord {
GroupsV2UpdateMessageProducer updateMessageProducer = new GroupsV2UpdateMessageProducer(context, descriptionStrategy, Recipient.self().getUuid().get());
if (decryptedGroupV2Context.hasChange() && decryptedGroupV2Context.getGroupState().getRevision() != 0) {
return UpdateDescription.concatWithNewLines(updateMessageProducer.describeChanges(decryptedGroupV2Context.getChange()));
return UpdateDescription.concatWithNewLines(updateMessageProducer.describeChanges(decryptedGroupV2Context.getPreviousGroupState(), decryptedGroupV2Context.getChange()));
} else {
return updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState(), decryptedGroupV2Context.getChange());
}

View File

@ -6,6 +6,7 @@ import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.KbsEnclave;
import org.thoughtcrime.securesms.messages.IncomingMessageProcessor;
import org.thoughtcrime.securesms.messages.BackgroundMessageRetriever;
import org.thoughtcrime.securesms.groups.GroupsV2AuthorizationMemoryValueCache;
@ -15,6 +16,7 @@ import org.thoughtcrime.securesms.keyvalue.KeyValueStore;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.pin.KbsEnclaves;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.LiveRecipientCache;
import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
@ -111,11 +113,11 @@ public class ApplicationDependencies {
return groupsV2Operations;
}
public static synchronized @NonNull KeyBackupService getKeyBackupService() {
public static synchronized @NonNull KeyBackupService getKeyBackupService(@NonNull KbsEnclave enclave) {
return getSignalServiceAccountManager().getKeyBackupService(IasKeyStore.getIasKeyStore(application),
BuildConfig.KBS_ENCLAVE_NAME,
Hex.fromStringOrThrow(BuildConfig.KBS_SERVICE_ID),
BuildConfig.KBS_MRENCLAVE,
enclave.getEnclaveName(),
Hex.fromStringOrThrow(enclave.getServiceId()),
enclave.getMrEnclave(),
10);
}

View File

@ -193,7 +193,7 @@ final class GroupManagerV2 {
.setEditor(UuidUtil.toByteString(selfUuid))
.build();
RecipientAndThread recipientAndThread = sendGroupUpdate(masterKey, decryptedGroup, groupChange, null);
RecipientAndThread recipientAndThread = sendGroupUpdate(masterKey, new GroupMutation(null, groupChange, decryptedGroup), null);
return new GroupManager.GroupActionResult(recipientAndThread.groupRecipient,
recipientAndThread.threadId,
@ -505,16 +505,18 @@ final class GroupManagerV2 {
private GroupManager.GroupActionResult commitChange(@NonNull GroupChange.Actions.Builder change)
throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException
{
final GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId);
final GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties();
final int nextRevision = v2GroupProperties.getGroupRevision() + 1;
final GroupChange.Actions changeActions = change.setRevision(nextRevision).build();
final GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId);
final GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties();
final int nextRevision = v2GroupProperties.getGroupRevision() + 1;
final GroupChange.Actions changeActions = change.setRevision(nextRevision).build();
final DecryptedGroupChange decryptedChange;
final DecryptedGroup decryptedGroupState;
final DecryptedGroup previousGroupState;
try {
previousGroupState = v2GroupProperties.getDecryptedGroup();
decryptedChange = groupOperations.decryptChange(changeActions, selfUuid);
decryptedGroupState = DecryptedGroupUtil.apply(v2GroupProperties.getDecryptedGroup(), decryptedChange);
decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
Log.w(TAG, e);
throw new IOException(e);
@ -523,7 +525,8 @@ final class GroupManagerV2 {
GroupChange signedGroupChange = commitToServer(changeActions);
groupDatabase.update(groupId, decryptedGroupState);
RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, decryptedGroupState, decryptedChange, signedGroupChange);
GroupMutation groupMutation = new GroupMutation(previousGroupState, decryptedChange, decryptedGroupState);
RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, groupMutation, signedGroupChange);
int newMembersCount = decryptedChange.getNewMembersCount();
List<RecipientId> newPendingMembers = getPendingMemberRecipientIds(decryptedChange.getNewPendingMembersList());
@ -681,7 +684,7 @@ final class GroupManagerV2 {
} else if (requestToJoin) {
Log.i(TAG, "Requested to join, cannot send update");
RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, decryptedGroup, decryptedChange, signedGroupChange);
RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, new GroupMutation(null, decryptedChange, decryptedGroup), signedGroupChange);
return new GroupManager.GroupActionResult(groupRecipient,
recipientAndThread.threadId,
@ -706,7 +709,7 @@ final class GroupManagerV2 {
System.currentTimeMillis(),
decryptedChange);
RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, decryptedGroup, decryptedChange, signedGroupChange);
RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, new GroupMutation(null, decryptedChange, decryptedGroup), signedGroupChange);
return new GroupManager.GroupActionResult(groupRecipient,
recipientAndThread.threadId,
@ -905,7 +908,7 @@ final class GroupManagerV2 {
groupDatabase.update(groupId, resetRevision(newGroup, decryptedGroup.getRevision()));
sendGroupUpdate(groupMasterKey, decryptedGroup, decryptedChange, signedGroupChange);
sendGroupUpdate(groupMasterKey, new GroupMutation(decryptedGroup, decryptedChange, newGroup), signedGroupChange);
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
throw new GroupChangeFailedException(e);
}
@ -959,13 +962,12 @@ final class GroupManagerV2 {
}
private @NonNull RecipientAndThread sendGroupUpdate(@NonNull GroupMasterKey masterKey,
@NonNull DecryptedGroup decryptedGroup,
@Nullable DecryptedGroupChange plainGroupChange,
@NonNull GroupMutation groupMutation,
@Nullable GroupChange signedGroupChange)
{
GroupId.V2 groupId = GroupId.v2(masterKey);
Recipient groupRecipient = Recipient.externalGroup(context, groupId);
DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, decryptedGroup, plainGroupChange, signedGroupChange);
DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, groupMutation, signedGroupChange);
OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient,
decryptedGroupV2Context,
null,
@ -977,8 +979,11 @@ final class GroupManagerV2 {
Collections.emptyList(),
Collections.emptyList());
DecryptedGroupChange plainGroupChange = groupMutation.getGroupChange();
if (plainGroupChange != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(plainGroupChange)) {
ApplicationDependencies.getJobManager().add(PushGroupSilentUpdateSendJob.create(context, groupId, decryptedGroup, outgoingMessage));
ApplicationDependencies.getJobManager().add(PushGroupSilentUpdateSendJob.create(context, groupId, groupMutation.getNewGroupState(), outgoingMessage));
return new RecipientAndThread(groupRecipient, -1);
} else {
long threadId = MessageSender.send(context, outgoingMessage, -1, false, null);

View File

@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.groups;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
public final class GroupMutation {
@Nullable private final DecryptedGroup previousGroupState;
@Nullable private final DecryptedGroupChange groupChange;
@NonNull private final DecryptedGroup newGroupState;
public GroupMutation(@Nullable DecryptedGroup previousGroupState, @Nullable DecryptedGroupChange groupChange, @NonNull DecryptedGroup newGroupState) {
this.previousGroupState = previousGroupState;
this.groupChange = groupChange;
this.newGroupState = newGroupState;
}
public @Nullable DecryptedGroup getPreviousGroupState() {
return previousGroupState;
}
public @Nullable DecryptedGroupChange getGroupChange() {
return groupChange;
}
public @NonNull DecryptedGroup getNewGroupState() {
return newGroupState;
}
}

View File

@ -49,12 +49,13 @@ public final class GroupProtoUtil {
}
public static DecryptedGroupV2Context createDecryptedGroupV2Context(@NonNull GroupMasterKey masterKey,
@NonNull DecryptedGroup decryptedGroup,
@Nullable DecryptedGroupChange plainGroupChange,
@NonNull GroupMutation groupMutation,
@Nullable GroupChange signedServerChange)
{
int revision = plainGroupChange != null ? plainGroupChange.getRevision() : decryptedGroup.getRevision();
SignalServiceProtos.GroupContextV2.Builder contextBuilder = SignalServiceProtos.GroupContextV2.newBuilder()
DecryptedGroupChange plainGroupChange = groupMutation.getGroupChange();
DecryptedGroup decryptedGroup = groupMutation.getNewGroupState();
int revision = plainGroupChange != null ? plainGroupChange.getRevision() : decryptedGroup.getRevision();
SignalServiceProtos.GroupContextV2.Builder contextBuilder = SignalServiceProtos.GroupContextV2.newBuilder()
.setMasterKey(ByteString.copyFrom(masterKey.serialize()))
.setRevision(revision);
@ -66,6 +67,10 @@ public final class GroupProtoUtil {
.setContext(contextBuilder.build())
.setGroupState(decryptedGroup);
if (groupMutation.getPreviousGroupState() != null) {
builder.setPreviousGroupState(groupMutation.getPreviousGroupState());
}
if (plainGroupChange != null) {
builder.setChange(plainGroupChange);
}

View File

@ -195,7 +195,7 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
private GroupInviteLinkUrl getGroupInviteLinkUrl() {
try {
//noinspection ConstantConditions
return GroupInviteLinkUrl.fromUrl(requireArguments().getString(ARG_GROUP_INVITE_LINK_URL));
return GroupInviteLinkUrl.fromUri(requireArguments().getString(ARG_GROUP_INVITE_LINK_URL));
} catch (GroupInviteLinkUrl.InvalidGroupLinkException | GroupInviteLinkUrl.UnknownGroupLinkVersionException e) {
throw new AssertionError();
}

View File

@ -12,8 +12,8 @@ import org.signal.zkgroup.groups.GroupMasterKey;
import org.whispersystems.util.Base64UrlSafe;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URI;
import java.net.URISyntaxException;
public final class GroupInviteLinkUrl {
@ -38,24 +38,24 @@ public final class GroupInviteLinkUrl {
* @return null iff not a group url.
* @throws InvalidGroupLinkException If group url, but cannot be parsed.
*/
public static @Nullable GroupInviteLinkUrl fromUrl(@NonNull String urlString)
public static @Nullable GroupInviteLinkUrl fromUri(@NonNull String urlString)
throws InvalidGroupLinkException, UnknownGroupLinkVersionException
{
URL url = getGroupUrl(urlString);
URI uri = getGroupUrl(urlString);
if (url == null) {
if (uri == null) {
return null;
}
try {
if (!"/".equals(url.getPath()) && url.getPath().length() > 0) {
throw new InvalidGroupLinkException("No path was expected in url");
if (!"/".equals(uri.getPath()) && uri.getPath().length() > 0) {
throw new InvalidGroupLinkException("No path was expected in uri");
}
String encoding = url.getRef();
String encoding = uri.getFragment();
if (encoding == null || encoding.length() == 0) {
throw new InvalidGroupLinkException("No reference was in the url");
throw new InvalidGroupLinkException("No reference was in the uri");
}
byte[] bytes = Base64UrlSafe.decodePaddingAgnostic(encoding);
@ -78,16 +78,23 @@ public final class GroupInviteLinkUrl {
}
/**
* @return {@link URL} if the host name matches.
* @return {@link URI} if the host name matches.
*/
private static URL getGroupUrl(@NonNull String urlString) {
private static URI getGroupUrl(@NonNull String urlString) {
try {
URL url = new URL(urlString);
URI url = new URI(urlString);
if (!"https".equalsIgnoreCase(url.getScheme()) &&
!"sgnl".equalsIgnoreCase(url.getScheme()))
{
return null;
}
return GROUP_URL_HOST.equalsIgnoreCase(url.getHost())
? url
: null;
} catch (MalformedURLException e) {
} catch (URISyntaxException e) {
return null;
}
}

View File

@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupMutation;
import org.thoughtcrime.securesms.groups.GroupNotAMemberException;
import org.thoughtcrime.securesms.groups.GroupProtoUtil;
import org.thoughtcrime.securesms.groups.GroupsV2Authorization;
@ -226,9 +227,9 @@ public final class GroupsV2StateProcessor {
determineProfileSharing(inputGroupState, newLocalState);
if (localState != null && localState.getRevision() == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) {
Log.i(TAG, "Inserting single update message for restore placeholder");
insertUpdateMessages(timestamp, Collections.singleton(new LocalGroupLogEntry(newLocalState, null)));
insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(newLocalState, null)));
} else {
insertUpdateMessages(timestamp, advanceGroupStateResult.getProcessedLogEntries());
insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries());
}
persistLearnedProfileKeys(inputGroupState);
@ -260,7 +261,7 @@ public final class GroupsV2StateProcessor {
.addDeleteMembers(UuidUtil.toByteString(selfUuid))
.build();
DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, simulatedGroupState, simulatedGroupChange, null);
DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, new GroupMutation(decryptedGroup, simulatedGroupChange, simulatedGroupState), null);
OutgoingGroupUpdateMessage leaveMessage = new OutgoingGroupUpdateMessage(groupRecipient,
decryptedGroupV2Context,
null,
@ -362,13 +363,17 @@ public final class GroupsV2StateProcessor {
}
}
private void insertUpdateMessages(long timestamp, Collection<LocalGroupLogEntry> processedLogEntries) {
private void insertUpdateMessages(long timestamp,
@Nullable DecryptedGroup previousGroupState,
Collection<LocalGroupLogEntry> processedLogEntries)
{
for (LocalGroupLogEntry entry : processedLogEntries) {
if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(entry.getChange()) && !DecryptedGroupUtil.changeIsEmpty(entry.getChange())) {
Log.d(TAG, "Skipping profile key changes only update message");
} else {
storeMessage(GroupProtoUtil.createDecryptedGroupV2Context(masterKey, entry.getGroup(), entry.getChange(), null), timestamp);
storeMessage(GroupProtoUtil.createDecryptedGroupV2Context(masterKey, new GroupMutation(previousGroupState, entry.getChange(), entry.getGroup()), null), timestamp);
}
previousGroupState = entry.getGroup();
}
}

View File

@ -0,0 +1,102 @@
package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.KbsEnclave;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.pin.KbsEnclaves;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
/**
* Clears data from an old KBS enclave.
*/
public class ClearFallbackKbsEnclaveJob extends BaseJob {
public static final String KEY = "ClearFallbackKbsEnclaveJob";
private static final String TAG = Log.tag(ClearFallbackKbsEnclaveJob.class);
private static final String KEY_ENCLAVE_NAME = "enclaveName";
private static final String KEY_SERVICE_ID = "serviceId";
private static final String KEY_MR_ENCLAVE = "mrEnclave";
private final KbsEnclave enclave;
ClearFallbackKbsEnclaveJob(@NonNull KbsEnclave enclave) {
this(new Parameters.Builder()
.addConstraint(NetworkConstraint.KEY)
.setLifespan(TimeUnit.DAYS.toMillis(90))
.setMaxAttempts(Parameters.UNLIMITED)
.setQueue("ClearFallbackKbsEnclaveJob")
.build(),
enclave);
}
public static void clearAll() {
if (KbsEnclaves.fallbacks().isEmpty()) {
Log.i(TAG, "No fallbacks!");
return;
}
JobManager jobManager = ApplicationDependencies.getJobManager();
for (KbsEnclave enclave : KbsEnclaves.fallbacks()) {
jobManager.add(new ClearFallbackKbsEnclaveJob(enclave));
}
}
private ClearFallbackKbsEnclaveJob(@NonNull Parameters parameters, @NonNull KbsEnclave enclave) {
super(parameters);
this.enclave = enclave;
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public @NonNull Data serialize() {
return new Data.Builder().putString(KEY_ENCLAVE_NAME, enclave.getEnclaveName())
.putString(KEY_SERVICE_ID, enclave.getServiceId())
.putString(KEY_MR_ENCLAVE, enclave.getMrEnclave())
.build();
}
@Override
public void onRun() throws IOException, UnauthenticatedResponseException {
Log.i(TAG, "Preparing to delete data from " + enclave.getEnclaveName());
ApplicationDependencies.getKeyBackupService(enclave).newPinChangeSession().removePin();
Log.i(TAG, "Successfully deleted the data from " + enclave.getEnclaveName());
}
@Override
public boolean onShouldRetry(@NonNull Exception e) {
return true;
}
@Override
public void onFailure() {
throw new AssertionError("This job should never fail. " + getClass().getSimpleName());
}
public static class Factory implements Job.Factory<ClearFallbackKbsEnclaveJob> {
@Override
public @NonNull ClearFallbackKbsEnclaveJob create(@NonNull Parameters parameters, @NonNull Data data) {
KbsEnclave enclave = new KbsEnclave(data.getString(KEY_ENCLAVE_NAME),
data.getString(KEY_SERVICE_ID),
data.getString(KEY_MR_ENCLAVE));
return new ClearFallbackKbsEnclaveJob(parameters, enclave);
}
}
}

View File

@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.migrations.AvatarIdRemovalMigrationJob;
import org.thoughtcrime.securesms.migrations.AvatarMigrationJob;
import org.thoughtcrime.securesms.migrations.CachedAttachmentsMigrationJob;
import org.thoughtcrime.securesms.migrations.DatabaseMigrationJob;
import org.thoughtcrime.securesms.migrations.KbsEnclaveMigrationJob;
import org.thoughtcrime.securesms.migrations.LegacyMigrationJob;
import org.thoughtcrime.securesms.migrations.MigrationCompleteJob;
import org.thoughtcrime.securesms.migrations.PassingMigrationJob;
@ -62,9 +63,11 @@ public final class JobManagerFactories {
put(AvatarGroupsV1DownloadJob.KEY, new AvatarGroupsV1DownloadJob.Factory());
put(AvatarGroupsV2DownloadJob.KEY, new AvatarGroupsV2DownloadJob.Factory());
put(CleanPreKeysJob.KEY, new CleanPreKeysJob.Factory());
put(ClearFallbackKbsEnclaveJob.KEY, new ClearFallbackKbsEnclaveJob.Factory());
put(CreateSignedPreKeyJob.KEY, new CreateSignedPreKeyJob.Factory());
put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory());
put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory());
put(KbsEnclaveMigrationWorkerJob.KEY, new KbsEnclaveMigrationWorkerJob.Factory());
put(LeaveGroupJob.KEY, new LeaveGroupJob.Factory());
put(LocalBackupJob.KEY, new LocalBackupJob.Factory());
put(MmsDownloadJob.KEY, new MmsDownloadJob.Factory());
@ -132,6 +135,7 @@ public final class JobManagerFactories {
put(AvatarMigrationJob.KEY, new AvatarMigrationJob.Factory());
put(CachedAttachmentsMigrationJob.KEY, new CachedAttachmentsMigrationJob.Factory());
put(DatabaseMigrationJob.KEY, new DatabaseMigrationJob.Factory());
put(KbsEnclaveMigrationJob.KEY, new KbsEnclaveMigrationJob.Factory());
put(LegacyMigrationJob.KEY, new LegacyMigrationJob.Factory());
put(MigrationCompleteJob.KEY, new MigrationCompleteJob.Factory());
put(PinOptOutMigration.KEY, new PinOptOutMigration.Factory());

View File

@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.migrations.KbsEnclaveMigrationJob;
import org.thoughtcrime.securesms.pin.PinState;
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException;
import java.io.IOException;
/**
* Should only be enqueued by {@link KbsEnclaveMigrationJob}. Does the actual work of migrating KBS
* data to the new enclave and deleting it from the old enclave(s).
*/
public class KbsEnclaveMigrationWorkerJob extends BaseJob {
public static final String KEY = "KbsEnclaveMigrationWorkerJob";
private static final String TAG = Log.tag(KbsEnclaveMigrationWorkerJob.class);
public KbsEnclaveMigrationWorkerJob() {
this(new Parameters.Builder()
.addConstraint(NetworkConstraint.KEY)
.setLifespan(Parameters.IMMORTAL)
.setMaxAttempts(Parameters.UNLIMITED)
.setQueue("KbsEnclaveMigrationWorkerJob")
.setMaxInstances(1)
.build());
}
private KbsEnclaveMigrationWorkerJob(@NonNull Parameters parameters) {
super(parameters);
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public @NonNull Data serialize() {
return Data.EMPTY;
}
@Override
public void onRun() throws IOException, UnauthenticatedResponseException {
String pin = SignalStore.kbsValues().getPin();
if (SignalStore.kbsValues().hasOptedOut()) {
Log.w(TAG, "Opted out of KBS! Nothing to migrate.");
return;
}
if (pin == null) {
Log.w(TAG, "No PIN available! Can't migrate!");
return;
}
PinState.onMigrateToNewEnclave(pin);
Log.i(TAG, "Migration successful!");
}
@Override
public boolean onShouldRetry(@NonNull Exception e) {
return e instanceof IOException ||
e instanceof UnauthenticatedResponseException;
}
@Override
public void onFailure() {
throw new AssertionError("This job should never fail. " + getClass().getSimpleName());
}
public static class Factory implements Job.Factory<KbsEnclaveMigrationWorkerJob> {
@Override
public @NonNull KbsEnclaveMigrationWorkerJob create(@NonNull Parameters parameters, @NonNull Data data) {
return new KbsEnclaveMigrationWorkerJob(parameters);
}
}
}

View File

@ -512,7 +512,7 @@ public final class PushProcessMessageJob extends BaseJob {
Intent intent = new Intent(context, WebRtcCallService.class);
Recipient recipient = Recipient.externalHighTrustPush(context, content.getSender());
RemotePeer remotePeer = new RemotePeer(recipient.getId());
byte[] remoteIdentityKey = recipient.getIdentityKey();
byte[] remoteIdentityKey = DatabaseFactory.getIdentityDatabase(context).getIdentity(recipient.getId()).transform(record -> record.getIdentityKey().serialize()).orNull();
intent.setAction(WebRtcCallService.ACTION_RECEIVE_OFFER)
.putExtra(WebRtcCallService.EXTRA_CALL_ID, message.getId())
@ -538,7 +538,7 @@ public final class PushProcessMessageJob extends BaseJob {
Intent intent = new Intent(context, WebRtcCallService.class);
Recipient recipient = Recipient.externalHighTrustPush(context, content.getSender());
RemotePeer remotePeer = new RemotePeer(recipient.getId());
byte[] remoteIdentityKey = recipient.getIdentityKey();
byte[] remoteIdentityKey = DatabaseFactory.getIdentityDatabase(context).getIdentity(recipient.getId()).transform(record -> record.getIdentityKey().serialize()).orNull();
intent.setAction(WebRtcCallService.ACTION_RECEIVE_ANSWER)
.putExtra(WebRtcCallService.EXTRA_CALL_ID, message.getId())
@ -1830,7 +1830,7 @@ public final class PushProcessMessageJob extends BaseJob {
Optional<GroupId> groupId = GroupUtil.idFromGroupContext(message.getGroupContext());
if (groupId.isPresent() && groupDatabase.isUnknownGroup(groupId.get())) {
return false;
return sender.isBlocked();
}
boolean isTextMessage = message.getBody().isPresent();

View File

@ -83,11 +83,10 @@ public class StorageForcePushJob extends BaseJob {
long newVersion = currentVersion + 1;
Map<RecipientId, StorageId> newContactStorageIds = generateContactStorageIds(oldContactStorageIds);
Set<RecipientId> archivedRecipients = DatabaseFactory.getThreadDatabase(context).getArchivedRecipients();
List<SignalStorageRecord> inserts = Stream.of(oldContactStorageIds.keySet())
.map(recipientDatabase::getRecipientSettingsForSync)
.withoutNulls()
.map(s -> StorageSyncModels.localToRemoteRecord(s, Objects.requireNonNull(newContactStorageIds.get(s.getId())).getRaw(), archivedRecipients))
.map(s -> StorageSyncModels.localToRemoteRecord(s, Objects.requireNonNull(newContactStorageIds.get(s.getId())).getRaw()))
.toList();
SignalStorageRecord accountRecord = StorageSyncHelper.buildAccountRecord(context, Recipient.self().fresh());

View File

@ -152,8 +152,7 @@ public class StorageSyncJob extends BaseJob {
if (!keyDifference.isEmpty()) {
Log.i(TAG, "[Remote Newer] There's a difference in keys. Local-only: " + keyDifference.getLocalOnlyKeys().size() + ", Remote-only: " + keyDifference.getRemoteOnlyKeys().size());
Set<RecipientId> archivedRecipients = DatabaseFactory.getThreadDatabase(context).getArchivedRecipients();
List<SignalStorageRecord> localOnly = buildLocalStorageRecords(context, keyDifference.getLocalOnlyKeys(), archivedRecipients);
List<SignalStorageRecord> localOnly = buildLocalStorageRecords(context, keyDifference.getLocalOnlyKeys());
List<SignalStorageRecord> remoteOnly = accountManager.readStorageRecords(storageServiceKey, keyDifference.getRemoteOnlyKeys());
MergeResult mergeResult = StorageSyncHelper.resolveConflict(remoteOnly, localOnly);
WriteOperationResult writeOperationResult = StorageSyncHelper.createWriteOperation(remoteManifest.get().getVersion(), allLocalStorageKeys, mergeResult);
@ -184,6 +183,8 @@ public class StorageSyncJob extends BaseJob {
}
remoteManifestVersion = writeOperationResult.getManifest().getVersion();
needsMultiDeviceSync = true;
} else {
Log.i(TAG, "[Remote Newer] After resolving the conflict, all changes are local. No remote writes needed.");
}
@ -191,7 +192,6 @@ public class StorageSyncJob extends BaseJob {
recipientDatabase.applyStorageSyncUpdates(mergeResult.getLocalContactInserts(), mergeResult.getLocalContactUpdates(), mergeResult.getLocalGroupV1Inserts(), mergeResult.getLocalGroupV1Updates(), mergeResult.getLocalGroupV2Inserts(), mergeResult.getLocalGroupV2Updates());
storageKeyDatabase.applyStorageSyncUpdates(mergeResult.getLocalUnknownInserts(), mergeResult.getLocalUnknownDeletes());
StorageSyncHelper.applyAccountStorageSyncUpdates(context, mergeResult.getLocalAccountUpdate());
needsMultiDeviceSync = true;
Log.i(TAG, "[Remote Newer] Updating local manifest version to: " + remoteManifestVersion);
TextSecurePreferences.setStorageManifestVersion(context, remoteManifestVersion);
@ -212,15 +212,13 @@ public class StorageSyncJob extends BaseJob {
List<RecipientSettings> pendingDeletions = recipientDatabase.getPendingRecipientSyncDeletions();
Optional<SignalAccountRecord> pendingAccountInsert = StorageSyncHelper.getPendingAccountSyncInsert(context, self);
Optional<SignalAccountRecord> pendingAccountUpdate = StorageSyncHelper.getPendingAccountSyncUpdate(context, self);
Set<RecipientId> archivedRecipients = DatabaseFactory.getThreadDatabase(context).getArchivedRecipients();
Optional<LocalWriteResult> localWriteResult = StorageSyncHelper.buildStorageUpdatesForLocal(localManifestVersion,
allLocalStorageKeys,
pendingUpdates,
pendingInsertions,
pendingDeletions,
pendingAccountUpdate,
pendingAccountInsert,
archivedRecipients);
pendingAccountInsert);
if (localWriteResult.isPresent()) {
Log.i(TAG, String.format(Locale.ENGLISH, "[Local Changes] Local changes present. %d updates, %d inserts, %d deletes, account update: %b, account insert: %b.", pendingUpdates.size(), pendingInsertions.size(), pendingDeletions.size(), pendingAccountUpdate.isPresent(), pendingAccountInsert.isPresent()));
@ -273,7 +271,7 @@ public class StorageSyncJob extends BaseJob {
DatabaseFactory.getStorageKeyDatabase(context).getAllKeys());
}
private static @NonNull List<SignalStorageRecord> buildLocalStorageRecords(@NonNull Context context, @NonNull List<StorageId> ids, @NonNull Set<RecipientId> archivedRecipients) {
private static @NonNull List<SignalStorageRecord> buildLocalStorageRecords(@NonNull Context context, @NonNull List<StorageId> ids) {
Recipient self = Recipient.self().fresh();
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context);
@ -287,10 +285,10 @@ public class StorageSyncJob extends BaseJob {
case ManifestRecord.Identifier.Type.GROUPV2_VALUE:
RecipientSettings settings = recipientDatabase.getByStorageId(id.getRaw());
if (settings != null) {
if (settings.getGroupType() == RecipientDatabase.GroupType.SIGNAL_V2 && settings.getGroupMasterKey() == null) {
if (settings.getGroupType() == RecipientDatabase.GroupType.SIGNAL_V2 && settings.getSyncExtras().getGroupMasterKey() == null) {
Log.w(TAG, "Missing master key on gv2 recipient");
} else {
records.add(StorageSyncModels.localToRemoteRecord(settings, archivedRecipients));
records.add(StorageSyncModels.localToRemoteRecord(settings));
}
} else {
Log.w(TAG, "Missing local recipient model! Type: " + id.getType());

View File

@ -137,6 +137,10 @@ public final class KbsValues extends SignalStoreValues {
}
}
public synchronized @Nullable String getPin() {
return getString(PIN, null);
}
public synchronized @Nullable String getLocalPinHash() {
return getString(LOCK_LOCAL_PIN_HASH, null);
}

View File

@ -252,7 +252,7 @@ public class LinkPreviewRepository {
{
SignalExecutors.UNBOUNDED.execute(() -> {
try {
GroupInviteLinkUrl groupInviteLinkUrl = GroupInviteLinkUrl.fromUrl(groupUrl);
GroupInviteLinkUrl groupInviteLinkUrl = GroupInviteLinkUrl.fromUri(groupUrl);
if (groupInviteLinkUrl == null) {
throw new AssertionError();
}

View File

@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupChangeException;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupManager;
import org.thoughtcrime.securesms.groups.ui.GroupChangeErrorCallback;
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
@ -229,6 +230,10 @@ final class MessageRequestRepository {
});
}
boolean isPendingMember(@NonNull GroupId.V2 groupId) {
return DatabaseFactory.getGroupDatabase(context).isPendingMember(groupId, Recipient.self());
}
enum MessageRequestState {
/**
* Message request permission does not need to be gained at this time.

View File

@ -5,6 +5,7 @@ import android.content.Context;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.livedata.LiveDataTriple;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.util.Collections;
import java.util.List;
@ -28,6 +30,7 @@ public class MessageRequestViewModel extends ViewModel {
private final SingleLiveEvent<Status> status = new SingleLiveEvent<>();
private final SingleLiveEvent<GroupChangeFailureReason> failures = new SingleLiveEvent<>();
private final MutableLiveData<Recipient> recipient = new MutableLiveData<>();
private final LiveData<MessageData> messageData;
private final MutableLiveData<List<String>> groups = new MutableLiveData<>(Collections.emptyList());
private final MutableLiveData<GroupMemberCount> memberCount = new MutableLiveData<>(GroupMemberCount.ZERO);
private final MutableLiveData<DisplayState> displayState = new MutableLiveData<>();
@ -46,7 +49,8 @@ public class MessageRequestViewModel extends ViewModel {
};
private MessageRequestViewModel(MessageRequestRepository repository) {
this.repository = repository;
this.repository = repository;
this.messageData = LiveDataUtil.mapAsync(recipient, this::createMessageDataForRecipient);
}
public void setConversationInfo(@NonNull RecipientId recipientId, long threadId) {
@ -77,6 +81,10 @@ public class MessageRequestViewModel extends ViewModel {
return recipient;
}
public LiveData<MessageData> getMessageData() {
return messageData;
}
public LiveData<RecipientInfo> getRecipientInfo() {
return recipientInfo;
}
@ -152,6 +160,29 @@ public class MessageRequestViewModel extends ViewModel {
repository.getMemberCount(liveRecipient.getId(), memberCount::postValue);
}
@WorkerThread
private @NonNull MessageData createMessageDataForRecipient(@NonNull Recipient recipient) {
if (recipient.isBlocked()) {
if (recipient.isGroup()) {
return new MessageData(recipient, MessageClass.BLOCKED_GROUP);
} else {
return new MessageData(recipient, MessageClass.BLOCKED_INDIVIDUAL);
}
} else if (recipient.isGroup()) {
if (recipient.isPushV2Group()) {
if (repository.isPendingMember(recipient.requireGroupId().requireV2())) {
return new MessageData(recipient, MessageClass.GROUP_V2_INVITE);
} else {
return new MessageData(recipient, MessageClass.GROUP_V2_ADD);
}
} else {
return new MessageData(recipient, MessageClass.GROUP_V1);
}
} else {
return new MessageData(recipient, MessageClass.INDIVIDUAL);
}
}
@SuppressWarnings("ConstantConditions")
private void loadMessageRequestAccepted(@NonNull Recipient recipient) {
if (recipient.isBlocked()) {
@ -218,6 +249,33 @@ public class MessageRequestViewModel extends ViewModel {
DISPLAY_MESSAGE_REQUEST, DISPLAY_LEGACY, DISPLAY_NONE
}
public enum MessageClass {
BLOCKED_INDIVIDUAL,
BLOCKED_GROUP,
GROUP_V1,
GROUP_V2_INVITE,
GROUP_V2_ADD,
INDIVIDUAL
}
public static final class MessageData {
private final Recipient recipient;
private final MessageClass messageClass;
public MessageData(@NonNull Recipient recipient, @NonNull MessageClass messageClass) {
this.recipient = recipient;
this.messageClass = messageClass;
}
public @NonNull Recipient getRecipient() {
return recipient;
}
public @NonNull MessageClass getMessageClass() {
return messageClass;
}
}
public static class Factory implements ViewModelProvider.Factory {
private final Context context;

View File

@ -60,23 +60,33 @@ public class MessageRequestsBottomView extends ConstraintLayout {
busyIndicator = findViewById(R.id.message_request_busy_indicator);
}
public void setRecipient(@NonNull Recipient recipient) {
if (recipient.isBlocked()) {
if (recipient.isGroup()) {
public void setMessageData(@NonNull MessageRequestViewModel.MessageData messageData) {
Recipient recipient = messageData.getRecipient();
switch (messageData.getMessageClass()) {
case BLOCKED_INDIVIDUAL:
question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_wont_receive_any_messages_until_you_unblock_them,
HtmlUtil.bold(recipient.getShortDisplayName(getContext()))), 0));
setActiveInactiveGroups(blockedButtons, normalButtons);
break;
case BLOCKED_GROUP:
question.setText(R.string.MessageRequestBottomView_unblock_this_group_and_share_your_name_and_photo_with_its_members);
} else {
String name = recipient.getShortDisplayName(getContext());
question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_wont_receive_any_messages_until_you_unblock_them, HtmlUtil.bold(name)), 0));
}
setActiveInactiveGroups(blockedButtons, normalButtons);
} else {
if (recipient.isGroup()) {
setActiveInactiveGroups(blockedButtons, normalButtons);
break;
case GROUP_V1:
case GROUP_V2_INVITE:
question.setText(R.string.MessageRequestBottomView_do_you_want_to_join_this_group_they_wont_know_youve_seen_their_messages_until_you_accept);
} else {
String name = recipient.getShortDisplayName(getContext());
question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_they_wont_know_youve_seen_their_messages_until_you_accept, HtmlUtil.bold(name)), 0));
}
setActiveInactiveGroups(normalButtons, blockedButtons);
setActiveInactiveGroups(normalButtons, blockedButtons);
break;
case GROUP_V2_ADD:
question.setText(R.string.MessageRequestBottomView_join_this_group_they_wont_know_youve_seen_their_messages_until_you_accept);
setActiveInactiveGroups(normalButtons, blockedButtons);
break;
case INDIVIDUAL:
question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_they_wont_know_youve_seen_their_messages_until_you_accept,
HtmlUtil.bold(recipient.getShortDisplayName(getContext()))), 0));
setActiveInactiveGroups(normalButtons, blockedButtons);
break;
}
}

View File

@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.migrations;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobs.KbsEnclaveMigrationWorkerJob;
/**
* A job to be run whenever we add a new KBS enclave. In order to prevent this moderately-expensive
* task from blocking the network for too long, this task simply enqueues another non-migration job,
* {@link KbsEnclaveMigrationWorkerJob}, to do the heavy lifting.
*/
public class KbsEnclaveMigrationJob extends MigrationJob {
public static final String KEY = "KbsEnclaveMigrationJob";
KbsEnclaveMigrationJob() {
this(new Parameters.Builder().build());
}
private KbsEnclaveMigrationJob(@NonNull Parameters parameters) {
super(parameters);
}
@Override
public boolean isUiBlocking() {
return false;
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public void performMigration() {
ApplicationDependencies.getJobManager().add(new KbsEnclaveMigrationWorkerJob());
}
@Override
boolean shouldRetry(@NonNull Exception e) {
return false;
}
public static class Factory implements Job.Factory<KbsEnclaveMigrationJob> {
@Override
public @NonNull KbsEnclaveMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) {
return new KbsEnclaveMigrationJob(parameters);
}
}
}

View File

@ -53,7 +53,7 @@ public class AudioSlide extends Slide {
@Override
public boolean hasImage() {
return true;
return false;
}
@Override

View File

@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.pin;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.KbsEnclave;
import org.thoughtcrime.securesms.util.Util;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public final class KbsEnclaves {
public static @NonNull KbsEnclave current() {
return BuildConfig.KBS_ENCLAVE;
}
public static @NonNull List<KbsEnclave> all() {
return Util.join(Collections.singletonList(BuildConfig.KBS_ENCLAVE), fallbacks());
}
public static @NonNull List<KbsEnclave> fallbacks() {
return Arrays.asList(BuildConfig.KBS_FALLBACKS);
}
}

View File

@ -1,11 +1,15 @@
package org.thoughtcrime.securesms.pin;
import androidx.annotation.NonNull;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.KbsEnclave;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob;
import org.thoughtcrime.securesms.jobs.StorageSyncJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.registration.service.KeyBackupSystemWrongPinException;
import org.thoughtcrime.securesms.util.Stopwatch;
@ -21,32 +25,58 @@ import java.util.Objects;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
class PinRestoreRepository {
public class PinRestoreRepository {
private static final String TAG = Log.tag(PinRestoreRepository.class);
private final Executor executor = SignalExecutors.UNBOUNDED;
private final KeyBackupService kbs = ApplicationDependencies.getKeyBackupService();
private final Executor executor = SignalExecutors.UNBOUNDED;
void getToken(@NonNull Callback<Optional<TokenData>> callback) {
executor.execute(() -> {
try {
String authorization = kbs.getAuthorization();
TokenResponse token = kbs.getToken(authorization);
TokenData tokenData = new TokenData(authorization, token);
callback.onComplete(Optional.of(tokenData));
callback.onComplete(Optional.fromNullable(getTokenSync(null)));
} catch (IOException e) {
callback.onComplete(Optional.absent());
}
});
}
/**
* @param authorization If this is being called before the user is registered (i.e. as part of
* reglock), you must pass in an authorization token that can be used to
* retrieve a backup. Otherwise, pass in null and we'll fetch one.
*/
public @NonNull TokenData getTokenSync(@Nullable String authorization) throws IOException {
TokenData firstKnownTokenData = null;
for (KbsEnclave enclave : KbsEnclaves.all()) {
KeyBackupService kbs = ApplicationDependencies.getKeyBackupService(enclave);
authorization = authorization == null ? kbs.getAuthorization() : authorization;
TokenResponse token = kbs.getToken(authorization);
TokenData tokenData = new TokenData(enclave, authorization, token);
if (tokenData.getTriesRemaining() > 0) {
Log.i(TAG, "Found data! " + enclave.getEnclaveName());
return tokenData;
} else if (firstKnownTokenData == null) {
Log.i(TAG, "No data, but storing as the first response. " + enclave.getEnclaveName());
firstKnownTokenData = tokenData;
} else {
Log.i(TAG, "No data, and we already have a 'first response'. " + enclave.getEnclaveName());
}
}
return Objects.requireNonNull(firstKnownTokenData);
}
void submitPin(@NonNull String pin, @NonNull TokenData tokenData, @NonNull Callback<PinResultData> callback) {
executor.execute(() -> {
try {
Stopwatch stopwatch = new Stopwatch("PinSubmission");
KbsPinData kbsData = PinState.restoreMasterKey(pin, tokenData.basicAuth, tokenData.tokenResponse);
KbsPinData kbsData = PinState.restoreMasterKey(pin, tokenData.getEnclave(), tokenData.getBasicAuth(), tokenData.getTokenResponse());
PinState.onSignalPinRestore(ApplicationDependencies.getApplication(), Objects.requireNonNull(kbsData), pin);
stopwatch.split("MasterKey");
@ -64,7 +94,7 @@ class PinRestoreRepository {
} catch (KeyBackupSystemNoDataException e) {
callback.onComplete(new PinResultData(PinResult.LOCKED, tokenData));
} catch (KeyBackupSystemWrongPinException e) {
callback.onComplete(new PinResultData(PinResult.INCORRECT, new TokenData(tokenData.basicAuth, e.getTokenResponse())));
callback.onComplete(new PinResultData(PinResult.INCORRECT, TokenData.withResponse(tokenData, e.getTokenResponse())));
}
});
}
@ -73,18 +103,81 @@ class PinRestoreRepository {
void onComplete(@NonNull T value);
}
static class TokenData {
public static class TokenData implements Parcelable {
private final KbsEnclave enclave;
private final String basicAuth;
private final TokenResponse tokenResponse;
TokenData(@NonNull String basicAuth, @NonNull TokenResponse tokenResponse) {
TokenData(@NonNull KbsEnclave enclave, @NonNull String basicAuth, @NonNull TokenResponse tokenResponse) {
this.enclave = enclave;
this.basicAuth = basicAuth;
this.tokenResponse = tokenResponse;
}
int getTriesRemaining() {
private TokenData(Parcel in) {
//noinspection ConstantConditions
this.enclave = new KbsEnclave(in.readString(), in.readString(), in.readString());
this.basicAuth = in.readString();
byte[] backupId = new byte[0];
byte[] token = new byte[0];
in.readByteArray(backupId);
in.readByteArray(token);
this.tokenResponse = new TokenResponse(backupId, token, in.readInt());
}
public static @NonNull TokenData withResponse(@NonNull TokenData data, @NonNull TokenResponse response) {
return new TokenData(data.getEnclave(), data.getBasicAuth(), response);
}
public int getTriesRemaining() {
return tokenResponse.getTries();
}
public @NonNull String getBasicAuth() {
return basicAuth;
}
public @NonNull TokenResponse getTokenResponse() {
return tokenResponse;
}
public @NonNull KbsEnclave getEnclave() {
return enclave;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(enclave.getEnclaveName());
dest.writeString(enclave.getServiceId());
dest.writeString(enclave.getMrEnclave());
dest.writeString(basicAuth);
dest.writeByteArray(tokenResponse.getBackupId());
dest.writeByteArray(tokenResponse.getToken());
dest.writeInt(tokenResponse.getTries());
}
public static final Creator<TokenData> CREATOR = new Creator<TokenData>() {
@Override
public TokenData createFromParcel(Parcel in) {
return new TokenData(in);
}
@Override
public TokenData[] newArray(int size) {
return new TokenData[size];
}
};
}
static class PinResultData {
@ -92,7 +185,7 @@ class PinRestoreRepository {
private final TokenData tokenData;
PinResultData(@NonNull PinResult result, @NonNull TokenData tokenData) {
this.result = result;
this.result = result;
this.tokenData = tokenData;
}

View File

@ -6,8 +6,11 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.KbsEnclave;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.JobTracker;
import org.thoughtcrime.securesms.jobs.ClearFallbackKbsEnclaveJob;
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob;
import org.thoughtcrime.securesms.jobs.StorageForcePushJob;
import org.thoughtcrime.securesms.keyvalue.KbsValues;
@ -26,12 +29,14 @@ import org.whispersystems.signalservice.api.KeyBackupServicePinException;
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException;
import org.whispersystems.signalservice.api.kbs.HashedPin;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException;
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
@ -46,6 +51,7 @@ public final class PinState {
* Does not affect {@link PinState}.
*/
public static synchronized @Nullable KbsPinData restoreMasterKey(@Nullable String pin,
@NonNull KbsEnclave enclave,
@Nullable String basicStorageCredentials,
@NonNull TokenResponse tokenResponse)
throws IOException, KeyBackupSystemWrongPinException, KeyBackupSystemNoDataException
@ -58,20 +64,31 @@ public final class PinState {
throw new AssertionError("Cannot restore KBS key, no storage credentials supplied");
}
KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService();
Log.i(TAG, "Preparing to restore from " + enclave.getEnclaveName());
return restoreMasterKeyFromEnclave(enclave, pin, basicStorageCredentials, tokenResponse);
}
Log.i(TAG, "Opening key backup service session");
KeyBackupService.RestoreSession session = keyBackupService.newRegistrationSession(basicStorageCredentials, tokenResponse);
private static @NonNull KbsPinData restoreMasterKeyFromEnclave(@NonNull KbsEnclave enclave,
@NonNull String pin,
@NonNull String basicStorageCredentials,
@NonNull TokenResponse tokenResponse)
throws IOException, KeyBackupSystemWrongPinException, KeyBackupSystemNoDataException
{
KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService(enclave);
KeyBackupService.RestoreSession session = keyBackupService.newRegistrationSession(basicStorageCredentials, tokenResponse);
try {
Log.i(TAG, "Restoring pin from KBS");
HashedPin hashedPin = PinHashing.hashPin(pin, session);
KbsPinData kbsData = session.restorePin(hashedPin);
if (kbsData != null) {
Log.i(TAG, "Found registration lock token on KBS.");
} else {
throw new AssertionError("Null not expected");
}
return kbsData;
} catch (UnauthenticatedResponseException e) {
Log.w(TAG, "Failed to restore key", e);
@ -90,7 +107,7 @@ public final class PinState {
@Nullable String pin,
boolean hasPinToRestore)
{
Log.i(TAG, "onNewRegistration()");
Log.i(TAG, "onRegistration()");
TextSecurePreferences.setV1RegistrationLockPin(context, pin);
@ -106,7 +123,8 @@ public final class PinState {
SignalStore.kbsValues().setV2RegistrationLockEnabled(true);
SignalStore.kbsValues().setKbsMasterKey(kbsData, pin);
SignalStore.pinValues().resetPinReminders();
resetPinRetryCount(context, pin, kbsData);
resetPinRetryCount(context, pin);
ClearFallbackKbsEnclaveJob.clearAll();
} else if (hasPinToRestore) {
Log.i(TAG, "Has a PIN to restore.");
SignalStore.kbsValues().clearRegistrationLockAndPin();
@ -131,7 +149,8 @@ public final class PinState {
SignalStore.kbsValues().setV2RegistrationLockEnabled(false);
SignalStore.pinValues().resetPinReminders();
SignalStore.storageServiceValues().setNeedsAccountRestore(false);
resetPinRetryCount(context, pin, kbsData);
resetPinRetryCount(context, pin);
ClearFallbackKbsEnclaveJob.clearAll();
updateState(buildInferredStateFromOtherFields());
}
@ -158,7 +177,7 @@ public final class PinState {
KbsValues kbsValues = SignalStore.kbsValues();
boolean isFirstPin = !kbsValues.hasPin() || kbsValues.hasOptedOut();
MasterKey masterKey = kbsValues.getOrCreateMasterKey();
KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService();
KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService(KbsEnclaves.current());
KeyBackupService.PinChangeSession pinChangeSession = keyBackupService.newPinChangeSession();
HashedPin hashedPin = PinHashing.hashPin(pin, pinChangeSession);
KbsPinData kbsData = pinChangeSession.setPin(hashedPin, masterKey);
@ -217,7 +236,7 @@ public final class PinState {
assertState(State.PIN_WITH_REGISTRATION_LOCK_DISABLED);
SignalStore.kbsValues().setV2RegistrationLockEnabled(false);
ApplicationDependencies.getKeyBackupService()
ApplicationDependencies.getKeyBackupService(KbsEnclaves.current())
.newPinChangeSession(SignalStore.kbsValues().getRegistrationLockTokenResponse())
.enableRegistrationLock(SignalStore.kbsValues().getOrCreateMasterKey());
SignalStore.kbsValues().setV2RegistrationLockEnabled(true);
@ -240,7 +259,7 @@ public final class PinState {
assertState(State.PIN_WITH_REGISTRATION_LOCK_ENABLED);
SignalStore.kbsValues().setV2RegistrationLockEnabled(true);
ApplicationDependencies.getKeyBackupService()
ApplicationDependencies.getKeyBackupService(KbsEnclaves.current())
.newPinChangeSession(SignalStore.kbsValues().getRegistrationLockTokenResponse())
.disableRegistrationLock();
SignalStore.kbsValues().setV2RegistrationLockEnabled(false);
@ -259,7 +278,7 @@ public final class PinState {
KbsValues kbsValues = SignalStore.kbsValues();
MasterKey masterKey = kbsValues.getOrCreateMasterKey();
KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService();
KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService(KbsEnclaves.current());
KeyBackupService.PinChangeSession pinChangeSession = keyBackupService.newPinChangeSession();
HashedPin hashedPin = PinHashing.hashPin(pin, pinChangeSession);
KbsPinData kbsData = pinChangeSession.setPin(hashedPin, masterKey);
@ -272,6 +291,22 @@ public final class PinState {
updateState(buildInferredStateFromOtherFields());
}
/**
* Should only be called by {@link org.thoughtcrime.securesms.jobs.KbsEnclaveMigrationWorkerJob}.
*/
@WorkerThread
public static synchronized void onMigrateToNewEnclave(@NonNull String pin)
throws IOException, UnauthenticatedResponseException
{
Log.i(TAG, "onMigrateToNewEnclave()");
assertState(State.PIN_WITH_REGISTRATION_LOCK_DISABLED, State.PIN_WITH_REGISTRATION_LOCK_ENABLED);
Log.i(TAG, "Migrating to enclave " + KbsEnclaves.current().getEnclaveName());
setPinOnEnclave(KbsEnclaves.current(), pin, SignalStore.kbsValues().getOrCreateMasterKey());
ClearFallbackKbsEnclaveJob.clearAll();
}
@WorkerThread
private static void bestEffortRefreshAttributes() {
Optional<JobTracker.JobState> result = ApplicationDependencies.getJobManager().runSynchronously(new RefreshAttributesJob(), TimeUnit.SECONDS.toMillis(10));
@ -301,23 +336,14 @@ public final class PinState {
}
@WorkerThread
private static void resetPinRetryCount(@NonNull Context context, @Nullable String pin, @NonNull KbsPinData kbsData) {
private static void resetPinRetryCount(@NonNull Context context, @Nullable String pin) {
if (pin == null) {
return;
}
KeyBackupService keyBackupService = ApplicationDependencies.getKeyBackupService();
try {
KbsValues kbsValues = SignalStore.kbsValues();
MasterKey masterKey = kbsValues.getOrCreateMasterKey();
KeyBackupService.PinChangeSession pinChangeSession = keyBackupService.newPinChangeSession(kbsData.getTokenResponse());
HashedPin hashedPin = PinHashing.hashPin(pin, pinChangeSession);
KbsPinData newData = pinChangeSession.setPin(hashedPin, masterKey);
kbsValues.setKbsMasterKey(newData, pin);
setPinOnEnclave(KbsEnclaves.current(), pin, SignalStore.kbsValues().getOrCreateMasterKey());
TextSecurePreferences.clearRegistrationLockV1(context);
Log.i(TAG, "Pin set/attempts reset on KBS");
} catch (IOException e) {
Log.w(TAG, "May have failed to reset pin attempts!", e);
@ -326,6 +352,20 @@ public final class PinState {
}
}
@WorkerThread
private static @NonNull KbsPinData setPinOnEnclave(@NonNull KbsEnclave enclave, @NonNull String pin, @NonNull MasterKey masterKey)
throws IOException, UnauthenticatedResponseException
{
KeyBackupService kbs = ApplicationDependencies.getKeyBackupService(enclave);
KeyBackupService.PinChangeSession pinChangeSession = kbs.newPinChangeSession();
HashedPin hashedPin = PinHashing.hashPin(pin, pinChangeSession);
KbsPinData newData = pinChangeSession.setPin(hashedPin, masterKey);
SignalStore.kbsValues().setKbsMasterKey(newData, pin);
return newData;
}
@WorkerThread
private static void optOutOfPin() {
SignalStore.kbsValues().optOut();

View File

@ -102,8 +102,6 @@ public class Recipient {
private final Capability groupsV2Capability;
private final InsightsBannerTier insightsBannerTier;
private final byte[] storageId;
private final byte[] identityKey;
private final VerifiedStatus identityStatus;
private final MentionSetting mentionSetting;
@ -317,8 +315,6 @@ public class Recipient {
this.uuidCapability = Capability.UNKNOWN;
this.groupsV2Capability = Capability.UNKNOWN;
this.storageId = null;
this.identityKey = null;
this.identityStatus = VerifiedStatus.DEFAULT;
this.mentionSetting = MentionSetting.ALWAYS_NOTIFY;
}
@ -361,8 +357,6 @@ public class Recipient {
this.uuidCapability = details.uuidCapability;
this.groupsV2Capability = details.groupsV2Capability;
this.storageId = details.storageId;
this.identityKey = details.identityKey;
this.identityStatus = details.identityStatus;
this.mentionSetting = details.mentionSetting;
}
@ -782,14 +776,6 @@ public class Recipient {
return storageId;
}
public @NonNull VerifiedStatus getIdentityVerifiedStatus() {
return identityStatus;
}
public @Nullable byte[] getIdentityKey() {
return identityKey;
}
public @NonNull UnidentifiedAccessMode getUnidentifiedAccessMode() {
return unidentifiedAccessMode;
}

View File

@ -65,9 +65,7 @@ public class RecipientDetails {
final Recipient.Capability uuidCapability;
final Recipient.Capability groupsV2Capability;
final InsightsBannerTier insightsBannerTier;
final byte[] storageId;
final byte[] identityKey;
final VerifiedStatus identityStatus;
final byte[] storageId;
final MentionSetting mentionSetting;
public RecipientDetails(@Nullable String name,
@ -113,8 +111,6 @@ public class RecipientDetails {
this.groupsV2Capability = settings.getGroupsV2Capability();
this.insightsBannerTier = settings.getInsightsBannerTier();
this.storageId = settings.getStorageId();
this.identityKey = settings.getIdentityKey();
this.identityStatus = settings.getIdentityStatus();
this.mentionSetting = settings.getMentionSetting();
if (name == null) this.name = settings.getSystemDisplayName();
@ -162,8 +158,6 @@ public class RecipientDetails {
this.uuidCapability = Recipient.Capability.UNKNOWN;
this.groupsV2Capability = Recipient.Capability.UNKNOWN;
this.storageId = null;
this.identityKey = null;
this.identityStatus = VerifiedStatus.DEFAULT;
this.mentionSetting = MentionSetting.ALWAYS_NOTIFY;
}

View File

@ -71,9 +71,9 @@ public class CustomNotificationsDialogFragment extends DialogFragment {
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (ThemeUtil.isDarkTheme(requireActivity())) {
setStyle(STYLE_NO_FRAME, R.style.TextSecure_DarkTheme);
setStyle(STYLE_NO_FRAME, R.style.TextSecure_DarkTheme_AnimatedDialog);
} else {
setStyle(STYLE_NO_FRAME, R.style.TextSecure_LightTheme);
setStyle(STYLE_NO_FRAME, R.style.TextSecure_LightTheme_AnimatedDialog);
}
}

View File

@ -10,6 +10,7 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.widget.SwitchCompat;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.DialogFragment;
@ -43,8 +44,8 @@ public final class ShareableGroupLinkDialogFragment extends DialogFragment {
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setStyle(STYLE_NO_FRAME, ThemeUtil.isDarkTheme(requireActivity()) ? R.style.TextSecure_DarkTheme
: R.style.TextSecure_LightTheme);
setStyle(STYLE_NO_FRAME, ThemeUtil.isDarkTheme(requireActivity()) ? R.style.TextSecure_DarkTheme_AnimatedDialog
: R.style.TextSecure_LightTheme_AnimatedDialog);
}
@Override
@ -101,16 +102,19 @@ public final class ShareableGroupLinkDialogFragment extends DialogFragment {
shareRow.setOnClickListener(v -> GroupLinkBottomSheetDialogFragment.show(requireFragmentManager(), groupId));
shareableGroupLinkRow.setOnClickListener(v -> viewModel.onToggleGroupLink(requireContext()));
approveNewMembersRow.setOnClickListener(v -> viewModel.onToggleApproveMembers(requireContext()));
resetLinkRow.setOnClickListener(v ->
new AlertDialog.Builder(requireContext())
.setMessage(R.string.ShareableGroupLinkDialogFragment__are_you_sure_you_want_to_reset_the_group_link)
.setPositiveButton(R.string.ShareableGroupLinkDialogFragment__reset_link, (dialog, which) -> viewModel.onResetLink(requireContext()))
.setNegativeButton(android.R.string.cancel, null)
.show());
viewModel.getCanEdit().observe(getViewLifecycleOwner(), canEdit -> {
if (canEdit) {
shareableGroupLinkRow.setOnClickListener(v -> viewModel.onToggleGroupLink());
approveNewMembersRow.setOnClickListener(v -> viewModel.onToggleApproveMembers());
resetLinkRow.setOnClickListener(v -> onResetGroupLink());
} else {
shareableGroupLinkRow.setOnClickListener(v -> toast(R.string.ManageGroupActivity_only_admins_can_enable_or_disable_the_sharable_group_link));
approveNewMembersRow.setOnClickListener(v -> toast(R.string.ManageGroupActivity_only_admins_can_enable_or_disable_the_option_to_approve_new_members));
resetLinkRow.setOnClickListener(v -> toast(R.string.ManageGroupActivity_only_admins_can_reset_the_sharable_group_link));
}
});
viewModel.getToasts().observe(getViewLifecycleOwner(), t -> Toast.makeText(requireContext(), t, Toast.LENGTH_SHORT).show());
viewModel.getToasts().observe(getViewLifecycleOwner(), this::toast);
viewModel.getBusy().observe(getViewLifecycleOwner(), busy -> {
if (busy) {
@ -126,6 +130,18 @@ public final class ShareableGroupLinkDialogFragment extends DialogFragment {
});
}
private void onResetGroupLink() {
new AlertDialog.Builder(requireContext())
.setMessage(R.string.ShareableGroupLinkDialogFragment__are_you_sure_you_want_to_reset_the_group_link)
.setPositiveButton(R.string.ShareableGroupLinkDialogFragment__reset_link, (dialog, which) -> viewModel.onResetLink())
.setNegativeButton(android.R.string.cancel, null)
.show();
}
protected void toast(@StringRes int message) {
Toast.makeText(requireContext(), getString(message), Toast.LENGTH_SHORT).show();
}
/**
* Inserts zero width space characters between each character in the original ensuring it takes
* the full width of the TextView.

View File

@ -1,14 +1,11 @@
package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.LiveGroup;
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
@ -21,12 +18,16 @@ final class ShareableGroupLinkViewModel extends ViewModel {
private final ShareableGroupLinkRepository repository;
private final LiveData<GroupLinkUrlAndStatus> groupLink;
private final SingleLiveEvent<String> toasts;
private final SingleLiveEvent<Integer> toasts;
private final SingleLiveEvent<Boolean> busy;
private final LiveData<Boolean> canEdit;
private ShareableGroupLinkViewModel(@NonNull GroupId.V2 groupId, @NonNull ShareableGroupLinkRepository repository) {
LiveGroup liveGroup = new LiveGroup(groupId);
this.repository = repository;
this.groupLink = new LiveGroup(groupId).getGroupLink();
this.groupLink = liveGroup.getGroupLink();
this.canEdit = liveGroup.isSelfAdmin();
this.toasts = new SingleLiveEvent<>();
this.busy = new SingleLiveEvent<>();
}
@ -35,7 +36,7 @@ final class ShareableGroupLinkViewModel extends ViewModel {
return groupLink;
}
LiveData<String> getToasts() {
LiveData<Integer> getToasts() {
return toasts;
}
@ -43,7 +44,11 @@ final class ShareableGroupLinkViewModel extends ViewModel {
return busy;
}
void onToggleGroupLink(@NonNull Context context) {
LiveData<Boolean> getCanEdit() {
return canEdit;
}
void onToggleGroupLink() {
busy.setValue(true);
repository.toggleGroupLinkEnabled(new AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason>() {
@Override
@ -54,12 +59,12 @@ final class ShareableGroupLinkViewModel extends ViewModel {
@Override
public void onError(@Nullable GroupChangeFailureReason error) {
busy.postValue(false);
toasts.postValue(context.getString(GroupErrors.getUserDisplayMessage(error)));
toasts.postValue(GroupErrors.getUserDisplayMessage(error));
}
});
}
void onToggleApproveMembers(@NonNull Context context) {
void onToggleApproveMembers() {
busy.setValue(true);
repository.toggleGroupLinkApprovalRequired(new AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason>() {
@Override
@ -70,24 +75,23 @@ final class ShareableGroupLinkViewModel extends ViewModel {
@Override
public void onError(@Nullable GroupChangeFailureReason error) {
busy.postValue(false);
toasts.postValue(context.getString(GroupErrors.getUserDisplayMessage(error)));
toasts.postValue(GroupErrors.getUserDisplayMessage(error));
}
});
}
void onResetLink(@NonNull Context context) {
void onResetLink() {
busy.setValue(true);
repository.cycleGroupLinkPassword(new AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason>() {
@Override
public void onComplete(@Nullable Void result) {
busy.postValue(false);
toasts.postValue(context.getString(R.string.ShareableGroupLinkDialogFragment__group_link_reset));
}
@Override
public void onError(@Nullable GroupChangeFailureReason error) {
busy.postValue(false);
toasts.postValue(context.getString(GroupErrors.getUserDisplayMessage(error)));
toasts.postValue(GroupErrors.getUserDisplayMessage(error));
}
});
}

View File

@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.components.registration.CallMeCountDownView;
import org.thoughtcrime.securesms.components.registration.VerificationCodeView;
import org.thoughtcrime.securesms.components.registration.VerificationPinKeyboard;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.pin.PinRestoreRepository;
import org.thoughtcrime.securesms.registration.ReceivedSmsEvent;
import org.thoughtcrime.securesms.registration.service.CodeVerificationRequest;
import org.thoughtcrime.securesms.registration.service.RegistrationCodeRequest;
@ -30,7 +31,6 @@ import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.SupportEmailUtil;
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
import java.util.ArrayList;
import java.util.Collections;
@ -107,7 +107,7 @@ public final class EnterCodeFragment extends BaseRegistrationFragment {
RegistrationService registrationService = RegistrationService.getInstance(model.getNumber().getE164Number(), model.getRegistrationSecret());
registrationService.verifyAccount(requireActivity(), model.getFcmToken(), code, null, null, null,
registrationService.verifyAccount(requireActivity(), model.getFcmToken(), code, null, null,
new CodeVerificationRequest.VerifyCallback() {
@Override
@ -133,10 +133,9 @@ public final class EnterCodeFragment extends BaseRegistrationFragment {
}
@Override
public void onKbsRegistrationLockPinRequired(long timeRemaining, @NonNull TokenResponse tokenResponse, @NonNull String kbsStorageCredentials) {
public void onKbsRegistrationLockPinRequired(long timeRemaining, @NonNull PinRestoreRepository.TokenData tokenData, @NonNull String kbsStorageCredentials) {
model.setLockedTimeRemaining(timeRemaining);
model.setStorageCredentials(kbsStorageCredentials);
model.setKeyBackupCurrentToken(tokenResponse);
model.setKeyBackupTokenData(tokenData);
keyboard.displayLocked().addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean r) {
@ -147,7 +146,7 @@ public final class EnterCodeFragment extends BaseRegistrationFragment {
}
@Override
public void onIncorrectKbsRegistrationLockPin(@NonNull TokenResponse tokenResponse) {
public void onIncorrectKbsRegistrationLockPin(@NonNull PinRestoreRepository.TokenData tokenData) {
throw new AssertionError("Unexpected, user has made no pin guesses");
}

View File

@ -25,12 +25,12 @@ import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.pin.PinRestoreRepository.TokenData;
import org.thoughtcrime.securesms.registration.service.CodeVerificationRequest;
import org.thoughtcrime.securesms.registration.service.RegistrationService;
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
import java.util.concurrent.TimeUnit;
@ -106,10 +106,10 @@ public final class RegistrationLockFragment extends BaseRegistrationFragment {
getModel().getLockedTimeRemaining()
.observe(getViewLifecycleOwner(), t -> timeRemaining = t);
TokenResponse keyBackupCurrentToken = getModel().getKeyBackupCurrentToken();
TokenData keyBackupCurrentToken = getModel().getKeyBackupCurrentToken();
if (keyBackupCurrentToken != null) {
int triesRemaining = keyBackupCurrentToken.getTries();
int triesRemaining = keyBackupCurrentToken.getTriesRemaining();
if (triesRemaining <= 3) {
int daysRemaining = getLockoutDays(timeRemaining);
@ -158,8 +158,7 @@ public final class RegistrationLockFragment extends BaseRegistrationFragment {
RegistrationViewModel model = getModel();
RegistrationService registrationService = RegistrationService.getInstance(model.getNumber().getE164Number(), model.getRegistrationSecret());
TokenResponse tokenResponse = model.getKeyBackupCurrentToken();
String basicStorageCredentials = model.getBasicStorageCredentials();
TokenData tokenData = model.getKeyBackupCurrentToken();
setSpinning(pinButton);
@ -167,8 +166,7 @@ public final class RegistrationLockFragment extends BaseRegistrationFragment {
model.getFcmToken(),
model.getTextCodeEntered(),
pin,
basicStorageCredentials,
tokenResponse,
tokenData,
new CodeVerificationRequest.VerifyCallback() {
@ -189,19 +187,19 @@ public final class RegistrationLockFragment extends BaseRegistrationFragment {
}
@Override
public void onKbsRegistrationLockPinRequired(long timeRemaining, @NonNull TokenResponse kbsTokenResponse, @NonNull String kbsStorageCredentials) {
public void onKbsRegistrationLockPinRequired(long timeRemaining, @NonNull TokenData kbsTokenData, @NonNull String kbsStorageCredentials) {
throw new AssertionError("Not expected after a pin guess");
}
@Override
public void onIncorrectKbsRegistrationLockPin(@NonNull TokenResponse tokenResponse) {
public void onIncorrectKbsRegistrationLockPin(@NonNull TokenData tokenData) {
cancelSpinning(pinButton);
pinEntry.getText().clear();
enableAndFocusPinEntry();
model.setKeyBackupCurrentToken(tokenResponse);
model.setKeyBackupTokenData(tokenData);
int triesRemaining = tokenResponse.getTries();
int triesRemaining = tokenData.getTriesRemaining();
if (triesRemaining == 0) {
Log.w(TAG, "Account locked. User out of attempts on KBS.");

View File

@ -22,6 +22,8 @@ import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
import org.thoughtcrime.securesms.jobs.RotateCertificateJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.pin.PinRestoreRepository;
import org.thoughtcrime.securesms.pin.PinRestoreRepository.TokenData;
import org.thoughtcrime.securesms.pin.PinState;
import org.thoughtcrime.securesms.push.AccountManagerFactory;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -65,41 +67,40 @@ public final class CodeVerificationRequest {
/**
* Asynchronously verify the account via the code.
*
* @param fcmToken The FCM token for the device.
* @param code The code that was delivered to the user.
* @param pin The users registration pin.
* @param callback Exactly one method on this callback will be called.
* @param kbsTokenResponse By keeping the token, on failure, a newly returned token will be reused in subsequent pin
* attempts, preventing certain attacks, we can also track the attempts making missing replies easier to spot.
* @param fcmToken The FCM token for the device.
* @param code The code that was delivered to the user.
* @param pin The users registration pin.
* @param callback Exactly one method on this callback will be called.
* @param kbsTokenData By keeping the token, on failure, a newly returned token will be reused in subsequent pin
* attempts, preventing certain attacks, we can also track the attempts making missing replies easier to spot.
*/
static void verifyAccount(@NonNull Context context,
@NonNull Credentials credentials,
@Nullable String fcmToken,
@NonNull String code,
@Nullable String pin,
@Nullable String basicStorageCredentials,
@Nullable TokenResponse kbsTokenResponse,
@Nullable TokenData kbsTokenData,
@NonNull VerifyCallback callback)
{
new AsyncTask<Void, Void, Result>() {
private volatile LockedException lockedException;
private volatile TokenResponse kbsToken;
private volatile TokenData tokenData;
@Override
protected Result doInBackground(Void... voids) {
final boolean pinSupplied = pin != null;
final boolean tryKbs = kbsTokenResponse != null;
final boolean tryKbs = tokenData != null;
try {
kbsToken = kbsTokenResponse;
verifyAccount(context, credentials, code, pin, kbsTokenResponse, basicStorageCredentials, fcmToken);
this.tokenData = kbsTokenData;
verifyAccount(context, credentials, code, pin, tokenData, fcmToken);
return Result.SUCCESS;
} catch (KeyBackupSystemNoDataException e) {
Log.w(TAG, "No data found on KBS");
return Result.KBS_ACCOUNT_LOCKED;
} catch (KeyBackupSystemWrongPinException e) {
kbsToken = e.getTokenResponse();
tokenData = TokenData.withResponse(tokenData, e.getTokenResponse());
return Result.KBS_WRONG_PIN;
} catch (LockedException e) {
if (pinSupplied && tryKbs) {
@ -110,8 +111,8 @@ public final class CodeVerificationRequest {
lockedException = e;
if (e.getBasicStorageCredentials() != null) {
try {
kbsToken = getToken(e.getBasicStorageCredentials());
if (kbsToken == null || kbsToken.getTries() == 0) {
tokenData = getToken(e.getBasicStorageCredentials());
if (tokenData == null || tokenData.getTriesRemaining() == 0) {
return Result.KBS_ACCOUNT_LOCKED;
}
} catch (IOException ex) {
@ -137,12 +138,12 @@ public final class CodeVerificationRequest {
callback.onSuccessfulRegistration();
break;
case PIN_LOCKED:
if (kbsToken != null) {
if (tokenData != null) {
if (lockedException.getBasicStorageCredentials() == null) {
throw new AssertionError("KBS Token set, but no storage credentials supplied.");
}
Log.w(TAG, "Reg Locked: V2 pin needed for registration");
callback.onKbsRegistrationLockPinRequired(lockedException.getTimeRemaining(), kbsToken, lockedException.getBasicStorageCredentials());
callback.onKbsRegistrationLockPinRequired(lockedException.getTimeRemaining(), tokenData, lockedException.getBasicStorageCredentials());
} else {
Log.w(TAG, "Reg Locked: V1 pin needed for registration");
callback.onV1RegistrationLockPinRequiredOrIncorrect(lockedException.getTimeRemaining());
@ -156,7 +157,7 @@ public final class CodeVerificationRequest {
break;
case KBS_WRONG_PIN:
Log.w(TAG, "KBS Pin was wrong");
callback.onIncorrectKbsRegistrationLockPin(kbsToken);
callback.onIncorrectKbsRegistrationLockPin(tokenData);
break;
case KBS_ACCOUNT_LOCKED:
Log.w(TAG, "KBS Account is locked");
@ -167,9 +168,9 @@ public final class CodeVerificationRequest {
}.executeOnExecutor(SignalExecutors.UNBOUNDED);
}
private static TokenResponse getToken(@Nullable String basicStorageCredentials) throws IOException {
private static TokenData getToken(@Nullable String basicStorageCredentials) throws IOException {
if (basicStorageCredentials == null) return null;
return ApplicationDependencies.getKeyBackupService().getToken(basicStorageCredentials);
return new PinRestoreRepository().getTokenSync(basicStorageCredentials);
}
private static void handleSuccessfulRegistration(@NonNull Context context) {
@ -185,12 +186,11 @@ public final class CodeVerificationRequest {
@NonNull Credentials credentials,
@NonNull String code,
@Nullable String pin,
@Nullable TokenResponse kbsTokenResponse,
@Nullable String kbsStorageCredentials,
@Nullable TokenData kbsTokenData,
@Nullable String fcmToken)
throws IOException, KeyBackupSystemWrongPinException, KeyBackupSystemNoDataException
{
boolean isV2RegistrationLock = kbsTokenResponse != null;
boolean isV2RegistrationLock = kbsTokenData != null;
int registrationId = KeyHelper.generateRegistrationId(false);
boolean universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context);
ProfileKey profileKey = findExistingProfileKey(context, credentials.getE164number());
@ -206,7 +206,7 @@ public final class CodeVerificationRequest {
SessionUtil.archiveAllSessions(context);
SignalServiceAccountManager accountManager = AccountManagerFactory.createUnauthenticated(context, credentials.getE164number(), credentials.getPassword());
KbsPinData kbsData = isV2RegistrationLock ? PinState.restoreMasterKey(pin, kbsStorageCredentials, kbsTokenResponse) : null;
KbsPinData kbsData = isV2RegistrationLock ? PinState.restoreMasterKey(pin, kbsTokenData.getEnclave(), kbsTokenData.getBasicAuth(), kbsTokenData.getTokenResponse()) : null;
String registrationLockV2 = kbsData != null ? kbsData.getMasterKey().deriveRegistrationLock() : null;
String registrationLockV1 = isV2RegistrationLock ? null : pin;
boolean hasFcm = fcmToken != null;
@ -292,14 +292,14 @@ public final class CodeVerificationRequest {
/**
* The account is locked with a V2 (KBS) pin. Called before any user pin guesses.
*/
void onKbsRegistrationLockPinRequired(long timeRemaining, @NonNull TokenResponse kbsTokenResponse, @NonNull String kbsStorageCredentials);
void onKbsRegistrationLockPinRequired(long timeRemaining, @NonNull TokenData kbsTokenData, @NonNull String kbsStorageCredentials);
/**
* The account is locked with a V2 (KBS) pin. Called after a user pin guess.
* <p>
* i.e. an attempt has likely been used.
*/
void onIncorrectKbsRegistrationLockPin(@NonNull TokenResponse kbsTokenResponse);
void onIncorrectKbsRegistrationLockPin(@NonNull TokenData kbsTokenResponse);
/**
* V2 (KBS) pin is set, but there is no data on KBS.

View File

@ -5,6 +5,7 @@ import android.app.Activity;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.pin.PinRestoreRepository;
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
import java.io.IOException;
@ -39,10 +40,9 @@ public final class RegistrationService {
@Nullable String fcmToken,
@NonNull String code,
@Nullable String pin,
@Nullable String basicStorageCredentials,
@Nullable TokenResponse tokenResponse,
@Nullable PinRestoreRepository.TokenData tokenData,
@NonNull CodeVerificationRequest.VerifyCallback callback)
{
CodeVerificationRequest.verifyAccount(activity, credentials, fcmToken, code, pin, basicStorageCredentials, tokenResponse, callback);
CodeVerificationRequest.verifyAccount(activity, credentials, fcmToken, code, pin, tokenData, callback);
}
}

View File

@ -9,6 +9,7 @@ import androidx.lifecycle.SavedStateHandle;
import androidx.lifecycle.ViewModel;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.pin.PinRestoreRepository.TokenData;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
import org.whispersystems.signalservice.internal.util.JsonUtil;
@ -28,11 +29,10 @@ public final class RegistrationViewModel extends ViewModel {
private final MutableLiveData<String> textCodeEntered;
private final MutableLiveData<String> captchaToken;
private final MutableLiveData<String> fcmToken;
private final MutableLiveData<String> basicStorageCredentials;
private final MutableLiveData<Boolean> restoreFlowShown;
private final MutableLiveData<Integer> successfulCodeRequestAttempts;
private final MutableLiveData<LocalCodeRequestRateLimiter> requestLimiter;
private final MutableLiveData<String> keyBackupCurrentTokenJson;
private final MutableLiveData<TokenData> kbsTokenData;
private final MutableLiveData<Long> lockedTimeRemaining;
private final MutableLiveData<Long> canCallAtTime;
@ -43,11 +43,10 @@ public final class RegistrationViewModel extends ViewModel {
textCodeEntered = savedStateHandle.getLiveData("TEXT_CODE_ENTERED", "");
captchaToken = savedStateHandle.getLiveData("CAPTCHA");
fcmToken = savedStateHandle.getLiveData("FCM_TOKEN");
basicStorageCredentials = savedStateHandle.getLiveData("BASIC_STORAGE_CREDENTIALS");
restoreFlowShown = savedStateHandle.getLiveData("RESTORE_FLOW_SHOWN", false);
successfulCodeRequestAttempts = savedStateHandle.getLiveData("SUCCESSFUL_CODE_REQUEST_ATTEMPTS", 0);
requestLimiter = savedStateHandle.getLiveData("REQUEST_RATE_LIMITER", new LocalCodeRequestRateLimiter(60_000));
keyBackupCurrentTokenJson = savedStateHandle.getLiveData("KBS_TOKEN");
kbsTokenData = savedStateHandle.getLiveData("KBS_TOKEN");
lockedTimeRemaining = savedStateHandle.getLiveData("TIME_REMAINING", 0L);
canCallAtTime = savedStateHandle.getLiveData("CAN_CALL_AT_TIME", 0L);
}
@ -158,28 +157,12 @@ public final class RegistrationViewModel extends ViewModel {
requestLimiter.setValue(requestLimiter.getValue());
}
public void setStorageCredentials(@Nullable String storageCredentials) {
basicStorageCredentials.setValue(storageCredentials);
public @Nullable TokenData getKeyBackupCurrentToken() {
return kbsTokenData.getValue();
}
public @Nullable String getBasicStorageCredentials() {
return basicStorageCredentials.getValue();
}
public @Nullable TokenResponse getKeyBackupCurrentToken() {
String json = keyBackupCurrentTokenJson.getValue();
if (json == null) return null;
try {
return JsonUtil.fromJson(json, TokenResponse.class);
} catch (IOException e) {
Log.w(TAG, e);
return null;
}
}
public void setKeyBackupCurrentToken(TokenResponse tokenResponse) {
String json = tokenResponse == null ? null : JsonUtil.toJson(tokenResponse);
keyBackupCurrentTokenJson.setValue(json);
public void setKeyBackupTokenData(TokenData tokenData) {
kbsTokenData.setValue(tokenData);
}
public LiveData<Long> getLockedTimeRemaining() {

View File

@ -60,14 +60,15 @@ class AccountConflictMerger implements StorageSyncHelper.ConflictMerger<SignalAc
String avatarUrlPath = remote.getAvatarUrlPath().or(local.getAvatarUrlPath()).or("");
byte[] profileKey = remote.getProfileKey().or(local.getProfileKey()).orNull();
boolean noteToSelfArchived = remote.isNoteToSelfArchived();
boolean noteToSelfForcedUnread = remote.isNoteToSelfForcedUnread();
boolean readReceipts = remote.isReadReceiptsEnabled();
boolean typingIndicators = remote.isTypingIndicatorsEnabled();
boolean sealedSenderIndicators = remote.isSealedSenderIndicatorsEnabled();
boolean linkPreviews = remote.isLinkPreviewsEnabled();
boolean unlisted = remote.isPhoneNumberUnlisted();
AccountRecord.PhoneNumberSharingMode phoneNumberSharingMode = remote.getPhoneNumberSharingMode();
boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted);
boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted );
boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted);
boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted );
if (matchesRemote) {
return remote;
@ -81,6 +82,7 @@ class AccountConflictMerger implements StorageSyncHelper.ConflictMerger<SignalAc
.setAvatarUrlPath(avatarUrlPath)
.setProfileKey(profileKey)
.setNoteToSelfArchived(noteToSelfArchived)
.setNoteToSelfForcedUnread(noteToSelfForcedUnread)
.setReadReceiptsEnabled(readReceipts)
.setTypingIndicatorsEnabled(typingIndicators)
.setSealedSenderIndicatorsEnabled(sealedSenderIndicators)
@ -99,6 +101,7 @@ class AccountConflictMerger implements StorageSyncHelper.ConflictMerger<SignalAc
@NonNull String avatarUrlPath,
@Nullable byte[] profileKey,
boolean noteToSelfArchived,
boolean noteToSelfForcedUnread,
boolean readReceipts,
boolean typingIndicators,
boolean sealedSenderIndicators,
@ -112,6 +115,7 @@ class AccountConflictMerger implements StorageSyncHelper.ConflictMerger<SignalAc
Objects.equals(contact.getAvatarUrlPath().or(""), avatarUrlPath) &&
Arrays.equals(contact.getProfileKey().orNull(), profileKey) &&
contact.isNoteToSelfArchived() == noteToSelfArchived &&
contact.isNoteToSelfForcedUnread() == noteToSelfForcedUnread &&
contact.isReadReceiptsEnabled() == readReceipts &&
contact.isTypingIndicatorsEnabled() == typingIndicators &&
contact.isSealedSenderIndicatorsEnabled() == sealedSenderIndicators &&

View File

@ -86,8 +86,9 @@ class ContactConflictMerger implements StorageSyncHelper.ConflictMerger<SignalCo
boolean blocked = remote.isBlocked();
boolean profileSharing = remote.isProfileSharingEnabled();
boolean archived = remote.isArchived();
boolean matchesRemote = doParamsMatch(remote, unknownFields, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived);
boolean matchesLocal = doParamsMatch(local, unknownFields, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived);
boolean forcedUnread = remote.isForcedUnread();
boolean matchesRemote = doParamsMatch(remote, unknownFields, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived, forcedUnread);
boolean matchesLocal = doParamsMatch(local, unknownFields, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived, forcedUnread);
if (matchesRemote) {
return remote;
@ -104,6 +105,7 @@ class ContactConflictMerger implements StorageSyncHelper.ConflictMerger<SignalCo
.setIdentityKey(identityKey)
.setBlocked(blocked)
.setProfileSharingEnabled(profileSharing)
.setForcedUnread(forcedUnread)
.build();
}
}
@ -119,7 +121,8 @@ class ContactConflictMerger implements StorageSyncHelper.ConflictMerger<SignalCo
@Nullable byte[] identityKey,
boolean blocked,
boolean profileSharing,
boolean archived)
boolean archived,
boolean forcedUnread)
{
return Arrays.equals(contact.serializeUnknownFields(), unknownFields) &&
Objects.equals(contact.getAddress(), address) &&
@ -131,6 +134,7 @@ class ContactConflictMerger implements StorageSyncHelper.ConflictMerger<SignalCo
Arrays.equals(contact.getIdentityKey().orNull(), identityKey) &&
contact.isBlocked() == blocked &&
contact.isProfileSharingEnabled() == profileSharing &&
contact.isArchived() == archived;
contact.isArchived() == archived &&
contact.isForcedUnread() == forcedUnread;
}
}

View File

@ -38,11 +38,12 @@ final class GroupV1ConflictMerger implements StorageSyncHelper.ConflictMerger<Si
public @NonNull SignalGroupV1Record merge(@NonNull SignalGroupV1Record remote, @NonNull SignalGroupV1Record local, @NonNull StorageSyncHelper.KeyGenerator keyGenerator) {
byte[] unknownFields = remote.serializeUnknownFields();
boolean blocked = remote.isBlocked();
boolean profileSharing = remote.isProfileSharingEnabled() || local.isProfileSharingEnabled();
boolean profileSharing = remote.isProfileSharingEnabled();
boolean archived = remote.isArchived();
boolean forcedUnread = remote.isForcedUnread();
boolean matchesRemote = Arrays.equals(unknownFields, remote.serializeUnknownFields()) && blocked == remote.isBlocked() && profileSharing == remote.isProfileSharingEnabled() && archived == remote.isArchived();
boolean matchesLocal = Arrays.equals(unknownFields, local.serializeUnknownFields()) && blocked == local.isBlocked() && profileSharing == local.isProfileSharingEnabled() && archived == local.isArchived();
boolean matchesRemote = Arrays.equals(unknownFields, remote.serializeUnknownFields()) && blocked == remote.isBlocked() && profileSharing == remote.isProfileSharingEnabled() && archived == remote.isArchived() && forcedUnread == remote.isForcedUnread();
boolean matchesLocal = Arrays.equals(unknownFields, local.serializeUnknownFields()) && blocked == local.isBlocked() && profileSharing == local.isProfileSharingEnabled() && archived == local.isArchived() && forcedUnread == local.isForcedUnread();
if (matchesRemote) {
return remote;
@ -53,6 +54,7 @@ final class GroupV1ConflictMerger implements StorageSyncHelper.ConflictMerger<Si
.setUnknownFields(unknownFields)
.setBlocked(blocked)
.setProfileSharingEnabled(blocked)
.setForcedUnread(forcedUnread)
.build();
}
}

View File

@ -38,11 +38,12 @@ final class GroupV2ConflictMerger implements StorageSyncHelper.ConflictMerger<Si
public @NonNull SignalGroupV2Record merge(@NonNull SignalGroupV2Record remote, @NonNull SignalGroupV2Record local, @NonNull StorageSyncHelper.KeyGenerator keyGenerator) {
byte[] unknownFields = remote.serializeUnknownFields();
boolean blocked = remote.isBlocked();
boolean profileSharing = remote.isProfileSharingEnabled() || local.isProfileSharingEnabled();
boolean profileSharing = remote.isProfileSharingEnabled();
boolean archived = remote.isArchived();
boolean forcedUnread = remote.isForcedUnread();
boolean matchesRemote = Arrays.equals(unknownFields, remote.serializeUnknownFields()) && blocked == remote.isBlocked() && profileSharing == remote.isProfileSharingEnabled() && archived == remote.isArchived();
boolean matchesLocal = Arrays.equals(unknownFields, local.serializeUnknownFields()) && blocked == local.isBlocked() && profileSharing == local.isProfileSharingEnabled() && archived == local.isArchived();
boolean matchesRemote = Arrays.equals(unknownFields, remote.serializeUnknownFields()) && blocked == remote.isBlocked() && profileSharing == remote.isProfileSharingEnabled() && archived == remote.isArchived() && forcedUnread == remote.isForcedUnread();
boolean matchesLocal = Arrays.equals(unknownFields, local.serializeUnknownFields()) && blocked == local.isBlocked() && profileSharing == local.isProfileSharingEnabled() && archived == local.isArchived() && forcedUnread == local.isForcedUnread();
if (matchesRemote) {
return remote;
@ -53,6 +54,8 @@ final class GroupV2ConflictMerger implements StorageSyncHelper.ConflictMerger<Si
.setUnknownFields(unknownFields)
.setBlocked(blocked)
.setProfileSharingEnabled(blocked)
.setArchived(archived)
.setForcedUnread(forcedUnread)
.build();
}
}

View File

@ -12,6 +12,7 @@ import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob;
import org.thoughtcrime.securesms.jobs.StorageSyncJob;
@ -83,8 +84,7 @@ public final class StorageSyncHelper {
@NonNull List<RecipientSettings> inserts,
@NonNull List<RecipientSettings> deletes,
@NonNull Optional<SignalAccountRecord> accountUpdate,
@NonNull Optional<SignalAccountRecord> accountInsert,
@NonNull Set<RecipientId> archivedRecipients)
@NonNull Optional<SignalAccountRecord> accountInsert)
{
int accountCount = Stream.of(currentLocalKeys)
.filter(id -> id.getType() == ManifestRecord.Identifier.Type.ACCOUNT_VALUE)
@ -119,12 +119,12 @@ public final class StorageSyncHelper {
Map<RecipientId, byte[]> storageKeyUpdates = new HashMap<>();
for (RecipientSettings insert : inserts) {
if (insert.getGroupType() == RecipientDatabase.GroupType.SIGNAL_V2 && insert.getGroupMasterKey() == null) {
if (insert.getGroupType() == RecipientDatabase.GroupType.SIGNAL_V2 && insert.getSyncExtras().getGroupMasterKey() == null) {
Log.w(TAG, "Missing master key on gv2 recipient");
continue;
}
storageInserts.add(StorageSyncModels.localToRemoteRecord(insert, archivedRecipients));
storageInserts.add(StorageSyncModels.localToRemoteRecord(insert));
switch (insert.getGroupType()) {
case NONE:
@ -173,7 +173,7 @@ public final class StorageSyncHelper {
throw new AssertionError("Unsupported type!");
}
storageInserts.add(StorageSyncModels.localToRemoteRecord(update, newId.getRaw(), archivedRecipients));
storageInserts.add(StorageSyncModels.localToRemoteRecord(update, newId.getRaw()));
storageDeletes.add(ByteBuffer.wrap(oldId.getRaw()));
completeIds.remove(oldId);
completeIds.add(newId);
@ -330,21 +330,23 @@ public final class StorageSyncHelper {
@NonNull List<StorageId> currentLocalStorageKeys,
@NonNull MergeResult mergeResult)
{
Set<StorageId> completeKeys = new HashSet<>(currentLocalStorageKeys);
completeKeys.addAll(Stream.of(mergeResult.getAllNewRecords()).map(SignalRecord::getId).toList());
completeKeys.removeAll(Stream.of(mergeResult.getAllRemovedRecords()).map(SignalRecord::getId).toList());
SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, new ArrayList<>(completeKeys));
List<SignalStorageRecord> inserts = new ArrayList<>();
inserts.addAll(mergeResult.getRemoteInserts());
inserts.addAll(Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getNew).toList());
List<byte[]> deletes = Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getOld).map(SignalStorageRecord::getId).map(StorageId::getRaw).toList();
deletes.addAll(Stream.of(mergeResult.getRemoteDeletes()).map(SignalRecord::getId).map(StorageId::getRaw).toList());
List<StorageId> deletes = new ArrayList<>();
deletes.addAll(Stream.of(mergeResult.getRemoteDeletes()).map(SignalRecord::getId).toList());
deletes.addAll(Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getOld).map(SignalStorageRecord::getId).toList());
return new WriteOperationResult(manifest, inserts, deletes);
Set<StorageId> completeKeys = new HashSet<>(currentLocalStorageKeys);
completeKeys.addAll(Stream.of(mergeResult.getAllNewRecords()).map(SignalRecord::getId).toList());
completeKeys.removeAll(Stream.of(mergeResult.getAllRemovedRecords()).map(SignalRecord::getId).toList());
completeKeys.addAll(Stream.of(inserts).map(SignalStorageRecord::getId).toList());
completeKeys.removeAll(deletes);
SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, new ArrayList<>(completeKeys));
return new WriteOperationResult(manifest, inserts, Stream.of(deletes).map(StorageId::getRaw).toList());
}
public static @NonNull byte[] generateKey() {
@ -413,12 +415,13 @@ public final class StorageSyncHelper {
RecipientSettings settings = DatabaseFactory.getRecipientDatabase(context).getRecipientSettingsForSync(self.getId());
SignalAccountRecord account = new SignalAccountRecord.Builder(self.getStorageServiceId())
.setUnknownFields(settings != null ? settings.getStorageProto() : null)
.setUnknownFields(settings != null ? settings.getSyncExtras().getStorageProto() : null)
.setProfileKey(self.getProfileKey())
.setGivenName(self.getProfileName().getGivenName())
.setFamilyName(self.getProfileName().getFamilyName())
.setAvatarUrlPath(self.getProfileAvatar())
.setNoteToSelfArchived(DatabaseFactory.getThreadDatabase(context).isArchived(self.getId()))
.setNoteToSelfArchived(settings != null && settings.getSyncExtras().isArchived())
.setNoteToSelfForcedUnread(settings != null && settings.getSyncExtras().isForcedUnread())
.setTypingIndicatorsEnabled(TextSecurePreferences.isTypingIndicatorsEnabled(context))
.setReadReceiptsEnabled(TextSecurePreferences.isReadReceiptsEnabled(context))
.setSealedSenderIndicatorsEnabled(TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context))
@ -439,6 +442,15 @@ public final class StorageSyncHelper {
}
}
private static PhoneNumberPrivacyValues.PhoneNumberSharingMode remoteToLocalPhoneNumberSharingMode(AccountRecord.PhoneNumberSharingMode phoneNumberPhoneNumberSharingMode) {
switch (phoneNumberPhoneNumberSharingMode) {
case EVERYBODY : return PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYONE;
case CONTACTS_ONLY: return PhoneNumberPrivacyValues.PhoneNumberSharingMode.CONTACTS;
case NOBODY : return PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY;
default : return PhoneNumberPrivacyValues.PhoneNumberSharingMode.CONTACTS;
}
}
public static void applyAccountStorageSyncUpdates(@NonNull Context context, Optional<StorageSyncHelper.RecordUpdate<SignalAccountRecord>> update) {
if (!update.isPresent()) {
return;
@ -448,12 +460,13 @@ public final class StorageSyncHelper {
public static void applyAccountStorageSyncUpdates(@NonNull Context context, @NonNull StorageId storageId, @NonNull SignalAccountRecord update, boolean fetchProfile) {
DatabaseFactory.getRecipientDatabase(context).applyStorageSyncUpdates(storageId, update);
DatabaseFactory.getThreadDatabase(context).setArchived(Recipient.self().getId(), update.isNoteToSelfArchived());
TextSecurePreferences.setReadReceiptsEnabled(context, update.isReadReceiptsEnabled());
TextSecurePreferences.setTypingIndicatorsEnabled(context, update.isTypingIndicatorsEnabled());
TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, update.isSealedSenderIndicatorsEnabled());
SignalStore.settings().setLinkPreviewsEnabled(update.isLinkPreviewsEnabled());
SignalStore.phoneNumberPrivacy().setPhoneNumberListingMode(update.isPhoneNumberUnlisted() ? PhoneNumberPrivacyValues.PhoneNumberListingMode.UNLISTED : PhoneNumberPrivacyValues.PhoneNumberListingMode.LISTED);
SignalStore.phoneNumberPrivacy().setPhoneNumberSharingMode(remoteToLocalPhoneNumberSharingMode(update.getPhoneNumberSharingMode()));
if (fetchProfile && update.getAvatarUrlPath().isPresent()) {
ApplicationDependencies.getJobManager().add(new RetrieveProfileAvatarJob(Recipient.self(), update.getAvatarUrlPath().get()));

View File

@ -20,42 +20,43 @@ public final class StorageSyncModels {
private StorageSyncModels() {}
public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings, @NonNull Set<RecipientId> archived) {
public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings) {
if (settings.getStorageId() == null) {
throw new AssertionError("Must have a storage key!");
}
return localToRemoteRecord(settings, settings.getStorageId(), archived);
return localToRemoteRecord(settings, settings.getStorageId());
}
public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings, @NonNull byte[] rawStorageId, @NonNull Set<RecipientId> archived) {
public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings, @NonNull byte[] rawStorageId) {
switch (settings.getGroupType()) {
case NONE: return SignalStorageRecord.forContact(localToRemoteContact(settings, rawStorageId, archived));
case SIGNAL_V1: return SignalStorageRecord.forGroupV1(localToRemoteGroupV1(settings, rawStorageId, archived));
case SIGNAL_V2: return SignalStorageRecord.forGroupV2(localToRemoteGroupV2(settings, rawStorageId, archived));
case NONE: return SignalStorageRecord.forContact(localToRemoteContact(settings, rawStorageId));
case SIGNAL_V1: return SignalStorageRecord.forGroupV1(localToRemoteGroupV1(settings, rawStorageId));
case SIGNAL_V2: return SignalStorageRecord.forGroupV2(localToRemoteGroupV2(settings, rawStorageId));
default: throw new AssertionError("Unsupported type!");
}
}
private static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientSettings recipient, byte[] rawStorageId, @NonNull Set<RecipientId> archived) {
private static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientSettings recipient, byte[] rawStorageId) {
if (recipient.getUuid() == null && recipient.getE164() == null) {
throw new AssertionError("Must have either a UUID or a phone number!");
}
return new SignalContactRecord.Builder(rawStorageId, new SignalServiceAddress(recipient.getUuid(), recipient.getE164()))
.setUnknownFields(recipient.getStorageProto())
.setUnknownFields(recipient.getSyncExtras().getStorageProto())
.setProfileKey(recipient.getProfileKey())
.setGivenName(recipient.getProfileName().getGivenName())
.setFamilyName(recipient.getProfileName().getFamilyName())
.setBlocked(recipient.isBlocked())
.setProfileSharingEnabled(recipient.isProfileSharing() || recipient.getSystemContactUri() != null)
.setIdentityKey(recipient.getIdentityKey())
.setIdentityState(localToRemoteIdentityState(recipient.getIdentityStatus()))
.setArchived(archived.contains(recipient.getId()))
.setIdentityKey(recipient.getSyncExtras().getIdentityKey())
.setIdentityState(localToRemoteIdentityState(recipient.getSyncExtras().getIdentityStatus()))
.setArchived(recipient.getSyncExtras().isArchived())
.setForcedUnread(recipient.getSyncExtras().isForcedUnread())
.build();
}
private static @NonNull SignalGroupV1Record localToRemoteGroupV1(@NonNull RecipientSettings recipient, byte[] rawStorageId, @NonNull Set<RecipientId> archived) {
private static @NonNull SignalGroupV1Record localToRemoteGroupV1(@NonNull RecipientSettings recipient, byte[] rawStorageId) {
GroupId groupId = recipient.getGroupId();
if (groupId == null) {
@ -67,14 +68,15 @@ public final class StorageSyncModels {
}
return new SignalGroupV1Record.Builder(rawStorageId, groupId.getDecodedId())
.setUnknownFields(recipient.getStorageProto())
.setUnknownFields(recipient.getSyncExtras().getStorageProto())
.setBlocked(recipient.isBlocked())
.setProfileSharingEnabled(recipient.isProfileSharing())
.setArchived(archived.contains(recipient.getId()))
.setArchived(recipient.getSyncExtras().isArchived())
.setForcedUnread(recipient.getSyncExtras().isForcedUnread())
.build();
}
private static @NonNull SignalGroupV2Record localToRemoteGroupV2(@NonNull RecipientSettings recipient, byte[] rawStorageId, @NonNull Set<RecipientId> archived) {
private static @NonNull SignalGroupV2Record localToRemoteGroupV2(@NonNull RecipientSettings recipient, byte[] rawStorageId) {
GroupId groupId = recipient.getGroupId();
if (groupId == null) {
@ -85,17 +87,18 @@ public final class StorageSyncModels {
throw new AssertionError("Group is not V2");
}
GroupMasterKey groupMasterKey = recipient.getGroupMasterKey();
GroupMasterKey groupMasterKey = recipient.getSyncExtras().getGroupMasterKey();
if (groupMasterKey == null) {
throw new AssertionError("Group master key not on recipient record");
}
return new SignalGroupV2Record.Builder(rawStorageId, groupMasterKey)
.setUnknownFields(recipient.getStorageProto())
.setUnknownFields(recipient.getSyncExtras().getStorageProto())
.setBlocked(recipient.isBlocked())
.setProfileSharingEnabled(recipient.isProfileSharing())
.setArchived(archived.contains(recipient.getId()))
.setArchived(recipient.getSyncExtras().isArchived())
.setForcedUnread(recipient.getSyncExtras().isForcedUnread())
.build();
}

View File

@ -165,7 +165,7 @@ public class CommunicationActions {
*/
public static boolean handlePotentialGroupLinkUrl(@NonNull FragmentActivity activity, @NonNull String potentialGroupLinkUrl) {
try {
GroupInviteLinkUrl groupInviteLinkUrl = GroupInviteLinkUrl.fromUrl(potentialGroupLinkUrl);
GroupInviteLinkUrl groupInviteLinkUrl = GroupInviteLinkUrl.fromUri(potentialGroupLinkUrl);
if (groupInviteLinkUrl == null) {
return false;

View File

@ -26,6 +26,10 @@ public final class CursorUtil {
return requireInt(cursor, column) != 0;
}
public static byte[] requireBlob(@NonNull Cursor cursor, @NonNull String column) {
return cursor.getBlob(cursor.getColumnIndexOrThrow(column));
}
public static Optional<String> getString(@NonNull Cursor cursor, @NonNull String column) {
if (cursor.getColumnIndex(column) < 0) {
return Optional.absent();
@ -41,4 +45,20 @@ public final class CursorUtil {
return Optional.of(requireInt(cursor, column));
}
}
public static Optional<Boolean> getBoolean(@NonNull Cursor cursor, @NonNull String column) {
if (cursor.getColumnIndex(column) < 0) {
return Optional.absent();
} else {
return Optional.of(requireBoolean(cursor, column));
}
}
public static Optional<byte[]> getBlob(@NonNull Cursor cursor, @NonNull String column) {
if (cursor.getColumnIndex(column) < 0) {
return Optional.absent();
} else {
return Optional.fromNullable(requireBlob(cursor, column));
}
}
}

View File

@ -48,7 +48,6 @@ public final class FeatureFlags {
private static final long FETCH_INTERVAL = TimeUnit.HOURS.toMillis(2);
private static final String USERNAMES = "android.usernames";
private static final String REMOTE_DELETE = "android.remoteDelete";
private static final String GROUPS_V2_CREATE_VERSION = "android.groupsv2.createVersion";
private static final String GROUPS_V2_JOIN_VERSION = "android.groupsv2.joinVersion";
private static final String GROUPS_V2_LINKS_VERSION = "android.groupsv2.manageGroupLinksVersion";
@ -66,7 +65,6 @@ public final class FeatureFlags {
*/
private static final Set<String> REMOTE_CAPABLE = Sets.newHashSet(
REMOTE_DELETE,
GROUPS_V2_CREATE_VERSION,
GROUPS_V2_CAPACITY,
GROUPS_V2_JOIN_VERSION,
@ -174,11 +172,6 @@ public final class FeatureFlags {
return getBoolean(USERNAMES, false);
}
/** Send support for remotely deleting a message. */
public static boolean remoteDelete() {
return getBoolean(REMOTE_DELETE, false);
}
/** Attempt groups v2 creation. */
public static boolean groupsV2create() {
return getVersionFlag(GROUPS_V2_CREATE_VERSION) == VersionFlag.ON &&

View File

@ -6,6 +6,8 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.groups.GroupMasterKey;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
@ -66,6 +68,14 @@ public final class GroupUtil {
return Optional.absent();
}
public static @NonNull GroupMasterKey requireMasterKey(@NonNull byte[] masterKey) {
try {
return new GroupMasterKey(masterKey);
} catch (InvalidInputException e) {
throw new AssertionError(e);
}
}
public static @NonNull GroupDescription getNonV2GroupDescription(@NonNull Context context, @Nullable String encodedGroup) {
if (encodedGroup == null) {
return new GroupDescription(context, null);

View File

@ -7,11 +7,13 @@ import androidx.annotation.NonNull;
import net.sqlcipher.database.SQLiteDatabase;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.libsignal.util.guava.Preconditions;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -63,9 +65,9 @@ public final class SqlUtil {
* change. In other words, if {@link SQLiteDatabase#update(String, ContentValues, String, String[])}
* returns > 0, then you know something *actually* changed.
*/
public static @NonNull UpdateQuery buildTrueUpdateQuery(@NonNull String selection,
@NonNull String[] args,
@NonNull ContentValues contentValues)
public static @NonNull Query buildTrueUpdateQuery(@NonNull String selection,
@NonNull String[] args,
@NonNull ContentValues contentValues)
{
StringBuilder qualifier = new StringBuilder();
Set<Map.Entry<String, Object>> valueSet = contentValues.valueSet();
@ -90,7 +92,29 @@ public final class SqlUtil {
i++;
}
return new UpdateQuery("(" + selection + ") AND (" + qualifier + ")", fullArgs.toArray(new String[0]));
return new Query("(" + selection + ") AND (" + qualifier + ")", fullArgs.toArray(new String[0]));
}
public static @NonNull Query buildCollectionQuery(@NonNull String column, @NonNull Collection<? extends Object> values) {
Preconditions.checkArgument(values.size() > 0);
StringBuilder query = new StringBuilder();
Object[] args = new Object[values.size()];
int i = 0;
for (Object value : values) {
query.append("?");
args[i] = value;
if (i != values.size() - 1) {
query.append(", ");
}
i++;
}
return new Query(column + " IN (" + query.toString() + ")", buildArgs(args));
}
public static String[] appendArg(@NonNull String[] args, String addition) {
@ -102,11 +126,11 @@ public final class SqlUtil {
return output;
}
public static class UpdateQuery {
public static class Query {
private final String where;
private final String[] whereArgs;
private UpdateQuery(@NonNull String where, @NonNull String[] whereArgs) {
private Query(@NonNull String where, @NonNull String[] whereArgs) {
this.where = where;
this.whereArgs = whereArgs;
}

View File

@ -116,6 +116,18 @@ public class Util {
return join(boxed, delimeter);
}
@SafeVarargs
public static @NonNull <E> List<E> join(@NonNull List<E>... lists) {
int totalSize = Stream.of(lists).reduce(0, (sum, list) -> sum + list.size());
List<E> joined = new ArrayList<>(totalSize);
for (List<E> list : lists) {
joined.addAll(list);
}
return joined;
}
public static String join(List<Long> list, String delimeter) {
StringBuilder sb = new StringBuilder();

View File

@ -28,9 +28,10 @@ import "SignalService.proto";
import "DecryptedGroups.proto";
message DecryptedGroupV2Context {
signalservice.GroupContextV2 context = 1;
DecryptedGroupChange change = 2;
DecryptedGroup groupState = 3;
signalservice.GroupContextV2 context = 1;
DecryptedGroupChange change = 2;
DecryptedGroup groupState = 3;
DecryptedGroup previousGroupState = 4;
}
message TemporalAuthCredentialResponse {

View File

@ -17,7 +17,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/recipient_name"
android:layout_width="0dp"
android:layout_height="0dp"

View File

@ -30,7 +30,7 @@
app:layout_constraintEnd_toEndOf="@+id/recipient_avatar"
tools:checked="true" />
<TextView
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/recipient_name"
android:layout_width="0dp"
android:layout_height="0dp"

View File

@ -30,7 +30,7 @@
app:layout_constraintEnd_toEndOf="@+id/recipient_avatar"
tools:checked="true" />
<TextView
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/recipient_name"
android:layout_width="0dp"
android:layout_height="0dp"

View File

@ -289,6 +289,7 @@
android:paddingEnd="16dp"
android:paddingBottom="32dp"
android:visibility="gone"
android:gravity="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"

View File

@ -320,7 +320,7 @@
<string name="ConversationFragment_deleting_messages">حذف الرسائل جارٍ…</string>
<string name="ConversationFragment_delete_for_me">إحذف بالنسبة لي</string>
<string name="ConversationFragment_delete_for_everyone">إحذف بالنسبة للجميع</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">سيتم حذف هذه الرسالة لجميع الأعضاء في هذه المحادثة. الجميع سيرى أنك حذفت رسالة.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">سيتم حذف هذه الرسالة لجميع الأعضاء في هذه المحادثة. الجميع سيرى أنك حذفت رسالة.</string> -->
<string name="ConversationFragment_quoted_message_not_found">لم يتم العثور على الرسالة الأصلية</string>
<string name="ConversationFragment_quoted_message_no_longer_available">الرسالة الأصلية لم تعد متوفرة</string>
<string name="ConversationFragment_failed_to_open_message">فشل في فتح الرسالة</string>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -303,7 +303,7 @@
<string name="ConversationFragment_deleting_messages">Mažu zprávy…</string>
<string name="ConversationFragment_delete_for_me">Smazat pro mne</string>
<string name="ConversationFragment_delete_for_everyone">Smazat pro všechny</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Tato zpráva bude trvale smazána pro všechny členy konverzace. Členové konverzace budou moci vidět, ze jste zprávu smazali.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Tato zpráva bude trvale smazána pro všechny členy konverzace. Členové konverzace budou moci vidět, ze jste zprávu smazali.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Původní zpráva nebyla nalezena</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Původní zpráva již není dostupná</string>
<string name="ConversationFragment_failed_to_open_message">Zprávu se nepovedlo otevřít</string>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -284,7 +284,7 @@
<string name="ConversationFragment_deleting_messages">Forviŝado de mesaĝoj…</string>
<string name="ConversationFragment_delete_for_me">Forviŝi nur por mi</string>
<string name="ConversationFragment_delete_for_everyone">Forviŝi por ĉiuj</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Tiu ĉi mesaĝo estos porĉiame forviŝita por ĉiuj en la interparolo. Anoj vidos, ke vi forviŝis mesaĝon.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Tiu ĉi mesaĝo estos porĉiame forviŝita por ĉiuj en la interparolo. Anoj vidos, ke vi forviŝis mesaĝon.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Origina mesaĝo ne troveblas</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Origina mesaĝo ne plu disponeblas</string>
<string name="ConversationFragment_failed_to_open_message">Malsukceso malfermi la mesaĝon</string>

File diff suppressed because one or more lines are too long

View File

@ -280,7 +280,7 @@
<string name="ConversationFragment_deleting_messages">Sõnumite kustutamine…</string>
<string name="ConversationFragment_delete_for_me">Kustuta minu jaoks</string>
<string name="ConversationFragment_delete_for_everyone">Kustuta kõigi jaoks</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">See sõnum kustutatakse jäädavalt kõigi vestluses osalejate jaoks. Liikmed näevad, et sa kustutasid sõnumi.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">See sõnum kustutatakse jäädavalt kõigi vestluses osalejate jaoks. Liikmed näevad, et sa kustutasid sõnumi.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Originaalsõnumit ei leitud</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Originaalsõnum pole enam saadaval</string>
<string name="ConversationFragment_failed_to_open_message">Sõnumi avamine ebaõnnestus</string>

File diff suppressed because one or more lines are too long

View File

@ -286,7 +286,7 @@
<string name="ConversationFragment_deleting_messages">حذف پیام‌‌ها…</string>
<string name="ConversationFragment_delete_for_me">حذف برای من</string>
<string name="ConversationFragment_delete_for_everyone">حذف برای همه</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">این پیام به طور دائم برای همه در این مکالمه حذف خواهد شد. اعضا قادر خواهند بود تا ببینند که شما پیامی را حذف کرده‌اید.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">این پیام به طور دائم برای همه در این مکالمه حذف خواهد شد. اعضا قادر خواهند بود تا ببینند که شما پیامی را حذف کرده‌اید.</string> -->
<string name="ConversationFragment_quoted_message_not_found">پیام اصلی یافت نشد</string>
<string name="ConversationFragment_quoted_message_no_longer_available">پیام اصلی دیگر در دسترس نیست</string>
<string name="ConversationFragment_failed_to_open_message">عدم موفقیت در باز کردن پیام</string>

View File

@ -129,6 +129,7 @@
<!--ClientDeprecatedActivity-->
<string name="ClientDeprecatedActivity_update_signal">Päivitä Signal</string>
<string name="ClientDeprecatedActivity_update">Päivitä</string>
<string name="ClientDeprecatedActivity_dont_update">Älä päivitä</string>
<string name="ClientDeprecatedActivity_warning">Varoitus</string>
<!--CommunicationActions-->
<string name="CommunicationActions_no_browser_found">Verkkoselainta ei löytynyt.</string>
@ -283,7 +284,7 @@
<string name="ConversationFragment_deleting_messages">Viestejä poistetaan…</string>
<string name="ConversationFragment_delete_for_me">Poista minulta</string>
<string name="ConversationFragment_delete_for_everyone">Poista kaikilta</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Tämä viesti poistetaan pysyvästi kaikilta keskustelun jäseniltä. Jäsenet voivat nähdä, että olet poistanut viestin.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Tämä viesti poistetaan pysyvästi kaikilta keskustelun jäseniltä. Jäsenet voivat nähdä, että olet poistanut viestin.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Alkuperäistä viestiä ei löytynyt</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Alkuperäinen viesti ei ole enää saatavilla</string>
<string name="ConversationFragment_failed_to_open_message">Viestin avaaminen epäonnistui</string>
@ -371,6 +372,7 @@
<string name="DozeReminder_optimize_for_missing_play_services">Optimoi Play Servicesin puuttumista</string>
<string name="DozeReminder_this_device_does_not_support_play_services_tap_to_disable_system_battery">Tämä laite ei tue Play Servicesiä. Napauta tästä ottaaksesi pois päältä virransäästöominaisuudet, jotka estävät Signalia hakemasta viestejä taustalla.</string>
<!--ExpiredBuildReminder-->
<string name="ExpiredBuildReminder_update_now">Päivitä nyt</string>
<!--ShareActivity-->
<string name="ShareActivity_share_with">Jaa</string>
<string name="ShareActivity_multiple_attachments_are_only_supported">Useiden tiedostojen liittämistä tuetaan vain kuvilla ja videoilla</string>
@ -498,6 +500,8 @@
<string name="ManageGroupActivity_member_requests_and_invites">Jäsenpyynnöt &amp; kutsut</string>
<string name="ManageGroupActivity_add_members">Lisää jäseniä</string>
<string name="ManageGroupActivity_edit_group_info">Muokkaa ryhmän tietoja</string>
<string name="ManageGroupActivity_who_can_add_new_members">Ketkä voivat lisätä uusia jäseniä?</string>
<string name="ManageGroupActivity_who_can_edit_this_groups_info">Ketkä voivat muokata tämän ryhmän tietoja?</string>
<string name="ManageGroupActivity_group_link">Ryhmälinkki</string>
<string name="ManageGroupActivity_block_group">Estä ryhmä</string>
<string name="ManageGroupActivity_unblock_group">Poista ryhmän esto</string>
@ -942,6 +946,7 @@
<string name="ExpirationDialog_your_messages_will_not_expire">Viestisi eivät vanhene.</string>
<string name="ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen">Lähetetyt ja vastaanotetut viestit katoavat tästä keskustelusta %s siitä, kun ne on nähty.</string>
<!--OutdatedBuildReminder-->
<string name="OutdatedBuildReminder_update_now">Päivitä nyt</string>
<!--PassphrasePromptActivity-->
<string name="PassphrasePromptActivity_enter_passphrase">Syötä salalause</string>
<string name="PassphrasePromptActivity_watermark_content_description">Signal-kuvake</string>
@ -1019,9 +1024,12 @@
<string name="WebRtcCallActivity__to_call_s_signal_needs_access_to_your_camera">Jotta voit soittaa yhteystiedolle %1$s, Signal tarvitsee lupaa käyttää kameraa.</string>
<string name="WebRtcCallActivity__signal_s">Signal %1$s</string>
<string name="WebRtcCallActivity__calling">Soitetaan…</string>
<string name="WebRtcCallActivity__group_call">Ryhmäpuhelu</string>
<!--WebRtcCallView-->
<string name="WebRtcCallView__signal_voice_call">Signal-puhelu…</string>
<string name="WebRtcCallView__signal_video_call">Signal-videopuhelu…</string>
<string name="WebRtcCallView__start_call">Aloita puhelu</string>
<string name="WebRtcCallView__group_call">Ryhmäpuhelu</string>
<!--CallParticipantsListDialog-->
<!--RegistrationActivity-->
<string name="RegistrationActivity_select_your_country">Valitse maasi</string>
@ -2215,6 +2223,7 @@ Vastaanotetiin avaintenvaihtoviesti, joka kuuluu väärälle protokollaversiolle
<string name="RecipientBottomSheet_insecure_voice_call_description">Salaamaton äänipuhelu</string>
<string name="RecipientBottomSheet_video_call_description">Videopuhelu</string>
<string name="RecipientBottomSheet_remove_s_as_group_admin">Poista %1$s ryhmän ylläpitäjistä?</string>
<string name="RecipientBottomSheet_remove_s_from_the_group">Poista %1$s ryhmästä?</string>
<string name="RecipientBottomSheet_remove">Poista</string>
<string name="RecipientBottomSheet_copied_to_clipboard">Kopioitu leikepöydälle</string>
<string name="GroupRecipientListItem_admin">Ylläpitäjä</string>

View File

@ -128,7 +128,7 @@
<string name="ClearProfileActivity_remove_group_photo">Supprimer la photo du groupe?</string>
<!--ClientDeprecatedActivity-->
<string name="ClientDeprecatedActivity_update_signal">Mettre Signal à jour</string>
<string name="ClientDeprecatedActivity_update">Mettre à jour</string>
<string name="ClientDeprecatedActivity_update">Mise à jour</string>
<string name="ClientDeprecatedActivity_warning">Avertissement</string>
<!--CommunicationActions-->
<string name="CommunicationActions_no_browser_found">Aucun navigateur Web na été trouvé.</string>
@ -226,7 +226,7 @@
<string name="ConversationActivity_signal_needs_the_recording_permissions_to_capture_video">Signal a besoin de lautorisation Microphone afin denregistrer des vidéos, mais elle a été refusée. Veuillez accéder aux paramètres de lappli, sélectionner Autorisations et activer Microphone et Appareil photo.</string>
<string name="ConversationActivity_signal_needs_recording_permissions_to_capture_video">Signal a besoin de lautorisation Microphone afin denregistrer des vidéos.</string>
<string name="ConversationActivity_quoted_contact_message">%1$s %2$s</string>
<string name="ConversationActivity_signal_cannot_sent_sms_mms_messages_because_it_is_not_your_default_sms_app">Signal ne peut ni envoyer de textos ni de messages multimédias, car ce nest pas votre appli de textos par défaut. Souhaitez-vous changer cela dans vos paramètres dAndroid?</string>
<string name="ConversationActivity_signal_cannot_sent_sms_mms_messages_because_it_is_not_your_default_sms_app">Signal ne peut ni envoyer de textos ni de messages multimédias, car ce nest pas votre appli de textos par défaut. Voulez-vous changer cela dans vos paramètres dAndroid?</string>
<string name="ConversationActivity_yes">Oui</string>
<string name="ConversationActivity_no">Non</string>
<string name="ConversationActivity_search_position">%1$d sur %2$d</string>
@ -283,7 +283,7 @@
<string name="ConversationFragment_deleting_messages">Suppression des messages…</string>
<string name="ConversationFragment_delete_for_me">Supprimer pour moi</string>
<string name="ConversationFragment_delete_for_everyone">Supprimer pour tous</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Ce message sera irrémédiablement supprimé pour tous les membres de la conversation. Les membres pourront voir que vous avez supprimé un message.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Ce message sera irrémédiablement supprimé pour tous les membres de la conversation. Les membres pourront voir que vous avez supprimé un message.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Le message original est introuvable</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Le message original nest plus disponible</string>
<string name="ConversationFragment_failed_to_open_message">Échec douverture du message</string>
@ -398,7 +398,7 @@
<string name="ChooseNewAdminActivity_you_left">Vous avez quitté « %1$s ».</string>
<!--GroupShareProfileView-->
<string name="GroupShareProfileView_share_your_profile_name_and_photo_with_this_group">Partager vos nom et photo de profil avec ce groupe?</string>
<string name="GroupShareProfileView_do_you_want_to_make_your_profile_name_and_photo_visible_to_all_current_and_future_members_of_this_group">Souhaitez-vous que vos nom et photo de profil soient visibles pour tous les membres actuels et futurs de ce groupe?</string>
<string name="GroupShareProfileView_do_you_want_to_make_your_profile_name_and_photo_visible_to_all_current_and_future_members_of_this_group">Voulez-vous que vos nom et photo de profil soient visibles pour tous les membres actuels et futurs de ce groupe?</string>
<string name="GroupShareProfileView_make_visible">Rendre visible</string>
<!--GroupMembersDialog-->
<string name="GroupMembersDialog_you">Vous</string>
@ -588,16 +588,16 @@
<string name="ShareableGroupLinkDialogFragment__default">Valeur par défaut</string>
<string name="ShareableGroupLinkDialogFragment__group_link_reset">Réinitialisation du lien de groupe</string>
<string name="ShareableGroupLinkDialogFragment__require_an_admin_to_approve_new_members_joining_via_the_group_link">Exiger quun administrateur approuve les nouveaux membres qui se joignent grâce au lien de groupe.</string>
<string name="ShareableGroupLinkDialogFragment__are_you_sure_you_want_to_reset_the_group_link">Souhaitez-vous vraiment réinitialiser le lien du groupe? Personne ne pourra plus se joindre au groupe grâce au lien actuel.</string>
<string name="ShareableGroupLinkDialogFragment__are_you_sure_you_want_to_reset_the_group_link">Voulez-vous vraiment réinitialiser le lien du groupe? Personne ne pourra plus se joindre au groupe grâce au lien actuel.</string>
<!--GroupLinkShareQrDialogFragment-->
<string name="GroupLinkShareQrDialogFragment__qr_code">Code QR</string>
<string name="GroupLinkShareQrDialogFragment__people_who_scan_this_code_will">Les personnes qui balaieront ce code pourront se joindre à votre groupe. Les administrateurs devront quand même approuver les nouveaux membres si vous avez activé ce paramètre.</string>
<string name="GroupLinkShareQrDialogFragment__share_code">Partager le code</string>
<!--GV2 Invite Revoke confirmation dialog-->
<string name="InviteRevokeConfirmationDialog_revoke_own_single_invite">Souhaitez-vous révoquer linvitation envoyée à %1$s?</string>
<string name="InviteRevokeConfirmationDialog_revoke_own_single_invite">Voulez-vous révoquer linvitation envoyée à %1$s?</string>
<plurals name="InviteRevokeConfirmationDialog_revoke_others_invites">
<item quantity="one">Souhaitez-vous révoquer linvitation envoyée par %1$s?</item>
<item quantity="other">Souhaitez-vous révoquer %2$d invitations envoyées par %1$s?</item>
<item quantity="one">Voulez-vous révoquer linvitation envoyée par %1$s?</item>
<item quantity="other">Voulez-vous révoquer %2$d invitations envoyées par %1$s?</item>
</plurals>
<!--GroupJoinBottomSheetDialogFragment-->
<string name="GroupJoinBottomSheetDialogFragment_you_are_already_a_member">Vous êtes déjà membre</string>
@ -607,7 +607,7 @@
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Une erreur réseau est survenue.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Ce lien de groupe est inactif</string>
<string name="GroupJoinBottomSheetDialogFragment_unable_to_get_group_information_please_try_again_later">Impossible de récupérer les renseignements du groupe, veuillez réessayer plus tard</string>
<string name="GroupJoinBottomSheetDialogFragment_direct_join">Souhaitez-vous vous joindre à ce groupe et partager votre nom et votre photo avec ses membres?</string>
<string name="GroupJoinBottomSheetDialogFragment_direct_join">Voulez-vous vous joindre à ce groupe et partager votre nom et votre photo avec ses membres?</string>
<string name="GroupJoinBottomSheetDialogFragment_admin_approval_needed">Un administrateur de ce groupe doit approuver votre demande avant que vous puissiez vous joindre à ce groupe. Quand vous demandez à vous joindre, votre nom et votre photo sont partagés avec ses membres.</string>
<plurals name="GroupJoinBottomSheetDialogFragment_group_dot_d_members">
<item quantity="one">Groupe · %1$d membre</item>
@ -1529,7 +1529,7 @@
<string name="log_submit_activity__please_review_this_log_from_my_app">Veuillez examiner ce journal provenant de mon appli : %1$s</string>
<string name="log_submit_activity__network_failure">Échec réseau. Veuillez réessayer.</string>
<!--database_migration_activity-->
<string name="database_migration_activity__would_you_like_to_import_your_existing_text_messages">Souhaitez-vous importer vos textos existants dans la base de données chiffrée de Signal?</string>
<string name="database_migration_activity__would_you_like_to_import_your_existing_text_messages">Voulez-vous importer vos textos existants dans la base de données chiffrée de Signal?</string>
<string name="database_migration_activity__the_default_system_database_will_not_be_modified">La base de données par défaut du système ne sera aucunement modifiée ni altérée.</string>
<string name="database_migration_activity__skip">Ignorer</string>
<string name="database_migration_activity__import">Importer</string>
@ -1798,7 +1798,7 @@
<string name="preferences_storage__this_will_permanently_delete_all_message_history_and_media">Lhistorique et les contenus multimédias des messages de plus de %1$s seront irrémédiablement supprimés de votre appareil.</string>
<string name="preferences_storage__this_will_permanently_trim_all_conversations_to_the_d_most_recent_messages">Toutes les conversations seront irrémédiablement réduites aux %1$s messages les plus récents.</string>
<string name="preferences_storage__this_will_delete_all_message_history_and_media_from_your_device">Tout lhistorique et tous les contenus multimédias des messages seront irrémédiablement supprimés de votre appareil.</string>
<string name="preferences_storage__are_you_sure_you_want_to_delete_all_message_history">Souhaitez-vous vraiment supprimer tout lhistorique des messages?</string>
<string name="preferences_storage__are_you_sure_you_want_to_delete_all_message_history">Voulez-vous vraiment supprimer tout lhistorique des messages?</string>
<string name="preferences_storage__all_message_history_will_be_permanently_removed_this_action_cannot_be_undone">Tout lhistorique des messages sera irrémédiablement supprimé. Cette action ne peut pas être annulée.</string>
<string name="preferences_storage__delete_all_now">Tout supprimer maintenant</string>
<string name="preferences_storage__forever">Pour toujours</string>
@ -2224,7 +2224,7 @@
<string name="GroupsLearnMore_legacy_vs_new_groups">Les groupes hérités comparés aux nouveaux</string>
<string name="GroupsLearnMore_what_are_legacy_groups">Que sont les groupes hérités?</string>
<string name="GroupsLearnMore_paragraph_1">Les groupes hérités sont des groupes qui ne sont pas compatibles avec les fonctions des nouveaux groupes, telles que les administrateurs et les mises à jour plus descriptives des groupes.</string>
<string name="GroupsLearnMore_can_i_upgrade_a_legacy_group">Puis-je mettre un groupe hérité à niveau?</string>
<string name="GroupsLearnMore_can_i_upgrade_a_legacy_group">Puis-je transformer un groupe hérité en nouveau groupe?</string>
<!--GroupLinkBottomSheetDialogFragment-->
<string name="GroupLinkBottomSheet_share_via_signal">Partager avec Signal</string>
<string name="GroupLinkBottomSheet_copy">Copier</string>

View File

@ -90,6 +90,7 @@
<string name="BlockUnblockDialog_you_will_no_longer_receive_messages_or_updates">Više nećete primati poruke ili ažuriranja iz ove grupe, a članovi grupe vas neće moći naknadno dodati u grupu.</string>
<string name="BlockUnblockDialog_group_members_wont_be_able_to_add_you">Članovi grupe vas neće moći ponovno dodati u ovu grupu.</string>
<string name="BlockUnblockDialog_group_members_will_be_able_to_add_you">Članovi grupe će vas moći ponovno dodati u ovu grupu.</string>
<string name="BlockUnblockDialog_you_will_be_able_to_call_and_message_each_other">Moći će te se razmjenjivati poruke i pozive, a vaše ime i fotografija podijelit će se s njima.</string>
<string name="BlockUnblockDialog_blocked_people_wont_be_able_to_call_you_or_send_you_messages">Blokirani korisnici vas neće moći nazvati niti vam slati poruke.</string>
<string name="BlockUnblockDialog_unblock_s">Odblokiraj %1$s?</string>
<string name="BlockUnblockDialog_unblock">Odblokiraj</string>
@ -130,8 +131,11 @@
<string name="ClearProfileActivity_remove_group_photo">Ukloniti sliku grupe?</string>
<!--ClientDeprecatedActivity-->
<string name="ClientDeprecatedActivity_update_signal">Ažuriraj Signal</string>
<string name="ClientDeprecatedActivity_this_version_of_the_app_is_no_longer_supported">Ova verzija aplikacije više nije podržana. Da biste nastavili slati i primati poruke, ažurirajte na najnoviju verziju.</string>
<string name="ClientDeprecatedActivity_update">Ažuriraj</string>
<string name="ClientDeprecatedActivity_dont_update">Ne ažuriraj</string>
<string name="ClientDeprecatedActivity_warning">Upozorenje</string>
<string name="ClientDeprecatedActivity_your_version_of_signal_has_expired_you_can_view_your_message_history">Vaša verzija Signala je istekla. Možete pregledati svoju povijest poruka, ali nećete moći slati ili primati poruke dok ne ažurirate.</string>
<!--CommunicationActions-->
<string name="CommunicationActions_no_browser_found">Nije pronađen nijedan web preglednik.</string>
<string name="CommunicationActions_no_email_app_found">Nije pronađena nijedna aplikacija za e-poštu.</string>
@ -214,7 +218,9 @@
<string name="ConversationActivity_attachment_exceeds_size_limits">Privitak premašuje ograničenja veličine za vrstu poruke koju šaljete.</string>
<string name="ConversationActivity_quick_camera_unavailable">Kamera nije dostupna</string>
<string name="ConversationActivity_unable_to_record_audio">Nije moguće snimiti zvuk!</string>
<string name="ConversationActivity_you_cant_send_messages_to_this_group">Ne možete slati poruke jer više niste član/ica ove grupe.</string>
<string name="ConversationActivity_there_is_no_app_available_to_handle_this_link_on_your_device">Na vašem uređaju nema dostupne aplikacije za obradu ove poveznice.</string>
<string name="ConversationActivity_your_request_to_join_has_been_sent_to_the_group_admin">Vaš zahtjev za pridruživanje poslan je administratoru grupe. Biti će te obaviješteni kada nešto poduzmu.</string>
<string name="ConversationActivity_cancel_request">Poništi zahtjev</string>
<string name="ConversationActivity_to_send_audio_messages_allow_signal_access_to_your_microphone">Za slanje zvučnih poruka, omogućite Signalu pristup vašem mikrofonu.</string>
<string name="ConversationActivity_signal_requires_the_microphone_permission_in_order_to_send_audio_messages">Signal zahtijeva dopuštenje za mikrofon za slanje zvučnih poruka, ali je trajno odbijen. Otvorite postavke aplikacije, odaberite \"Dozvole\" i omogućite \"Mikrofon\".</string>
@ -242,6 +248,7 @@
<string name="ConversationActivity_delete_and_leave">Izbriši i napusti</string>
<string name="ConversationActivity__to_call_s_signal_needs_access_to_your_microphone">Da biste uputili poziv za %1$s, omogućite Signalu pristup vašem mikrofonu</string>
<string name="ConversationActivity__to_call_s_signal_needs_access_to_your_microphone_and_camera">Da biste uputili poziv za %1$s, omogućite Signalu pristup vašem mikrofonu i kameri.</string>
<string name="ConversationActivity__more_options_now_in_group_settings">Više opcija je sada u \"Postavke grupe\"</string>
<!--ConversationAdapter-->
<plurals name="ConversationAdapter_n_unread_messages">
<item quantity="one">%d nepročitana poruka</item>
@ -289,7 +296,7 @@
<string name="ConversationFragment_deleting_messages">Brisanje poruka…</string>
<string name="ConversationFragment_delete_for_me">Izbriši za mene</string>
<string name="ConversationFragment_delete_for_everyone">Izbriši za sve</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Ova poruka će biti trajno izbrisana za sve osobe u razgovoru. Članovi će moći vidjeti da ste izbrisali poruku.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Ova poruka će biti trajno izbrisana za sve osobe u razgovoru. Članovi će moći vidjeti da ste izbrisali poruku.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Originalna poruka nije pronađena</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Originalna poruka više nije dostupna</string>
<string name="ConversationFragment_failed_to_open_message">Otvaranje poruke nije uspjelo</string>
@ -297,6 +304,7 @@
<string name="ConversationFragment_you_can_swipe_to_the_left_reply">Za brzi odgovor, prijeđite prstom ulijevo po bilo kojoj poruci</string>
<string name="ConversationFragment_outgoing_view_once_media_files_are_automatically_removed">Odlazni medijski zapisi koji su vidljivi samo jednom se automatski uklanjaju nakon što su poslani</string>
<string name="ConversationFragment_you_already_viewed_this_message">Već ste pregledali ovu poruku</string>
<string name="ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation">U ovom razgovoru možete dodati osobne bilješke.\nAko vaš račun ima povezane uređaje, nove će se bilješke sinkronizirati.</string>
<!--ConversationListActivity-->
<string name="ConversationListActivity_there_is_no_browser_installed_on_your_device">Na vašem uređaju nije instaliran preglednik.</string>
<!--ConversationListFragment-->
@ -350,6 +358,7 @@
<string name="AvatarSelectionBottomSheetDialogFragment__take_photo">Uslikaj</string>
<string name="AvatarSelectionBottomSheetDialogFragment__choose_from_gallery">Odaberi iz galerije</string>
<string name="AvatarSelectionBottomSheetDialogFragment__remove_photo">Ukloni sliku</string>
<string name="AvatarSelectionBottomSheetDialogFragment__taking_a_photo_requires_the_camera_permission">Za snimanje fotografija potrebno je dopuštenje kamere.</string>
<!--DateUtils-->
<string name="DateUtils_just_now">Sada</string>
<string name="DateUtils_minutes_ago">%dm</string>
@ -379,6 +388,8 @@
<string name="DozeReminder_optimize_for_missing_play_services">Optimiziraj za nedostatak Play Services</string>
<string name="DozeReminder_this_device_does_not_support_play_services_tap_to_disable_system_battery">Ovaj uređaj ne podržava Play Services. Pritisnite kako biste onemogućili sistemsku optimizaciju baterije koja spriječava Signal da dohvaća poruke dok je uređaj neaktivan.</string>
<!--ExpiredBuildReminder-->
<string name="ExpiredBuildReminder_this_version_of_signal_has_expired">Ova verzija Signala je istekla. Ažurirajte odmah da biste slali i primali poruke.</string>
<string name="ExpiredBuildReminder_update_now">Ažuriraj sada</string>
<!--ShareActivity-->
<string name="ShareActivity_share_with">Podijeli s</string>
<string name="ShareActivity_multiple_attachments_are_only_supported">Višestruki privitci podržani su samo za slike i videozapise</string>
@ -393,6 +404,9 @@
<string name="GiphyFragmentPagerAdapter_gifs">GIF-ovi</string>
<string name="GiphyFragmentPagerAdapter_stickers">Naljepnice</string>
<!--AddToGroupActivity-->
<string name="AddToGroupActivity_add_member">Dodaj člana?</string>
<string name="AddToGroupActivity_add_s_to_s">Dodaj \"%1$s\" u \"%2$s\"?</string>
<string name="AddToGroupActivity_s_added_to_s">\"%1$s\" je dodan/a u \"%2$s\".</string>
<string name="AddToGroupActivity_add_to_group">Dodaj u grupu</string>
<string name="AddToGroupActivity_add_to_groups">Dodaj u grupe</string>
<string name="AddToGroupActivity_this_person_cant_be_added_to_legacy_groups">Ovu osobu nije moguće dodati u naslijeđene grupe.</string>
@ -418,13 +432,16 @@
<item quantity="few">Poslane su %d pozivnice</item>
<item quantity="other">Poslano je %d pozivnica</item>
</plurals>
<string name="GroupManagement_invite_single_user">Ne možete automatski dodati \"%1$s\" u grupu.\n\nKorisnik je pozvan i neće vidjeti poruke grupe dok ne prihvati pozivnicu.</string>
<string name="GroupManagement_learn_more">Saznaj više</string>
<string name="GroupManagement_invite_multiple_users">Ne možete automatski dodati ove korisnike u grupu.\n\nKorisnik je pozvan u grupu i neće vidjeti poruke grupe dok ne prihvati pozivnicu.</string>
<!--LeaveGroupDialog-->
<string name="LeaveGroupDialog_leave_group">Napusti grupu?</string>
<string name="LeaveGroupDialog_you_will_no_longer_be_able_to_send_or_receive_messages_in_this_group">Nećete više biti u mogućnosti slati i primati poruke u ovoj grupi.</string>
<string name="LeaveGroupDialog_leave">Napusti</string>
<string name="LeaveGroupDialog_choose_new_admin">Odaberi novog administratora</string>
<string name="LeaveGroupDialog_before_you_leave_you_must_choose_at_least_one_new_admin_for_this_group">Prije nego što napustite, morate odabrati novog administratora za ovu grupu.</string>
<string name="LeaveGroupDialog_choose_admin">Odaberite administratora</string>
<!--LinkPreviewsMegaphone-->
<string name="LinkPreviewsMegaphone_disable">Onemogući</string>
<string name="LinkPreviewsMegaphone_preview_any_link">Pregledajte bilo koju poveznicu</string>
@ -434,6 +451,11 @@
<string name="LinkPreviewView_this_group_link_is_not_active">Grupna poveznica nije aktivna</string>
<string name="LinkPreviewView_domain_date">%1$s · %2$s</string>
<!--LinkPreviewRepository-->
<plurals name="LinkPreviewRepository_d_members">
<item quantity="one">%1$d član</item>
<item quantity="few">%1$d člana</item>
<item quantity="other">%1$d članova</item>
</plurals>
<!--PendingMembersActivity-->
<string name="PendingMemberInvitesActivity_pending_group_invites">Grupne pozivnice na čekanju</string>
<string name="PendingMembersActivity_requests">Zahtjevi</string>
@ -445,11 +467,30 @@
<string name="PendingMembersActivity_missing_detail_explanation">Pojedinosti o osobama koje su pozvali drugi članovi grupe nisu prikazani. Ako se pozvane osobe odluče pridružiti, njihovi će se podaci tada podijeliti s grupom. Neće vidjeti poruke u grupi dok se ne pridruže.</string>
<string name="PendingMembersActivity_revoke_invite">Opozovi pozivnicu</string>
<string name="PendingMembersActivity_revoke_invites">Opozovi pozivnice</string>
<plurals name="PendingMembersActivity_revoke_d_invites">
<item quantity="one">Opozovi pozivnicu</item>
<item quantity="few">Opozovi %1$d pozivnice</item>
<item quantity="other">Opozovi %1$d pozivnice</item>
</plurals>
<plurals name="PendingMembersActivity_error_revoking_invite">
<item quantity="one">Pogreška pri opozivu pozivnice</item>
<item quantity="few">Pogreška pri opozivu pozivnica</item>
<item quantity="other">Pogreška pri opozivu pozivnica</item>
</plurals>
<!--RequestingMembersFragment-->
<string name="RequestingMembersFragment_pending_member_requests">Zahtjevi za članove na čekanju</string>
<string name="RequestingMembersFragment_no_member_requests_to_show">Nema zahtjeva za članove</string>
<string name="RequestingMembersFragment_explanation">Osobe s ovog popisa pokušavaju se pridružiti ovoj grupi putem grupne poveznice.</string>
<string name="RequestingMembersFragment_added_s">Dodan/a je \"%1$s\"</string>
<string name="RequestingMembersFragment_denied_s">Odbijen/a je “%1$s“</string>
<!--AddMembersActivity-->
<string name="AddMembersActivity__done">Gotovo</string>
<string name="AddMembersActivity__this_person_cant_be_added_to_legacy_groups">Ovu osobu nije moguće dodati u naslijeđene grupe.</string>
<plurals name="AddMembersActivity__add_d_members_to_s">
<item quantity="one">Dodaj \"%1$s\" u \"%2$s\"?</item>
<item quantity="few">Dodaj %3$d člana u \"%2$s\"?</item>
<item quantity="other">Dodaj %3$d članova u \"%2$s\"?</item>
</plurals>
<string name="AddMembersActivity__add">Dodaj</string>
<!--AddGroupDetailsFragment-->
<string name="AddGroupDetailsFragment__name_this_group">Dodijelite naziv grupi</string>
@ -464,6 +505,7 @@
<string name="AddGroupDetailsFragment__youve_selected_a_contact_that_doesnt">Odabrali ste kontakt koji ne podržava Signal grupe, pa će ovo biti MMS grupa.</string>
<string name="AddGroupDetailsFragment__remove">Ukloni</string>
<string name="AddGroupDetailsFragment__sms_contact">SMS kontakt</string>
<string name="AddGroupDetailsFragment__remove_s_from_this_group">Ukloniti %1$s iz ove grupe?</string>
<plurals name="AddGroupDetailsFragment__d_members_do_not_support_new_groups">
<item quantity="one">%d član ne podržava Nove grupe, tako da će ovo biti Naslijeđena grupa.</item>
<item quantity="few">%d člana ne podržavaju Nove grupe, tako da će ovo biti Naslijeđena grupa.</item>
@ -479,9 +521,14 @@
<!--ManageGroupActivity-->
<string name="ManageGroupActivity_disappearing_messages">Poruke koje nestaju</string>
<string name="ManageGroupActivity_pending_group_invites">Grupne pozivnice na čekanju</string>
<string name="ManageGroupActivity_member_requests_and_invites">Zahtjevi za članove i pozivnice</string>
<string name="ManageGroupActivity_add_members">Dodaj članove</string>
<string name="ManageGroupActivity_edit_group_info">Uredi detalje o grupi</string>
<string name="ManageGroupActivity_who_can_add_new_members">Tko može dodati nove članove?</string>
<string name="ManageGroupActivity_who_can_edit_this_groups_info">Tko može urediti detalje o grupi?</string>
<string name="ManageGroupActivity_group_link">Grupna poveznica</string>
<string name="ManageGroupActivity_block_group">Blokiraj grupu</string>
<string name="ManageGroupActivity_unblock_group">Odblokiraj grupu</string>
<string name="ManageGroupActivity_leave_group">Napusti grupu</string>
<string name="ManageGroupActivity_mute_notifications">Utišaj obavijesti</string>
<string name="ManageGroupActivity_custom_notifications">Prilagođene obavijesti</string>
@ -490,17 +537,41 @@
<string name="ManageGroupActivity_off">Isključeno</string>
<string name="ManageGroupActivity_on">Uključeno</string>
<string name="ManageGroupActivity_view_all_members">Prikaži sve članove</string>
<string name="ManageGroupActivity_see_all">Prikaži sve</string>
<string name="ManageGroupActivity_none">Nijedno</string>
<plurals name="ManageGroupActivity_invited">
<item quantity="one">%d pozvan</item>
<item quantity="few">%d pozvana</item>
<item quantity="other">%d pozvano</item>
</plurals>
<plurals name="ManageGroupActivity_added">
<item quantity="one">%d član dodan.</item>
<item quantity="few">%d člana dodana.</item>
<item quantity="other">%d članova dodano.</item>
</plurals>
<string name="ManageGroupActivity_you_dont_have_the_rights_to_do_this">Nemate prava na ovo</string>
<string name="ManageGroupActivity_not_capable">Netko koga ste dodali ne podržava nove grupe i mora ažurirati Signal</string>
<string name="ManageGroupActivity_failed_to_update_the_group">Ažuriranje grupe nije uspjelo</string>
<string name="ManageGroupActivity_youre_not_a_member_of_the_group">Vi niste član ove grupe</string>
<string name="ManageGroupActivity_failed_to_update_the_group_please_retry_later">Ažuriranje grupe nije uspjelo, pokušajte ponovno</string>
<string name="ManageGroupActivity_failed_to_update_the_group_due_to_a_network_error_please_retry_later">Ažuriranje grupe nije uspjelo zbog mrežne pogreške, pokušajte ponovno</string>
<string name="ManageGroupActivity_edit_name_and_picture">Uredi naziv i sliku</string>
<string name="ManageGroupActivity_legacy_group">Naslijeđena grupa</string>
<string name="ManageGroupActivity_legacy_group_learn_more">Ovo je Naslijeđena grupa. Značajke poput grupnih administratora su dostupne samo za Nove grupe.</string>
<string name="ManageGroupActivity_this_is_an_insecure_mms_group">Ovo je nesigurna MMS grupa. Da biste privatno razgovarali i pristupili značajkama poput naziva grupa, pozovite svoje kontakte u Signal.</string>
<string name="ManageGroupActivity_invite_now">Pozovi sada</string>
<!--GroupMentionSettingDialog-->
<string name="GroupMentionSettingDialog_notify_me_for_mentions">Obavijesti me samo za Spominjanja</string>
<string name="GroupMentionSettingDialog_receive_notifications_when_youre_mentioned_in_muted_chats">Primi obavijesti kad vas se spominje u utišanim razgovorima?</string>
<string name="GroupMentionSettingDialog_default_notify_me">Zadano (Obavijesti me)</string>
<string name="GroupMentionSettingDialog_default_dont_notify_me">Zadano (Ne obavještavaj me)</string>
<string name="GroupMentionSettingDialog_always_notify_me">Uvijek me obavijesti</string>
<string name="GroupMentionSettingDialog_dont_notify_me">Ne obavještavaj me</string>
<!--ManageRecipientActivity-->
<string name="ManageRecipientActivity_add_to_system_contacts">Dodaj u kontakte na uređaju</string>
<string name="ManageRecipientActivity_this_person_is_in_your_contacts">Ovaj korisnik je u vašim kontaktima</string>
<string name="ManageRecipientActivity_disappearing_messages">Poruke koje nestaju</string>
<string name="ManageRecipientActivity_chat_color">Boja razgovora</string>
<string name="ManageRecipientActivity_block">Blokiraj</string>
<string name="ManageRecipientActivity_unblock">Odblokiraj</string>
<string name="ManageRecipientActivity_view_safety_number">Prikaži sigurnosni broj</string>
@ -510,6 +581,14 @@
<string name="ManageRecipientActivity_off">Isključeno</string>
<string name="ManageRecipientActivity_on">Uključeno</string>
<string name="ManageRecipientActivity_add_to_a_group">Dodaj u grupu</string>
<string name="ManageRecipientActivity_view_all_groups">Prikaži sve grupe</string>
<string name="ManageRecipientActivity_see_all">Prikaži sve</string>
<string name="ManageRecipientActivity_no_groups_in_common">Nema zajedničkih grupa</string>
<plurals name="ManageRecipientActivity_d_groups_in_common">
<item quantity="one">%d zajednička grupa</item>
<item quantity="few">%d zajedničke grupe</item>
<item quantity="other">%d zajedničkih grupa</item>
</plurals>
<string name="ManageRecipientActivity_edit_name_and_picture">Uredite ime i sliku</string>
<string name="ManageRecipientActivity_message_description">Poruka</string>
<string name="ManageRecipientActivity_voice_call_description">Glasovni poziv</string>
@ -525,7 +604,7 @@
<string name="CustomNotificationsDialogFragment__messages">Poruke</string>
<string name="CustomNotificationsDialogFragment__use_custom_notifications">Koristi prilagođene obavijesti</string>
<string name="CustomNotificationsDialogFragment__notification_sound">Zvuk obavijesti</string>
<string name="CustomNotificationsDialogFragment__vibrate">Vibriraj</string>
<string name="CustomNotificationsDialogFragment__vibrate">Vibracija</string>
<string name="CustomNotificationsDialogFragment__call_settings">Postavke poziva</string>
<string name="CustomNotificationsDialogFragment__ringtone">Melodija zvona</string>
<string name="CustomNotificationsDialogFragment__enabled">Omogućeno</string>
@ -533,8 +612,12 @@
<string name="CustomNotificationsDialogFragment__default">Zadano</string>
<!--ShareableGroupLinkDialogFragment-->
<string name="ShareableGroupLinkDialogFragment__shareable_group_link">Grupna poveznica koja se može dijeliti</string>
<string name="ShareableGroupLinkDialogFragment__manage_and_share">Upravljanje i dijeljenje</string>
<string name="ShareableGroupLinkDialogFragment__group_link">Grupna poveznica</string>
<string name="ShareableGroupLinkDialogFragment__share">Podijeli</string>
<string name="ShareableGroupLinkDialogFragment__reset_link">Poništi poveznicu</string>
<string name="ShareableGroupLinkDialogFragment__member_requests">Zahtjevi za članove</string>
<string name="ShareableGroupLinkDialogFragment__approve_new_members">Odobri nove članove</string>
<string name="ShareableGroupLinkDialogFragment__enabled">Omogućeno</string>
<string name="ShareableGroupLinkDialogFragment__disabled">Onemogućeno</string>
<string name="ShareableGroupLinkDialogFragment__default">Zadano</string>
@ -542,21 +625,42 @@
<string name="ShareableGroupLinkDialogFragment__require_an_admin_to_approve_new_members_joining_via_the_group_link">Zahtjeva da administrator odobri pridruživanje novih članova putem grupne poveznice.</string>
<string name="ShareableGroupLinkDialogFragment__are_you_sure_you_want_to_reset_the_group_link">Jeste li sigurni da želite poništiti grupnu poveznicu? Osobe se više neće moći pridružiti grupi koristeći trenutnu poveznicu.</string>
<!--GroupLinkShareQrDialogFragment-->
<string name="GroupLinkShareQrDialogFragment__qr_code">QR kôd</string>
<string name="GroupLinkShareQrDialogFragment__people_who_scan_this_code_will">Osobe koji skeniraju ovaj kôd moći će se pridružiti vašoj grupi. Administratori će i dalje trebati odobriti nove članove ako je ta postavka uključena.</string>
<string name="GroupLinkShareQrDialogFragment__share_code">Podijeli kôd</string>
<!--GV2 Invite Revoke confirmation dialog-->
<string name="InviteRevokeConfirmationDialog_revoke_own_single_invite">Želite li opozvati pozivnicu koju ste poslali %1$s?</string>
<plurals name="InviteRevokeConfirmationDialog_revoke_others_invites">
<item quantity="one">Želite li opozvati pozivnicu koju je poslao/la %1$s?</item>
<item quantity="few">Želite li opozvati %2$d pozivnice koje je poslao/la %1$s?</item>
<item quantity="other">Želite li opozvati %2$d pozivnica koje je poslao/la %1$s?</item>
</plurals>
<!--GroupJoinBottomSheetDialogFragment-->
<string name="GroupJoinBottomSheetDialogFragment_you_are_already_a_member">Već ste član</string>
<string name="GroupJoinBottomSheetDialogFragment_join">Pridruži se</string>
<string name="GroupJoinBottomSheetDialogFragment_request_to_join">Zahtjev za pridruživanje</string>
<string name="GroupJoinBottomSheetDialogFragment_unable_to_join_group_please_try_again_later">Nije moguće pridružiti se grupi. Molimo pokušajte ponovo kasnije</string>
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Došlo je do mrežne pogreške.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Grupna poveznica nije aktivna</string>
<string name="GroupJoinBottomSheetDialogFragment_unable_to_get_group_information_please_try_again_later">Nije moguće dohvatiti detalje o grupi. Molimo pokušajte ponovo kasnije</string>
<string name="GroupJoinBottomSheetDialogFragment_direct_join">Želite li se pridružiti ovoj grupi i podijeliti svoje ime i fotografiju s članovima?</string>
<string name="GroupJoinBottomSheetDialogFragment_admin_approval_needed">Administrator ove grupe mora odobriti vaš zahtjev prije nego što se možete pridružiti ovoj grupi. Kada zatražite pridruživanje, vaše će ime i fotografija biti podijeljena sa članovima grupe.</string>
<plurals name="GroupJoinBottomSheetDialogFragment_group_dot_d_members">
<item quantity="one">Grupa · %1$d član</item>
<item quantity="few">Grupa · %1$d člana</item>
<item quantity="other">Grupa · %1$d članova</item>
</plurals>
<!--GroupJoinUpdateRequiredBottomSheetDialogFragment-->
<string name="GroupJoinUpdateRequiredBottomSheetDialogFragment_group_links_coming_soon">Grupne poveznice uskoro dolaze</string>
<string name="GroupJoinUpdateRequiredBottomSheetDialogFragment_update_signal_to_use_group_links">Ažurirajte Signal da biste koristili grupne poveznice</string>
<string name="GroupJoinUpdateRequiredBottomSheetDialogFragment_coming_soon">Pridruživanje grupi putem poveznice još nije podržano u Signalu. Ova će značajka biti objavljena u nadolazećem ažuriranju.</string>
<string name="GroupJoinUpdateRequiredBottomSheetDialogFragment_update_message">Verzija Signala koju upotrebljavate ne podržava grupne poveznice. Ažurirajte na najnoviju verziju da biste se pridružili ovoj grupi putem poveznice.</string>
<string name="GroupJoinUpdateRequiredBottomSheetDialogFragment_update_signal">Ažuriraj Signal</string>
<string name="GroupJoinUpdateRequiredBottomSheetDialogFragment_update_linked_device_message">Na jednom ili više povezanih uređaja pokrenuta je verzija Signala koja ne podržava grupne poveznice. Ažurirajte Signal na povezanim uređajima da biste se pridružili ovoj grupi.</string>
<string name="GroupJoinUpdateRequiredBottomSheetDialogFragment_group_link_is_not_valid">Grupna poveznica je nevažeća</string>
<!--GV2 Request confirmation dialog-->
<string name="RequestConfirmationDialog_add_s_to_the_group">Dodaj \"%1$s\" u grupu?</string>
<string name="RequestConfirmationDialog_deny_request_from_s">Odbij zahtjev od “%1$s”?</string>
<string name="RequestConfirmationDialog_add">Dodaj</string>
<string name="RequestConfirmationDialog_deny">Odbij</string>
<!--CropImageActivity-->
@ -615,6 +719,11 @@
<item quantity="few">Želite li izbrisati odabrane stavke?</item>
<item quantity="other">Želite li izbrisati odabrane stavke?</item>
</plurals>
<plurals name="MediaOverviewActivity_Media_delete_confirm_message">
<item quantity="one">Ovo će trajno izbrisati odabranu datoteku. Također će se izbrisati i sve tekstualne poruke povezane s ovom datotekom.</item>
<item quantity="few">Ovo će trajno izbrisati %1$d odabrane datoteke. Također će se izbrisati i sve tekstualne poruke povezane s ovim datotekama.</item>
<item quantity="other">Ovo će trajno izbrisati %1$d odabranih datoteka. Također će se izbrisati i sve tekstualne poruke povezane s ovim datotekama.</item>
</plurals>
<string name="MediaOverviewActivity_Media_delete_progress_title">Brisanje</string>
<string name="MediaOverviewActivity_Media_delete_progress_message">Brisanje poruka…</string>
<string name="MediaOverviewActivity_Select_all">Odaberi sve</string>
@ -622,12 +731,33 @@
<string name="MediaOverviewActivity_Sort_by">Poredaj po</string>
<string name="MediaOverviewActivity_Newest">Najnovije</string>
<string name="MediaOverviewActivity_Oldest">Najstarije</string>
<string name="MediaOverviewActivity_Storage_used">Upotrebljeni prostor za pohranu</string>
<string name="MediaOverviewActivity_All_storage_use">Sva upotreba prostora za pohranu</string>
<string name="MediaOverviewActivity_Grid_view_description">Prikaz rešetke</string>
<string name="MediaOverviewActivity_List_view_description">Prikaz popisa</string>
<string name="MediaOverviewActivity_Selected_description">Odabrano</string>
<plurals name="MediaOverviewActivity_d_items_s">
<item quantity="one">%1$d stavka %2$s</item>
<item quantity="few">%1$d stavke %2$s</item>
<item quantity="other">%1$d stavki %2$s</item>
</plurals>
<plurals name="MediaOverviewActivity_d_items">
<item quantity="one">%1$d stavka</item>
<item quantity="few">%1$d stavke</item>
<item quantity="other">%1$d stavki</item>
</plurals>
<string name="MediaOverviewActivity_file">Datoteka</string>
<string name="MediaOverviewActivity_audio">Zvuk</string>
<string name="MediaOverviewActivity_video">Video</string>
<string name="MediaOverviewActivity_image">Slika</string>
<string name="MediaOverviewActivity_sent_by_s">Poslao/la %1$s</string>
<string name="MediaOverviewActivity_sent_by_you">Poslali ste vi</string>
<string name="MediaOverviewActivity_sent_by_s_to_s">Poslao/la %1$s za %2$s</string>
<string name="MediaOverviewActivity_sent_by_you_to_s">Poslali ste vi za %1$s</string>
<!--Megaphones-->
<string name="Megaphones_introducing_reactions">Predstavljamo reakcije</string>
<string name="Megaphones_tap_and_hold_any_message_to_quicky_share_how_you_feel">Dodirnite i držite bilo koju poruku da brzo podijelite kako se osjećate.</string>
<string name="Megaphones_remind_me_later">Podsjeti me kasnije</string>
<string name="Megaphones_verify_your_signal_pin">Potvrdite svoj Signal PIN</string>
<string name="Megaphones_well_occasionally_ask_you_to_verify_your_pin">Povremeno ćemo zatražiti da potvrdite svoj PIN kako biste ga zapamtili.</string>
<string name="Megaphones_verify_pin">Potvrdite PIN</string>
@ -645,6 +775,7 @@
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">Pogreška pri preuzimanju MMS poruke, pritisnite za ponovni pokušaj</string>
<!--MediaPickerActivity-->
<string name="MediaPickerActivity_send_to">Pošalji za %s</string>
<string name="MediaPickerActivity__menu_open_camera">Otvori kameru</string>
<!--MediaPickerItemFragment-->
<string name="MediaPickerItemFragment_tap_to_select">Pritisni za odabir slike profila</string>
<!--MediaSendActivity-->
@ -670,6 +801,7 @@
<string name="MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported">Primljena je poruka koja je šifrirana pomoću stare verzije Signala koja više nije podržana. Zamolite pošiljatelja da ažurira na najnoviju verziju i ponovo pošalje poruku.</string>
<string name="MessageRecord_left_group">Napustili ste grupu.</string>
<string name="MessageRecord_you_updated_group">Ažurirali ste grupu.</string>
<string name="MessageRecord_the_group_was_updated">Grupa je ažurirana.</string>
<string name="MessageRecord_you_called">Vi ste zvali</string>
<string name="MessageRecord_called_you">Kontakt vas je zvao</string>
<string name="MessageRecord_missed_call">Propušteni poziv</string>
@ -682,26 +814,94 @@
<string name="MessageRecord_s_disabled_disappearing_messages">%1$s je onemogućio poruke koje nestaju.</string>
<string name="MessageRecord_you_set_disappearing_message_time_to_s">Postavili ste da poruke nestaju za %1$s.</string>
<string name="MessageRecord_s_set_disappearing_message_time_to_s">%1$s je postavio/la da poruke nestaju za %2$s.</string>
<string name="MessageRecord_disappearing_message_time_set_to_s">Postavljeno je da poruke nestaju za %1$s.</string>
<!--Profile change updates-->
<string name="MessageRecord_changed_their_profile_name_to">%1$s je promijenio/la svoje ime na profilu u %2$s.</string>
<string name="MessageRecord_changed_their_profile_name_from_to">%1$s je promijenio/la svoje ime na profilu iz %2$s u %3$s.</string>
<string name="MessageRecord_changed_their_profile">%1$s je promijenio/la svoj profil.</string>
<!--GV2 specific-->
<string name="MessageRecord_you_created_the_group">Stvorili ste grupu.</string>
<string name="MessageRecord_group_updated">Grupa je ažurirana.</string>
<!--GV2 member additions-->
<string name="MessageRecord_you_added_s">Dodali ste %1$s.</string>
<string name="MessageRecord_s_added_s">%1$s je dodao/la %2$s.</string>
<string name="MessageRecord_s_added_you">%1$s vas je dodao/la u grupu.</string>
<string name="MessageRecord_you_joined_the_group">Pridružili ste se grupi.</string>
<string name="MessageRecord_s_joined_the_group">%1$s se pridružio/la grupi.</string>
<!--GV2 member removals-->
<string name="MessageRecord_you_removed_s">Uklonili ste %1$s.</string>
<string name="MessageRecord_s_removed_s">%1$s je uklonio/la %2$s.</string>
<string name="MessageRecord_s_removed_you_from_the_group">%1$s vas je uklonio/la iz grupe.</string>
<string name="MessageRecord_you_left_the_group">Napustili ste grupu.</string>
<string name="MessageRecord_s_left_the_group">%1$s je napustio/la grupu.</string>
<string name="MessageRecord_you_are_no_longer_in_the_group">Više niste član ove grupe.</string>
<string name="MessageRecord_s_is_no_longer_in_the_group">%1$s više nije član/ica grupe.</string>
<!--GV2 role change-->
<string name="MessageRecord_you_made_s_an_admin">Postavili ste %1$s za administratora.</string>
<string name="MessageRecord_s_made_s_an_admin">%1$s je postavio/la %2$s za administratora.</string>
<string name="MessageRecord_s_made_you_an_admin">%1$s vas je postavio/la za administratora.</string>
<string name="MessageRecord_you_revoked_admin_privileges_from_s">Opozvali ste administratorska prava za %1$s.</string>
<string name="MessageRecord_s_revoked_your_admin_privileges">%1$s je opozvao vaša administratorska prava.</string>
<string name="MessageRecord_s_revoked_admin_privileges_from_s">%1$s je opozvao administratorska prava za %2$s.</string>
<string name="MessageRecord_s_is_now_an_admin">%1$s je sada administrator.</string>
<string name="MessageRecord_you_are_now_an_admin">Sada ste administrator.</string>
<string name="MessageRecord_s_is_no_longer_an_admin">%1$s više nije administrator.</string>
<string name="MessageRecord_you_are_no_longer_an_admin">Više niste administrator.</string>
<!--GV2 invitations-->
<string name="MessageRecord_you_invited_s_to_the_group">Pozvali ste %1$s u grupu.</string>
<string name="MessageRecord_s_invited_you_to_the_group">%1$s vas je pozvao/la u grupu.</string>
<plurals name="MessageRecord_s_invited_members">
<item quantity="one">%1$s je pozvao/la 1 osobu u grupu.</item>
<item quantity="few">%1$s je pozvao/la %2$d osobe u grupu.</item>
<item quantity="other">%1$s je pozvao/la %2$d osoba u grupu.</item>
</plurals>
<string name="MessageRecord_you_were_invited_to_the_group">Pozvani ste u grupu.</string>
<plurals name="MessageRecord_d_people_were_invited_to_the_group">
<item quantity="one">1 osoba je pozvana u grupu.</item>
<item quantity="few">%1$d osobe su pozvane u grupu.</item>
<item quantity="other">%1$d osoba je pozvano u grupu.</item>
</plurals>
<!--GV2 invitation revokes-->
<plurals name="MessageRecord_you_revoked_invites">
<item quantity="one">Opozvali ste pozivnicu u grupu.</item>
<item quantity="few">Opozvali ste %1$d pozivnice u grupu.</item>
<item quantity="other">Opozvali ste %1$d pozivnica u grupu.</item>
</plurals>
<plurals name="MessageRecord_s_revoked_invites">
<item quantity="one">%1$s je opozvao pozivnicu u grupu.</item>
<item quantity="few">%1$s je opozvao %2$d pozivnice u grupu.</item>
<item quantity="other">%1$s je opozvao %2$d pozivnica u grupu.</item>
</plurals>
<string name="MessageRecord_someone_declined_an_invitation_to_the_group">Netko je odbio poziv u grupu.</string>
<string name="MessageRecord_you_declined_the_invitation_to_the_group">Odbili ste poziv u grupu.</string>
<string name="MessageRecord_s_revoked_your_invitation_to_the_group">%1$s je opozvao/la vašu pozivnicu u grupu.</string>
<string name="MessageRecord_an_admin_revoked_your_invitation_to_the_group">Administrator je opozvao vašu pozivnicu u grupu.</string>
<plurals name="MessageRecord_d_invitations_were_revoked">
<item quantity="one">Pozivnica u grupu je opozvana.</item>
<item quantity="few">%1$d pozivnice u grupu su opozvane.</item>
<item quantity="other">%1$d pozivnica u grupu je opozvano.</item>
</plurals>
<!--GV2 invitation acceptance-->
<string name="MessageRecord_you_accepted_invite">Prihvatili ste poziv u grupu.</string>
<string name="MessageRecord_s_accepted_invite">%1$s je prihvatio poziv u grupu.</string>
<string name="MessageRecord_you_added_invited_member_s">Dodali ste pozvanog člana/icu %1$s.</string>
<string name="MessageRecord_s_added_invited_member_s">%1$s je dodao/la pozvanog člana/icu %2$s.</string>
<!--GV2 title change-->
<string name="MessageRecord_you_changed_the_group_name_to_s">Promijenili ste naziv grupe u \"%1$s\".</string>
<string name="MessageRecord_s_changed_the_group_name_to_s">%1$s je promijenio/la naziv grupe u \"%2$s\".</string>
<string name="MessageRecord_the_group_name_has_changed_to_s">Naziv grupe je promijenjen u \"%1$s\".</string>
<!--GV2 avatar change-->
<string name="MessageRecord_you_changed_the_group_avatar">Promijenili ste sliku grupe.</string>
<string name="MessageRecord_s_changed_the_group_avatar">%1$s je promijenio/la sliku grupe.</string>
<string name="MessageRecord_the_group_group_avatar_has_been_changed">Slika grupe je promijenjena.</string>
<!--GV2 attribute access level change-->
<string name="MessageRecord_you_changed_who_can_edit_group_info_to_s">Promijenili ste tko sve može urediti detalje o grupi na \"%1$s\".</string>
<string name="MessageRecord_s_changed_who_can_edit_group_info_to_s">%1$s je promijenio tko sve može uređivati detalje o grupi na \"%2$s\".</string>
<string name="MessageRecord_who_can_edit_group_info_has_been_changed_to_s">Promijenjeno je tko sve može urediti detalje o grupi na \"%1$s\".</string>
<!--GV2 membership access level change-->
<string name="MessageRecord_you_changed_who_can_edit_group_membership_to_s">Promijenili ste tko može uređivati članstvo grupe na \"%1$s\".</string>
<string name="MessageRecord_s_changed_who_can_edit_group_membership_to_s">%1$s je promijenio tko može uređivati članstvo grupe na \"%2$s\".</string>
<string name="MessageRecord_who_can_edit_group_membership_has_been_changed_to_s">Promijenjeno je tko sve može urediti članstvo grupe na \"%1$s\".</string>
<!--GV2 group link invite access level change-->
<string name="MessageRecord_you_turned_on_the_group_link_with_admin_approval_off">Isključili ste grupnu poveznicu s administratorskim odobrenjem.</string>
<string name="MessageRecord_you_turned_on_the_group_link_with_admin_approval_on">Uključili ste grupnu poveznicu s administratorskim odobrenjem.</string>
@ -723,7 +923,14 @@
<string name="MessageRecord_you_sent_a_request_to_join_the_group">Poslali ste zahtjev za pridruživanje grupi.</string>
<string name="MessageRecord_s_requested_to_join_via_the_group_link">%1$s je zatražio/la pridruživanje grupi putem grupne poveznice.</string>
<!--GV2 group link approvals-->
<string name="MessageRecord_s_approved_your_request_to_join_the_group">%1$s je prihvatio vaš zahtjev za pridruživanje ovoj grupi.</string>
<string name="MessageRecord_s_approved_a_request_to_join_the_group_from_s">%1$s je prihvatio/la zahtjev za pridruživanje u grupu od %2$s.</string>
<string name="MessageRecord_you_approved_a_request_to_join_the_group_from_s">Prihvatili ste zahtjev za pridruživanje u grupu od %1$s.</string>
<string name="MessageRecord_your_request_to_join_the_group_has_been_approved">Vaš zahtjev za pridruživanje u grupu je odobren.</string>
<string name="MessageRecord_a_request_to_join_the_group_from_s_has_been_approved">Odobren je zahtjev za pridruživanje u grupu od %1$s.</string>
<!--GV2 group link deny-->
<string name="MessageRecord_your_request_to_join_the_group_has_been_denied_by_an_admin">Vaš zahtjev za pridruživanje u grupu je odbijen od administratora.</string>
<string name="MessageRecord_s_denied_a_request_to_join_the_group_from_s">%1$s je odbio zahtjev za pridruživanje u grupu od %2$s.</string>
<string name="MessageRecord_a_request_to_join_the_group_from_s_has_been_denied">Odbili ste zahtjev za pridruživanje u grupu od %1$s.</string>
<string name="MessageRecord_you_canceled_your_request_to_join_the_group">Otkazali ste zahtjev za pridruživanje ovoj grupi.</string>
<string name="MessageRecord_s_canceled_their_request_to_join_the_group">%1$s je odbio njihov zahtjev za pridruživanje ovoj grupi.</string>
@ -789,6 +996,13 @@
<string name="ExpirationDialog_your_messages_will_not_expire">Vaše poruke neće nestati.</string>
<string name="ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen">Poslane i primljene poruke u ovom razgovoru će nestati za %s nakon što ih se vidi.</string>
<!--OutdatedBuildReminder-->
<string name="OutdatedBuildReminder_update_now">Ažuriraj sada</string>
<string name="OutdatedBuildReminder_your_version_of_signal_will_expire_today">Ova verzija Signala istječe danas. Ažurirajte na najnoviju verziju.</string>
<plurals name="OutdatedBuildReminder_your_version_of_signal_will_expire_in_n_days">
<item quantity="one">Ova verzija Signala istječe sutra. Ažurirajte na najnoviju verziju.</item>
<item quantity="few">Ova verzija Signala istječe za %d dana. Ažurirajte na najnoviju verziju.</item>
<item quantity="other">Ova verzija Signala istječe za %d dana. Ažurirajte na najnoviju verziju.</item>
</plurals>
<!--PassphrasePromptActivity-->
<string name="PassphrasePromptActivity_enter_passphrase">Unesite lozinku</string>
<string name="PassphrasePromptActivity_watermark_content_description">Signal ikona</string>
@ -867,10 +1081,20 @@
<string name="WebRtcCallActivity__to_call_s_signal_needs_access_to_your_camera">Da biste uputili poziv za %1$s, omogućite Signalu pristup vašoj kameri</string>
<string name="WebRtcCallActivity__signal_s">Signal %1$s</string>
<string name="WebRtcCallActivity__calling">Uspostava poziva</string>
<string name="WebRtcCallActivity__group_call">Grupni poziv</string>
<!--WebRtcCallView-->
<string name="WebRtcCallView__signal_voice_call">Signal glasovni poziv</string>
<string name="WebRtcCallView__signal_video_call">Signal video poziv…</string>
<string name="WebRtcCallView__start_call">Započni poziv</string>
<string name="WebRtcCallView__group_call">Grupni poziv</string>
<string name="WebRtcCallView__view_participants_list">Prikaži sudionike</string>
<string name="WebRtcCallView__your_video_is_off">Vaš video je isključen</string>
<!--CallParticipantsListDialog-->
<plurals name="CallParticipantsListDialog_in_this_call_d_people">
<item quantity="one">U ovom pozivu · %1$d osoba</item>
<item quantity="few">U ovom pozivu · %1$d osobe</item>
<item quantity="other">U ovom pozivu · %1$d osoba</item>
</plurals>
<!--RegistrationActivity-->
<string name="RegistrationActivity_select_your_country">Odaberite svoju državu</string>
<string name="RegistrationActivity_you_must_specify_your_country_code">Unesite pozivni broj
@ -1453,7 +1677,7 @@ Uvezi sigurnosnu kopiju običnog teksta. Kompatibilno s \'SMSBackup And Restore\
<string name="unknown_sender_view__add_to_contacts">DODAJ U KONTAKTE</string>
<string name="unknown_sender_view__don_t_add_but_make_my_profile_visible">NE DODAJ, ALI MI PROFIL UČINI VIDLJIVIM</string>
<!--verify_display_fragment-->
<string name="verify_display_fragment__if_you_wish_to_verify_the_security_of_your_end_to_end_encryption_with_s"><![CDATA[Ukoliko želite provjeriti sigurnost vašeg šifriranja s%s, usporedite brojeve koje vidite iznad s brojevima na drugom uređaju. Također možete skenirati kôd s drugog telefona ili zamoliti da skeniraju kôd s vašeg telefona. <a href="https://signal.org/redirect/safety-numbers">Saznajte više.</a>]]></string>
<string name="verify_display_fragment__if_you_wish_to_verify_the_security_of_your_end_to_end_encryption_with_s"><![CDATA[Ukoliko želite provjeriti sigurnost vašeg šifriranja s %s, usporedite brojeve koje vidite iznad s brojevima na drugom uređaju. Također možete skenirati kôd s drugog telefona ili zamoliti da skeniraju kôd s vašeg telefona. <a href="https://signal.org/redirect/safety-numbers">Saznajte više.</a>]]></string>
<string name="verify_display_fragment__tap_to_scan">Pritisni za skeniranje</string>
<string name="verify_display_fragment__loading">Učitavanje…</string>
<string name="verify_display_fragment__verified">Provjereno</string>
@ -1757,7 +1981,7 @@ Uvezi sigurnosnu kopiju običnog teksta. Kompatibilno s \'SMSBackup And Restore\
<!--conversation_list_fragment-->
<string name="conversation_list_fragment__fab_content_description">Novi razgovor</string>
<string name="conversation_list_fragment__open_camera_description">Otvori kameru</string>
<string name="conversation_list_fragment__give_your_inbox_something_to_write_home_about_get_started_by_messaging_a_friend">Stvorite nove uspomene. Započnite slanjem poruke prijatelju.</string>
<string name="conversation_list_fragment__give_your_inbox_something_to_write_home_about_get_started_by_messaging_a_friend">Stvorite nove uspomene. Započnite slanjem poruke prijateljima.</string>
<!--conversation_secure_verified-->
<string name="conversation_secure_verified__menu_reset_secure_session">Poništi sigurnu sesiju</string>
<!--conversation_muted-->
@ -1943,6 +2167,7 @@ Uvezi sigurnosnu kopiju običnog teksta. Kompatibilno s \'SMSBackup And Restore\
<string name="KbsMegaphone__well_remind_you_later_confirming_your_pin">Podsjetiti ćemo vas kasnije. Potvrđivanje PIN-a postati će obvezno za %1$d dana.</string>
<!--Research Megaphone-->
<string name="ResearchMegaphone_tell_signal_what_you_think">Recite što mislite o Signalu</string>
<string name="ResearchMegaphone_to_make_signal_the_best_messaging_app_on_the_planet">Kako bi Signal bio najbolja aplikacija za razmjenu poruka na planetu, voljeli bismo čuti vaše povratne informacije.</string>
<string name="ResearchMegaphone_learn_more">Saznaj više</string>
<string name="ResearchMegaphone_dismiss">Odbaci</string>
<string name="ResearchMegaphoneDialog_signal_research">Signal istraživanja</string>
@ -1973,7 +2198,7 @@ Uvezi sigurnosnu kopiju običnog teksta. Kompatibilno s \'SMSBackup And Restore\
<string name="preferences_chats__backup_chats_to_external_storage">Spremi sigurnosnu kopiju razgovora u vanjskoj pohranu</string>
<string name="preferences_chats__create_backup">Stvori sigurnosnu kopiju</string>
<string name="preferences_chats__verify_backup_passphrase">Provjerite lozinku sigurnosne kopije</string>
<string name="preferences_chats__test_your_backup_passphrase_and_verify_that_it_matches">Testirajte vašu lozinku sigurnosne kopije i provjerite poklapa li se</string>
<string name="preferences_chats__test_your_backup_passphrase_and_verify_that_it_matches">Testirajte vašu lozinku sigurnosne kopije i provjerite je li točna</string>
<string name="RegistrationActivity_enter_backup_passphrase">Unesi lozinku sigurnosne kopije</string>
<string name="RegistrationActivity_restore">Vrati</string>
<string name="RegistrationActivity_backup_failure_downgrade">Ne mogu se uvesti sigurnosne kopije iz novijih verzija Signala</string>
@ -2090,6 +2315,7 @@ Uvezi sigurnosnu kopiju običnog teksta. Kompatibilno s \'SMSBackup And Restore\
<string name="RecipientBottomSheet_video_call_description">Video poziv</string>
<string name="RecipientBottomSheet_remove_s_as_group_admin">Ukloni %1$skao administratora grupe?</string>
<string name="RecipientBottomSheet_s_will_be_able_to_edit_group">\"%1$s\" će moći uređivati ovu grupu i njene članove</string>
<string name="RecipientBottomSheet_remove_s_from_the_group">Ukloniti %1$s iz grupe?</string>
<string name="RecipientBottomSheet_remove">Ukloni</string>
<string name="RecipientBottomSheet_copied_to_clipboard">Kopirano u međuspremnik</string>
<string name="GroupRecipientListItem_admin">Administrator</string>
@ -2100,6 +2326,8 @@ Uvezi sigurnosnu kopiju običnog teksta. Kompatibilno s \'SMSBackup And Restore\
<string name="GroupsLearnMore_what_are_legacy_groups">Što su Naslijeđene grupe?</string>
<string name="GroupsLearnMore_paragraph_1">Naslijeđene grupe su grupe koje nisu kompatibilne s značajkama Nove grupe kao što su administratori i opisnija ažuriranja grupa.</string>
<string name="GroupsLearnMore_can_i_upgrade_a_legacy_group">Mogu li \"nadograditi\" Naslijeđenu grupu?</string>
<string name="GroupsLearnMore_paragraph_2">Naslijeđene grupe se još ne mogu nadograditi u Nove grupe, ali možete stvoriti Novu grupu s istim članovima ako su na najnovijoj verziji Signala.</string>
<string name="GroupsLearnMore_paragraph_3">Signal će u budućnosti ponuditi način za nadogradnju Naslijeđenih grupa.</string>
<!--GroupLinkBottomSheetDialogFragment-->
<string name="GroupLinkBottomSheet_share_via_signal">Podijeli putem Signala</string>
<string name="GroupLinkBottomSheet_copy">Kopiraj</string>

View File

@ -287,7 +287,7 @@
<string name="ConversationFragment_deleting_messages">Üzenetek törlése…</string>
<string name="ConversationFragment_delete_for_me">Törlés számomra</string>
<string name="ConversationFragment_delete_for_everyone">Törlés mindenki számára</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Ez az üzenet a beszélgetés minden tagja számára véglegesen törlésre kerül. A tagok számára látható lesz, hogy törölted az üzenetet.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Ez az üzenet a beszélgetés minden tagja számára véglegesen törlésre kerül. A tagok számára látható lesz, hogy törölted az üzenetet.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Az eredeti üzenet nem található</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Az eredeti üzenet már nem érhető el</string>
<string name="ConversationFragment_failed_to_open_message">Nem sikerült megnyitni az üzenetet</string>

View File

@ -286,7 +286,7 @@
<string name="ConversationFragment_deleting_messages">Eliminazione messaggi…</string>
<string name="ConversationFragment_delete_for_me">Elimina per me</string>
<string name="ConversationFragment_delete_for_everyone">Elimina per tutti</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Questo messaggio verrà eliminato definitivamente per tutti nella conversazione. I membri potranno vedere che hai eliminato un messaggio.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Questo messaggio verrà eliminato definitivamente per tutti nella conversazione. I membri potranno vedere che hai eliminato un messaggio.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Messaggio originale non trovato</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Messaggio originale non più disponibile</string>
<string name="ConversationFragment_failed_to_open_message">Apertura messaggio fallita</string>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -239,7 +239,7 @@
<string name="ConversationFragment_sms">SMS</string>
<string name="ConversationFragment_deleting">Taksih mbrusaki</string>
<string name="ConversationFragment_deleting_messages">Taksih mbrusaki pesen…</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Pesen niki bakal dibrusak kangge piyambak sedaya ing obrolan niki sacara permanen Anggota sedaya bakal saged ningali menawi sampeyan mbrusaki pesen.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Pesen niki bakal dibrusak kangge piyambak sedaya ing obrolan niki sacara permanen Anggota sedaya bakal saged ningali menawi sampeyan mbrusaki pesen.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Pesen asli mboten saged kepanggih</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Pesen asli sampun mboten enten malih</string>
<string name="ConversationFragment_failed_to_open_message">Gagal mbikak pesen</string>

View File

@ -269,7 +269,7 @@
<string name="ConversationFragment_deleting_messages">កំពុងលុបសារ…</string>
<string name="ConversationFragment_delete_for_me">លុបសម្រាប់ខ្ញុំ</string>
<string name="ConversationFragment_delete_for_everyone">លុបសម្រាប់ទាំងអស់គ្នា</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">សារនេះ នឹងត្រូវលុបចោលរហូតសម្រាប់អ្នកទាំងអស់គ្នាក្នុងការសន្ទនានេះ។ សមាជិក នឹងអាចមើលឃើញ អ្នកបានលុបសារនេះ។</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">សារនេះ នឹងត្រូវលុបចោលរហូតសម្រាប់អ្នកទាំងអស់គ្នាក្នុងការសន្ទនានេះ។ សមាជិក នឹងអាចមើលឃើញ អ្នកបានលុបសារនេះ។</string> -->
<string name="ConversationFragment_quoted_message_not_found">រកមិនឃើញសារដើម</string>
<string name="ConversationFragment_quoted_message_no_longer_available">សារដើមលែងមានទៀតហើយ</string>
<string name="ConversationFragment_failed_to_open_message">បរាជ័យក្នុងការបើកសារ</string>

View File

@ -267,7 +267,7 @@
<string name="ConversationFragment_deleting_messages">메시지 삭제 중…</string>
<string name="ConversationFragment_delete_for_me">나에게서 삭제</string>
<string name="ConversationFragment_delete_for_everyone">모두에게서 삭제</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">이 메시지가 대화에 참가한 모든 이들에게서 삭제됩니다. 멤버는 내가 메시지를 삭제했다는 것을 볼 수 있습니다.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">이 메시지가 대화에 참가한 모든 이들에게서 삭제됩니다. 멤버는 내가 메시지를 삭제했다는 것을 볼 수 있습니다.</string> -->
<string name="ConversationFragment_quoted_message_not_found">원본 메시지를 찾을 수 없음</string>
<string name="ConversationFragment_quoted_message_no_longer_available">원본 메시지를 더 이상 볼 수 없음</string>
<string name="ConversationFragment_failed_to_open_message">메시지 열기 실패</string>

File diff suppressed because one or more lines are too long

View File

@ -280,7 +280,7 @@
<string name="ConversationFragment_deleting_messages">Tiek dzēstas ziņas…</string>
<string name="ConversationFragment_delete_for_me">Dzēst tikai man</string>
<string name="ConversationFragment_delete_for_everyone">Dzēst no visiem</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Šī ziņa tiks dzēsta no visiem dalībniekiem. Dalībnieki var redzēt, ka jūs izdzēsāt ziņu.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Šī ziņa tiks dzēsta no visiem dalībniekiem. Dalībnieki var redzēt, ka jūs izdzēsāt ziņu.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Oriģinālā ziņa netika atrasta</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Oriģinālā ziņa vairs nav pieejama</string>
<string name="ConversationFragment_failed_to_open_message">Neizdevās atvērt ziņu</string>

File diff suppressed because one or more lines are too long

View File

@ -280,7 +280,7 @@
<string name="ConversationFragment_deleting_messages">संदेश हटवत आहे…</string>
<string name="ConversationFragment_delete_for_me">माझ्यासाठी हटवा</string>
<string name="ConversationFragment_delete_for_everyone">प्रत्येकासाठी हटवा</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">हा संदेश संभाषणातील प्रत्येकासाठी कायमचा हटविला जाईल. आपण संदेश हटविला हे सदस्य पाहण्यास सक्षम असतील.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">हा संदेश संभाषणातील प्रत्येकासाठी कायमचा हटविला जाईल. आपण संदेश हटविला हे सदस्य पाहण्यास सक्षम असतील.</string> -->
<string name="ConversationFragment_quoted_message_not_found">मूळ संदेश आढळला नाही</string>
<string name="ConversationFragment_quoted_message_no_longer_available">मूळ संदेश आता उपलब्ध नाही</string>
<string name="ConversationFragment_failed_to_open_message">संदेश उघडण्यात अयशस्वी</string>

View File

@ -280,7 +280,7 @@
<string name="ConversationFragment_deleting_messages">Sletter meldinger…</string>
<string name="ConversationFragment_delete_for_me">Slett for meg</string>
<string name="ConversationFragment_delete_for_everyone">Slett for alle</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Denne meldingen vil bli slettet permanent for alle i samtalen. Medlemmer vil se at du slettet en melding.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Denne meldingen vil bli slettet permanent for alle i samtalen. Medlemmer vil se at du slettet en melding.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Opprinnelig melding ikke funnet</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Opprinnelig melding er ikke lenger tilgjengelig</string>
<string name="ConversationFragment_failed_to_open_message">Kunne ikke åpne meldingen</string>

View File

@ -286,7 +286,7 @@
<string name="ConversationFragment_deleting_messages">Berichten aan het wissen …</string>
<string name="ConversationFragment_delete_for_me">Voor mij wissen</string>
<string name="ConversationFragment_delete_for_everyone">Wissen voor iedereen</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Dit bericht zal worden verwijderd voor alle deelnemers aan het gesprek. Gespreksdeelnemers zullen wel kunnen zien dat jij een bericht hebt verwijderd.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Dit bericht zal worden verwijderd voor alle deelnemers aan het gesprek. Gespreksdeelnemers zullen wel kunnen zien dat jij een bericht hebt verwijderd.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Oorspronkelijk bericht niet gevonden</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Het oorspronkelijke bericht is niet langer beschikbaar</string>
<string name="ConversationFragment_failed_to_open_message">Openen van bericht is mislukt</string>
@ -909,7 +909,7 @@
<string name="MessageRequestBottomView_unblock">Deblokkeren</string>
<string name="MessageRequestBottomView_do_you_want_to_let_s_message_you_they_wont_know_youve_seen_their_messages_until_you_accept">Wil je berichten van %1$s ontvangen, en sta je toe dat hij of zij je profielnaam en -foto kan zien? Hij of zij zal totdat je de gespreksuitnodiging aanvaardt niet kunnen zien dat je zijn of haar berichten hebt gelezen.</string>
<string name="MessageRequestBottomView_do_you_want_to_let_s_message_you_wont_receive_any_messages_until_you_unblock_them">Wil je berichten van %1$s ontvangen, en sta je toe dat hij of zij je profielnaam en -foto kan zien? Je zult geen berichten ontvangen en je profiel zal voor hem of haar niet meer worden bijgewerkt totdat je hem of haar hebt gedeblokkeerd.</string>
<string name="MessageRequestBottomView_do_you_want_to_join_this_group_they_wont_know_youve_seen_their_messages_until_you_accept">Wil je lid worden van deze groep, en sta je toe dat alle leden van de groep je profielnaam en -foto kunnen zien? De leden van deze groep zullen niet kunnen zien dat je hun berichten hebt gelezen totdat je de uitnodiging hebt aanvaard.</string>
<string name="MessageRequestBottomView_do_you_want_to_join_this_group_they_wont_know_youve_seen_their_messages_until_you_accept">Wil je lid worden van deze groep, en sta je toe dat alle leden van de groep je profielnaam en -foto kunnen zien?</string>
<string name="MessageRequestBottomView_unblock_this_group_and_share_your_name_and_photo_with_its_members">Wil je weer berichten van deze groep ontvangen, en sta je toe dat alle leden van de groep je profielnaam en -foto kunnen zien? Je zult geen berichten ontvangen en je profiel zal voor de leden van deze groep niet worden bijgewerkt totdat je de groep hebt gedeblokkeerd.</string>
<string name="MessageRequestProfileView_member_of_one_group">Lid van %1$s</string>
<string name="MessageRequestProfileView_member_of_two_groups">Lid van %1$s en %2$s</string>

View File

@ -286,7 +286,7 @@
<string name="ConversationFragment_deleting_messages">Slettar meldingar …</string>
<string name="ConversationFragment_delete_for_me">Slett for meg</string>
<string name="ConversationFragment_delete_for_everyone">Slett for alle</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Denne meldinga blir permanent sletta for alle i samtalen. Medlem vil sjå at du sletta ei melding.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Denne meldinga blir permanent sletta for alle i samtalen. Medlem vil sjå at du sletta ei melding.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Fann ikkje den opphavlege meldinga</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Den opphavlege meldinga er ikkje lenger tilgjengeleg</string>
<string name="ConversationFragment_failed_to_open_message">Klarte ikkje opna meldinga</string>

View File

@ -274,7 +274,7 @@
<string name="ConversationFragment_deleting_messages">ਸੁਨੇਹਿਆਂ ਨੂੰ ਮਿਟਾਇਆ ਜਾ ਰਿਹਾ ਹੈ</string>
<string name="ConversationFragment_delete_for_me">ਮੇਰੇ ਲਈ ਮਿਟਾਓ</string>
<string name="ConversationFragment_delete_for_everyone">ਹਰ ਕਿਸੇ ਲਈ ਮਿਟਾਓ</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">ਇਹ ਸੰਦੇਸ਼ ਗੱਲਬਾਤ ਵਿਚਲੇ ਹਰੇਕ ਲਈ ਸਥਾਈ ਤੌਰ ਤੇ ਮਿਟਾ ਦਿੱਤਾ ਜਾਏਗਾ. ਮੈਂਬਰ ਇਹ ਵੇਖਣ ਦੇ ਯੋਗ ਹੋਣਗੇ ਕਿ ਤੁਸੀਂ ਇੱਕ ਸੁਨੇਹਾ ਮਿਟਾ ਦਿੱਤਾ ਹੈ.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">ਇਹ ਸੰਦੇਸ਼ ਗੱਲਬਾਤ ਵਿਚਲੇ ਹਰੇਕ ਲਈ ਸਥਾਈ ਤੌਰ ਤੇ ਮਿਟਾ ਦਿੱਤਾ ਜਾਏਗਾ. ਮੈਂਬਰ ਇਹ ਵੇਖਣ ਦੇ ਯੋਗ ਹੋਣਗੇ ਕਿ ਤੁਸੀਂ ਇੱਕ ਸੁਨੇਹਾ ਮਿਟਾ ਦਿੱਤਾ ਹੈ.</string> -->
<string name="ConversationFragment_quoted_message_not_found">ਅਸਲੀ ਸੁਨੇਹਾ ਨਹੀਂ ਲੱਭਿਆ </string>
<string name="ConversationFragment_quoted_message_no_longer_available">ਮੂਲ ਸੁਨੇਹਾ ਹੁਣ ਉਪਲਬਧ ਨਹੀਂ ਹੈ</string>
<string name="ConversationFragment_failed_to_open_message">ਸੁਨੇਹਾ ਖੋਲ੍ਹਣ ਵਿੱਚ ਅਸਫਲ</string>

View File

@ -306,7 +306,7 @@
<string name="ConversationFragment_deleting_messages">Usuwanie wiadomości…</string>
<string name="ConversationFragment_delete_for_me">Usuń u mnie</string>
<string name="ConversationFragment_delete_for_everyone">Usuń u wszystkich</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Ta wiadomość zostanie trwale usunięta u wszystkich, uczestników tej konwersacji. Będą oni mogli zobaczyć, że skasowałeś(aś) wiadomość.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Ta wiadomość zostanie trwale usunięta u wszystkich, uczestników tej konwersacji. Będą oni mogli zobaczyć, że skasowałeś(aś) wiadomość.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Nie znaleziono oryginalnej wiadomości</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Oryginalna wiadomość nie jest już dostępna</string>
<string name="ConversationFragment_failed_to_open_message">Nie udało się otworzyć wiadomości</string>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -306,7 +306,7 @@
<string name="ConversationFragment_deleting_messages">Удаляем сообщения…</string>
<string name="ConversationFragment_delete_for_me">Удалить для меня</string>
<string name="ConversationFragment_delete_for_everyone">Удалить для всех</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Это сообщение будет навсегда удалено для всех в этом разговоре. Участники смогут увидеть, что вы удалили сообщение.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Это сообщение будет навсегда удалено для всех в этом разговоре. Участники смогут увидеть, что вы удалили сообщение.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Исходное сообщение не найдено</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Исходное сообщение больше не доступно</string>
<string name="ConversationFragment_failed_to_open_message">Не удалось открыть сообщение</string>

View File

@ -295,7 +295,7 @@
<string name="ConversationFragment_deleting_messages">Mažú sa správy…</string>
<string name="ConversationFragment_delete_for_me">Zmazať pre mňa</string>
<string name="ConversationFragment_delete_for_everyone">Zmazať pre všetkých</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Táto správa sa natrvalo vymaže pre všetkých v tejto konverzácii. Členovia budú vidieť, že ste vymazali správu.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Táto správa sa natrvalo vymaže pre všetkých v tejto konverzácii. Členovia budú vidieť, že ste vymazali správu.</string> -->
<string name="ConversationFragment_quoted_message_not_found">Pôvodná správa sa nenašla</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Pôvodná správa už nie je k dispozícii</string>
<string name="ConversationFragment_failed_to_open_message">Nepodarilo sa otvoriť správu</string>

File diff suppressed because one or more lines are too long

View File

@ -286,7 +286,7 @@
<string name="ConversationFragment_deleting_messages">Po fshihen mesazhet…</string>
<string name="ConversationFragment_delete_for_me">Fshije për mua</string>
<string name="ConversationFragment_delete_for_everyone">Fshije për këdo</string>
<string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Ky mesazh do të fshihet përgjithmonë për këdo te biseda. Anëtarët do të jenë në gjendje të shohin që ju fshitë një mesazh.</string>
<!-- Removed by excludeNonTranslatables <string name="ConversationFragment_this_message_will_be_permanently_deleted_for_everyone">Ky mesazh do të fshihet përgjithmonë për këdo te biseda. Anëtarët do të jenë në gjendje të shohin që ju fshitë një mesazh.</string> -->
<string name="ConversationFragment_quoted_message_not_found">S\u gjet mesazhi origjinal</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Mesazhi origjinal s\gjendet më</string>
<string name="ConversationFragment_failed_to_open_message">Su arrit të hapet mesazhe</string>

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