From 8348badcd697cd837e78d1e5f800c65c6d94da1f Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Wed, 2 Feb 2022 10:45:04 -0500 Subject: [PATCH] Periodically fetch release notes. --- .../securesms/ApplicationContext.java | 2 + .../securesms/backup/FullBackupExporter.java | 23 +- .../conversation/ConversationFragment.java | 6 +- .../ConversationParentFragment.java | 4 +- .../ConversationReactionOverlay.java | 9 +- .../securesms/conversation/MenuState.java | 32 ++- .../securesms/database/MmsDatabase.java | 12 +- .../securesms/database/RecipientDatabase.kt | 16 ++ .../securesms/database/SmsDatabase.java | 12 +- .../securesms/database/ThreadDatabase.java | 8 +- .../securesms/jobs/CreateReleaseChannelJob.kt | 109 ++++++++ .../securesms/jobs/JobManagerFactories.java | 2 + .../jobs/RetrieveReleaseChannelJob.kt | 263 ++++++++++++++++++ .../keyvalue/ReleaseChannelValues.kt | 37 +++ .../securesms/keyvalue/SignalStore.java | 8 + .../securesms/recipients/LiveRecipient.java | 4 +- .../securesms/recipients/Recipient.java | 2 +- .../recipients/RecipientDetails.java | 15 +- .../java/org/thoughtcrime/securesms/s3/S3.kt | 215 ++++++++++++++ .../securesms/util/EncryptedStreamUtils.kt | 27 ++ .../securesms/util/JsonUtils.java | 1 + .../securesms/util/LocaleFeatureFlags.java | 4 + .../securesms/util/VersionTracker.java | 2 + .../database/RecipientDatabaseTestUtils.kt | 6 +- .../internal/websocket/ErrorMapper.java | 4 + 25 files changed, 789 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/CreateReleaseChannelJob.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveReleaseChannelJob.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/keyvalue/ReleaseChannelValues.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/s3/S3.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/EncryptedStreamUtils.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index fc6af695aa..5e3d9aaee1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -36,6 +36,7 @@ import org.signal.core.util.logging.Log; import org.signal.core.util.tracing.Tracer; import org.signal.glide.SignalGlideCodecs; import org.thoughtcrime.securesms.emoji.JumboEmoji; +import org.thoughtcrime.securesms.jobs.RetrieveReleaseChannelJob; import org.thoughtcrime.securesms.mms.SignalGlideModule; import org.signal.ringrtc.CallManager; import org.thoughtcrime.securesms.avatar.AvatarPickerStorage; @@ -194,6 +195,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr .addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary) .addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge())) .addPostRender(() -> JumboEmoji.updateCurrentVersion(this)) + .addPostRender(RetrieveReleaseChannelJob::enqueue) .execute(); Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java index 3771776c8f..a5408aae6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java @@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.CursorUtil; import org.thoughtcrime.securesms.util.SetUtil; import org.thoughtcrime.securesms.util.Stopwatch; @@ -162,17 +163,17 @@ public class FullBackupExporter extends FullBackupBase { for (String table : tables) { throwIfCanceled(cancellationSignal); if (table.equals(MmsDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringMmsMessage, null, count, estimatedCount, cancellationSignal); + count = exportTable(table, input, outputStream, cursor -> isNonExpiringMmsMessage(cursor) && isNotReleaseChannel(cursor), null, count, estimatedCount, cancellationSignal); } else if (table.equals(SmsDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringSmsMessage, null, count, estimatedCount, cancellationSignal); + count = exportTable(table, input, outputStream, cursor -> isNonExpiringSmsMessage(cursor) && isNotReleaseChannel(cursor), null, count, estimatedCount, cancellationSignal); } else if (table.equals(ReactionDatabase.TABLE_NAME)) { count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, new MessageId(CursorUtil.requireLong(cursor, ReactionDatabase.MESSAGE_ID), CursorUtil.requireBoolean(cursor, ReactionDatabase.IS_MMS))), null, count, estimatedCount, cancellationSignal); } else if (table.equals(MentionDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessage(input, CursorUtil.requireLong(cursor, MentionDatabase.MESSAGE_ID)), null, count, estimatedCount, cancellationSignal); + count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessageAndNotReleaseChannel(input, CursorUtil.requireLong(cursor, MentionDatabase.MESSAGE_ID)), null, count, estimatedCount, cancellationSignal); } else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count, estimatedCount, cancellationSignal); + count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessageAndNotReleaseChannel(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count, estimatedCount, cancellationSignal); } else if (table.equals(AttachmentDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal); + count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessageAndNotReleaseChannel(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal); } else if (table.equals(StickerDatabase.TABLE_NAME)) { count = exportTable(table, input, outputStream, cursor -> true, (cursor, innerCount) -> exportSticker(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal); } else if (!BLACKLISTED_TABLES.contains(table) && !table.startsWith("sqlite_")) { @@ -480,7 +481,7 @@ public class FullBackupExporter extends FullBackupBase { private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, @NonNull MessageId messageId) { if (messageId.isMms()) { - return isForNonExpiringMmsMessage(db, messageId.getId()); + return isForNonExpiringMmsMessageAndNotReleaseChannel(db, messageId.getId()); } else { return isForNonExpiringSmsMessage(db, messageId.getId()); } @@ -500,20 +501,24 @@ public class FullBackupExporter extends FullBackupBase { return false; } - private static boolean isForNonExpiringMmsMessage(@NonNull SQLiteDatabase db, long mmsId) { - String[] columns = new String[] { MmsDatabase.EXPIRES_IN, MmsDatabase.VIEW_ONCE}; + private static boolean isForNonExpiringMmsMessageAndNotReleaseChannel(@NonNull SQLiteDatabase db, long mmsId) { + String[] columns = new String[] { MmsDatabase.RECIPIENT_ID, MmsDatabase.EXPIRES_IN, MmsDatabase.VIEW_ONCE}; String where = MmsDatabase.ID + " = ?"; String[] args = new String[] { String.valueOf(mmsId) }; try (Cursor mmsCursor = db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) { if (mmsCursor != null && mmsCursor.moveToFirst()) { - return isNonExpiringMmsMessage(mmsCursor); + return isNonExpiringMmsMessage(mmsCursor) && isNotReleaseChannel(mmsCursor); } } return false; } + private static boolean isNotReleaseChannel(Cursor cursor) { + RecipientId releaseChannel = SignalStore.releaseChannelValues().getReleaseChannelRecipientId(); + return releaseChannel == null || cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.RECIPIENT_ID)) != releaseChannel.toLong(); + } private static class BackupFrameOutputStream extends BackupStream { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index b65a13d069..cae208656f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -1828,7 +1828,11 @@ public class ConversationFragment extends LoggingFragment implements Multiselect @Override public void onDonateClicked() { - + if (SignalStore.donationsValues().isLikelyASustainer()) { + startActivity(AppSettingsActivity.boost(requireContext())); + } else { + startActivity(AppSettingsActivity.subscriptions(requireContext())); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index 3e0bf28dd2..cc4163faed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -1545,7 +1545,7 @@ public class ConversationParentFragment extends Fragment sendButton.resetAvailableTransports(isMediaMessage); - if (!isSecureText && !isPushGroupConversation() && !recipient.get().isAciOnly()) { + if (!isSecureText && !isPushGroupConversation() && !recipient.get().isAciOnly() && !recipient.get().isReleaseNotes()) { sendButton.disableTransport(Type.TEXTSECURE); } @@ -1556,7 +1556,7 @@ public class ConversationParentFragment extends Fragment if (!recipient.get().isPushGroup() && recipient.get().isForceSmsSelection()) { sendButton.setDefaultTransport(Type.SMS); } else { - if (isSecureText || isPushGroupConversation() || recipient.get().isAciOnly()) { + if (isSecureText || isPushGroupConversation() || recipient.get().isAciOnly() || recipient.get().isReleaseNotes()) { sendButton.setDefaultTransport(Type.TEXTSECURE); } else { sendButton.setDefaultTransport(Type.SMS); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java index bc2dd26f15..95db5e5ff7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java @@ -715,7 +715,14 @@ public final class ConversationReactionOverlay extends RelativeLayout { } items.add(new ActionItem(R.drawable.ic_select_24_tinted, getResources().getString(R.string.conversation_selection__menu_multi_select), () -> handleActionItemClicked(Action.MULTISELECT))); - items.add(new ActionItem(R.drawable.ic_info_tinted_24, getResources().getString(R.string.conversation_selection__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO))); + + if (menuState.shouldShowInfoAction()) { + items.add(new ActionItem(R.drawable.ic_info_tinted_24, getResources().getString(R.string.conversation_selection__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO))); + } + + backgroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE); + foregroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE); + items.add(new ActionItem(R.drawable.ic_delete_tinted_24, getResources().getString(R.string.conversation_selection__menu_delete), () -> handleActionItemClicked(Action.DELETE))); return items; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java index f50e1d568c..a9f756b6a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java @@ -22,6 +22,8 @@ final class MenuState { private final boolean resend; private final boolean copy; private final boolean delete; + private final boolean info; + private final boolean reactions; private MenuState(@NonNull Builder builder) { forward = builder.forward; @@ -31,6 +33,8 @@ final class MenuState { resend = builder.resend; copy = builder.copy; delete = builder.delete; + info = builder.info; + reactions = builder.reactions; } boolean shouldShowForwardAction() { @@ -61,6 +65,14 @@ final class MenuState { return delete; } + boolean shouldShowInfoAction() { + return info; + } + + boolean shouldShowReactions() { + return reactions; + } + static MenuState getMenuState(@NonNull Recipient conversationRecipient, @NonNull Set selectedParts, boolean shouldShowMessageRequest, @@ -148,6 +160,8 @@ final class MenuState { return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText) .shouldShowDeleteAction(!hasInMemory && onlyContainsCompleteMessages(selectedParts)) + .shouldShowInfoAction(!conversationRecipient.isReleaseNotes()) + .shouldShowReactions(!conversationRecipient.isReleaseNotes()) .build(); } @@ -172,7 +186,8 @@ final class MenuState { !isDisplayingMessageRequest && messageRecord.isSecure() && (!conversationRecipient.isGroup() || conversationRecipient.isActiveGroup()) && - !messageRecord.getRecipient().isBlocked(); + !messageRecord.getRecipient().isBlocked() && + !conversationRecipient.isReleaseNotes(); } static boolean isActionMessage(@NonNull MessageRecord messageRecord) { @@ -188,7 +203,8 @@ final class MenuState { messageRecord.isGroupV1MigrationEvent() || messageRecord.isChatSessionRefresh() || messageRecord.isInMemoryMessageRecord() || - messageRecord.isChangeNumber(); + messageRecord.isChangeNumber() || + messageRecord.isBoostRequest(); } private final static class Builder { @@ -200,6 +216,8 @@ final class MenuState { private boolean resend; private boolean copy; private boolean delete; + private boolean info; + private boolean reactions; @NonNull Builder shouldShowForwardAction(boolean forward) { this.forward = forward; @@ -236,6 +254,16 @@ final class MenuState { return this; } + @NonNull Builder shouldShowInfoAction(boolean info) { + this.info = info; + return this; + } + + @NonNull Builder shouldShowReactions(boolean reactions) { + this.reactions = reactions; + return this; + } + @NonNull MenuState build() { return new MenuState(this); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index 341a7b52bd..757d1709cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -56,6 +56,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; import org.thoughtcrime.securesms.jobs.TrimThreadJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.mms.IncomingMediaMessage; import org.thoughtcrime.securesms.mms.MessageGroupContext; @@ -970,9 +971,10 @@ public class MmsDatabase extends MessageDatabase { } private List setMessagesRead(String where, String[] arguments) { - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - List result = new LinkedList<>(); - Cursor cursor = null; + SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); + List result = new LinkedList<>(); + Cursor cursor = null; + RecipientId releaseChannelId = SignalStore.releaseChannelValues().getReleaseChannelRecipientId(); database.beginTransaction(); @@ -990,7 +992,9 @@ public class MmsDatabase extends MessageDatabase { SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent); ExpirationInfo expirationInfo = new ExpirationInfo(messageId, expiresIn, expireStarted, true); - result.add(new MarkedMessageInfo(threadId, syncMessageId, new MessageId(messageId, true), expirationInfo)); + if (!recipientId.equals(releaseChannelId)) { + result.add(new MarkedMessageInfo(threadId, syncMessageId, new MessageId(messageId, true), expirationInfo)); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt index bef5c5dc49..f3e5c68b4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt @@ -664,6 +664,22 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : } } + /** + * Only call once to create initial release channel recipient. + */ + fun insertReleaseChannelRecipient(): RecipientId { + val values = ContentValues().apply { + put(AVATAR_COLOR, AvatarColor.random().serialize()) + } + + val id = writableDatabase.insert(TABLE_NAME, null, values) + if (id < 0) { + throw AssertionError("Failed to insert recipient!") + } else { + return GetOrInsertResult(RecipientId.from(id), true).recipientId + } + } + fun getBlocked(): Cursor { return readableDatabase.query(TABLE_NAME, ID_PROJECTION, "$BLOCKED = 1", null, null, null, null) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 8d1d9b5b93..029196c0cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDet import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; import org.thoughtcrime.securesms.jobs.TrimThreadJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.mms.IncomingMediaMessage; import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; @@ -591,9 +592,10 @@ public class SmsDatabase extends MessageDatabase { } private List setMessagesRead(String where, String[] arguments) { - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - List results = new LinkedList<>(); - Cursor cursor = null; + SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); + List results = new LinkedList<>(); + Cursor cursor = null; + RecipientId releaseChannelId = SignalStore.releaseChannelValues().getReleaseChannelRecipientId(); database.beginTransaction(); try { @@ -610,7 +612,9 @@ public class SmsDatabase extends MessageDatabase { SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent); ExpirationInfo expirationInfo = new ExpirationInfo(messageId, expiresIn, expireStarted, false); - results.add(new MarkedMessageInfo(threadId, syncMessageId, new MessageId(messageId, false), expirationInfo)); + if (!recipientId.equals(releaseChannelId)) { + results.add(new MarkedMessageInfo(threadId, syncMessageId, new MessageId(messageId, false), expirationInfo)); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index d2084f6e99..0584cec8ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.database.model.RecipientRecord; import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.groups.BadGroupIdException; import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.mms.StickerSlide; @@ -582,6 +583,10 @@ public class ThreadDatabase extends Database { query += " AND " + ARCHIVED + " = 0"; + if (SignalStore.releaseChannelValues().getReleaseChannelRecipientId() != null) { + query += " AND " + RECIPIENT_ID + " != " + SignalStore.releaseChannelValues().getReleaseChannelRecipientId().toLong(); + } + return db.rawQuery(createQuery(query, 0, limit, true), null); } @@ -1599,7 +1604,8 @@ public class ThreadDatabase extends Database { false, recipientSettings.getRegistered(), recipientSettings, - null); + null, + false); recipient = new Recipient(recipientId, details, false); } else { recipient = Recipient.live(recipientId).get(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/CreateReleaseChannelJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/CreateReleaseChannelJob.kt new file mode 100644 index 0000000000..b3bfd51946 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/CreateReleaseChannelJob.kt @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.jobs + +import androidx.core.content.ContextCompat +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.Avatar +import org.thoughtcrime.securesms.avatar.AvatarRenderer +import org.thoughtcrime.securesms.avatar.Avatars +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.jobmanager.Data +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.profiles.AvatarHelper +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.providers.BlobProvider +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.transport.RetryLaterException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Creates the Release Channel (Signal) recipient. + */ +class CreateReleaseChannelJob private constructor(parameters: Parameters) : BaseJob(parameters) { + companion object { + const val KEY = "CreateReleaseChannelJob" + + private val TAG = Log.tag(CreateReleaseChannelJob::class.java) + + fun create(): CreateReleaseChannelJob { + return CreateReleaseChannelJob( + Parameters.Builder() + .setQueue("CreateReleaseChannelJob") + .setMaxInstancesForFactory(1) + .setMaxAttempts(3) + .build() + ) + } + } + + override fun serialize(): Data = Data.EMPTY + + override fun getFactoryKey(): String = KEY + + override fun onFailure() = Unit + + override fun onRun() { + if (!SignalStore.account().isRegistered) { + Log.i(TAG, "Not registered, skipping.") + return + } + + if (SignalStore.releaseChannelValues().releaseChannelRecipientId != null) { + Log.i(TAG, "Already created Release Channel recipient ${SignalStore.releaseChannelValues().releaseChannelRecipientId}") + + val recipient = Recipient.resolved(SignalStore.releaseChannelValues().releaseChannelRecipientId!!) + if (recipient.profileAvatar == null || recipient.profileAvatar?.isEmpty() == true) { + setAvatar(recipient.id) + } + } else { + val recipients = SignalDatabase.recipients + + val releaseChannelId: RecipientId = recipients.insertReleaseChannelRecipient() + SignalStore.releaseChannelValues().setReleaseChannelRecipientId(releaseChannelId) + + recipients.setProfileName(releaseChannelId, ProfileName.asGiven("Signal")) + recipients.setMuted(releaseChannelId, Long.MAX_VALUE) + setAvatar(releaseChannelId) + } + } + + private fun setAvatar(id: RecipientId) { + val latch = CountDownLatch(1) + AvatarRenderer.renderAvatar( + context, + Avatar.Resource( + R.drawable.ic_signal_logo_large, + Avatars.ColorPair(ContextCompat.getColor(context, R.color.core_ultramarine), ContextCompat.getColor(context, R.color.core_white), "") + ), + onAvatarRendered = { media -> + AvatarHelper.setAvatar(context, id, BlobProvider.getInstance().getStream(context, media.uri)) + SignalDatabase.recipients.setProfileAvatar(id, "local") + latch.countDown() + }, + onRenderFailed = { t -> + Log.w(TAG, t) + latch.countDown() + } + ) + + try { + val completed: Boolean = latch.await(30, TimeUnit.SECONDS) + if (!completed) { + throw RetryLaterException() + } + } catch (e: InterruptedException) { + throw RetryLaterException() + } + } + + override fun onShouldRetry(e: Exception): Boolean = e is RetryLaterException + + class Factory : Job.Factory { + override fun create(parameters: Parameters, data: Data): CreateReleaseChannelJob { + return CreateReleaseChannelJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 67b414e080..6fbf4889fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -84,6 +84,7 @@ public final class JobManagerFactories { put(CleanPreKeysJob.KEY, new CleanPreKeysJob.Factory()); put(ClearFallbackKbsEnclaveJob.KEY, new ClearFallbackKbsEnclaveJob.Factory()); put(ConversationShortcutUpdateJob.KEY, new ConversationShortcutUpdateJob.Factory()); + put(CreateReleaseChannelJob.KEY, new CreateReleaseChannelJob.Factory()); put(CreateSignedPreKeyJob.KEY, new CreateSignedPreKeyJob.Factory()); put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory()); put(DonationReceiptRedemptionJob.KEY, new DonationReceiptRedemptionJob.Factory()); @@ -151,6 +152,7 @@ public final class JobManagerFactories { put(RequestGroupV2InfoJob.KEY, new RequestGroupV2InfoJob.Factory()); put(RetrieveProfileAvatarJob.KEY, new RetrieveProfileAvatarJob.Factory()); put(RetrieveProfileJob.KEY, new RetrieveProfileJob.Factory()); + put(RetrieveReleaseChannelJob.KEY, new RetrieveReleaseChannelJob.Factory()); put(RotateCertificateJob.KEY, new RotateCertificateJob.Factory()); put(RotateProfileKeyJob.KEY, new RotateProfileKeyJob.Factory()); put(RotateSignedPreKeyJob.KEY, new RotateSignedPreKeyJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveReleaseChannelJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveReleaseChannelJob.kt new file mode 100644 index 0000000000..8ff0b1a257 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveReleaseChannelJob.kt @@ -0,0 +1,263 @@ +package org.thoughtcrime.securesms.jobs + +import androidx.core.os.LocaleListCompat +import com.fasterxml.jackson.annotation.JsonProperty +import org.signal.core.util.ThreadUtil +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.BuildConfig +import org.thoughtcrime.securesms.database.MessageDatabase +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.addButton +import org.thoughtcrime.securesms.database.model.addLink +import org.thoughtcrime.securesms.database.model.addStyle +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +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.recipients.Recipient +import org.thoughtcrime.securesms.releasechannel.ReleaseChannel +import org.thoughtcrime.securesms.s3.S3 +import org.thoughtcrime.securesms.transport.RetryLaterException +import org.thoughtcrime.securesms.util.Hex +import org.thoughtcrime.securesms.util.LocaleFeatureFlags +import org.whispersystems.signalservice.internal.ServiceResponse +import java.io.IOException +import java.lang.Integer.max +import java.security.MessageDigest +import java.util.Locale +import java.util.concurrent.TimeUnit + +/** + * Retrieves and processes release channel messages. + */ +class RetrieveReleaseChannelJob private constructor(private val force: Boolean, parameters: Parameters) : BaseJob(parameters) { + companion object { + const val KEY = "RetrieveReleaseChannelJob" + private const val MANIFEST = "https://updates.signal.org/dynamic/release-notes/release-notes.json" + private const val BASE_RELEASE_NOTE = "https://updates.signal.org/static/release-notes" + private const val KEY_FORCE = "force" + + private val TAG = Log.tag(RetrieveReleaseChannelJob::class.java) + + @JvmStatic + @JvmOverloads + fun enqueue(force: Boolean = false) { + if (!SignalStore.account().isRegistered) { + Log.i(TAG, "Not registered, skipping.") + return + } + + if (!force && System.currentTimeMillis() < SignalStore.releaseChannelValues().nextScheduledCheck) { + Log.i(TAG, "Too soon to check for updated release notes") + return + } + + val job = RetrieveReleaseChannelJob( + force, + Parameters.Builder() + .setQueue("RetrieveReleaseChannelJob") + .setMaxInstancesForFactory(1) + .setMaxAttempts(3) + .addConstraint(NetworkConstraint.KEY) + .build() + ) + + ApplicationDependencies.getJobManager() + .startChain(CreateReleaseChannelJob.create()) + .then(job) + .enqueue() + } + } + + override fun serialize(): Data = Data.Builder().putBoolean(KEY_FORCE, force).build() + + override fun getFactoryKey(): String = KEY + + override fun onFailure() = Unit + + @Suppress("UsePropertyAccessSyntax") + override fun onRun() { + if (!SignalStore.account().isRegistered) { + Log.i(TAG, "Not registered, skipping.") + return + } + + val values = SignalStore.releaseChannelValues() + + if (values.releaseChannelRecipientId == null) { + Log.w(TAG, "Release Channel recipient is null, this shouldn't happen, will try to create on next run") + return + } + + if (Recipient.resolved(values.releaseChannelRecipientId!!).isBlocked) { + Log.i(TAG, "Release channel is blocked, do not fetch updates") + values.nextScheduledCheck = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7) + return + } + + if (!force && System.currentTimeMillis() < values.nextScheduledCheck) { + Log.i(TAG, "Too soon to check for updated release notes") + return + } + + if (SignalDatabase.threads.getUnarchivedConversationListCount() < 6) { + Log.i(TAG, "User does not have enough conversations to show release channel") + return + } + + val manifestMd5: ByteArray? = S3.getObjectMD5(MANIFEST) + + if (manifestMd5 == null) { + Log.i(TAG, "Unable to retrieve manifest MD5") + return + } + + when { + values.highestVersionNoteReceived == 0 -> { + Log.i(TAG, "First check, saving code and skipping download") + values.highestVersionNoteReceived = BuildConfig.CANONICAL_VERSION_CODE + } + MessageDigest.isEqual(manifestMd5, values.previousManifestMd5) -> { + Log.i(TAG, "Manifest has not changed since last fetch.") + } + else -> updateReleaseNotes(manifestMd5) + } + + values.previousManifestMd5 = manifestMd5 + values.nextScheduledCheck = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7) + } + + private fun updateReleaseNotes(manifestMd5: ByteArray) { + Log.i(TAG, "Updating release notes to ${Hex.toStringCondensed(manifestMd5)}") + + val values = SignalStore.releaseChannelValues() + val allReleaseNotes: ReleaseNotes? = S3.getAndVerifyObject(MANIFEST, ReleaseNotes::class.java, manifestMd5).result.orNull() + + if (allReleaseNotes != null) { + val resolvedNotes: List = allReleaseNotes.announcements + .filter { it.androidMinVersion.toIntOrNull()?.let { minVersion: Int -> minVersion > values.highestVersionNoteReceived && minVersion <= BuildConfig.CANONICAL_VERSION_CODE } ?: false } + .filter { it.countries == null || LocaleFeatureFlags.shouldShowReleaseNote(it.uuid, it.countries) } + .sortedBy { it.androidMinVersion.toInt() } + .map { resolveReleaseNote(it) } + + if (resolvedNotes.any { it == null }) { + Log.w(TAG, "Some release notes did not resolve, aborting.") + throw RetryLaterException() + } + + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(values.releaseChannelRecipientId!!)) + var highestVersion = values.highestVersionNoteReceived + + resolvedNotes.filterNotNull() + .forEach { note -> + val body = "${note.translation.title}\n\n${note.translation.body}" + val bodyRangeList = BodyRangeList.newBuilder() + .addStyle(BodyRangeList.BodyRange.Style.BOLD, 0, note.translation.title.length) + + if (note.releaseNote.link?.isNotEmpty() == true && note.translation.linkText?.isNotEmpty() == true) { + val linkIndex = body.indexOf(note.translation.linkText) + if (linkIndex != -1 && linkIndex + note.translation.linkText.length < body.length) { + bodyRangeList.addLink(note.releaseNote.link, linkIndex, note.translation.linkText.length) + } + } + + if (note.releaseNote.ctaId?.isNotEmpty() == true && note.translation.callToActionText?.isNotEmpty() == true) { + bodyRangeList.addButton(note.translation.callToActionText, note.releaseNote.ctaId, body.lastIndex, 0) + } + + ThreadUtil.sleep(1) + val insertResult: MessageDatabase.InsertResult? = ReleaseChannel.insertAnnouncement( + recipientId = values.releaseChannelRecipientId!!, + body = body, + threadId = threadId, + messageRanges = bodyRangeList.build(), + image = note.translation.image + ) + + SignalDatabase.sms.insertBoostRequestMessage(values.releaseChannelRecipientId!!, threadId) + + if (insertResult != null) { + SignalDatabase.attachments.getAttachmentsForMessage(insertResult.messageId) + .forEach { ApplicationDependencies.getJobManager().add(AttachmentDownloadJob(insertResult.messageId, it.attachmentId, false)) } + + ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.threadId) + TrimThreadJob.enqueueAsync(insertResult.threadId) + + highestVersion = max(highestVersion, note.releaseNote.androidMinVersion.toInt()) + } + } + + values.highestVersionNoteReceived = highestVersion + } else { + Log.w(TAG, "Unable to retrieve manifest json") + } + } + + private fun resolveReleaseNote(releaseNote: ReleaseNote): FullReleaseNote? { + val urlBase = "$BASE_RELEASE_NOTE/${releaseNote.uuid}" + val localeList: LocaleListCompat = LocaleListCompat.getDefault() + + val potentialNoteUrls = mutableListOf() + + if (SignalStore.settings().language != "zz") { + potentialNoteUrls += "$urlBase/${SignalStore.settings().language}.json" + } + + for (index in 0 until localeList.size()) { + val locale: Locale = localeList.get(index) + if (locale.language.isNotEmpty()) { + if (locale.country.isNotEmpty()) { + potentialNoteUrls += "$urlBase/${locale.language}_${locale.country}.json" + } + potentialNoteUrls += "$urlBase/${locale.language}.json" + } + } + + potentialNoteUrls += "$urlBase/en.json" + + for (potentialUrl: String in potentialNoteUrls) { + val translationJson: ServiceResponse = S3.getAndVerifyObject(potentialUrl, TranslatedReleaseNote::class.java) + + if (translationJson.result.isPresent) { + return FullReleaseNote(releaseNote, translationJson.result.get()) + } else if (translationJson.status != 404 && translationJson.executionError.orNull() !is S3.Md5FailureException) { + throw RetryLaterException() + } + } + + return null + } + + override fun onShouldRetry(e: Exception): Boolean { + return e is RetryLaterException || e is IOException + } + + data class FullReleaseNote(val releaseNote: ReleaseNote, val translation: TranslatedReleaseNote) + + data class ReleaseNotes(@JsonProperty val announcements: List) + + data class ReleaseNote( + @JsonProperty val uuid: String, + @JsonProperty val countries: String?, + @JsonProperty val androidMinVersion: String, + @JsonProperty val link: String?, + @JsonProperty val ctaId: String? + ) + + data class TranslatedReleaseNote( + @JsonProperty val uuid: String, + @JsonProperty val image: String?, + @JsonProperty val linkText: String?, + @JsonProperty val title: String, + @JsonProperty val body: String, + @JsonProperty val callToActionText: String?, + ) + + class Factory : Job.Factory { + override fun create(parameters: Parameters, data: Data): RetrieveReleaseChannelJob { + return RetrieveReleaseChannelJob(data.getBoolean(KEY_FORCE), parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ReleaseChannelValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ReleaseChannelValues.kt new file mode 100644 index 0000000000..b38312533d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ReleaseChannelValues.kt @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.keyvalue + +import org.thoughtcrime.securesms.recipients.RecipientId + +internal class ReleaseChannelValues(store: KeyValueStore) : SignalStoreValues(store) { + + companion object { + private const val KEY_RELEASE_CHANNEL_RECIPIENT_ID = "releasechannel.recipient_id" + private const val KEY_NEXT_SCHEDULED_CHECK = "releasechannel.next_scheduled_check" + private const val KEY_PREVIOUS_MANIFEST_MD5 = "releasechannel.previous_manifest_md5" + private const val KEY_HIGHEST_VERSION_NOTE_RECEIVED = "releasechannel.highest_version_note_received" + } + + override fun onFirstEverAppLaunch() = Unit + + override fun getKeysToIncludeInBackup(): List = listOf( + KEY_RELEASE_CHANNEL_RECIPIENT_ID + ) + + val releaseChannelRecipientId: RecipientId? + get() { + val id = getString(KEY_RELEASE_CHANNEL_RECIPIENT_ID, "") + return if (id.isEmpty()) { + null + } else { + RecipientId.from(id) + } + } + + fun setReleaseChannelRecipientId(id: RecipientId) { + putString(KEY_RELEASE_CHANNEL_RECIPIENT_ID, id.serialize()) + } + + var nextScheduledCheck by longValue(KEY_NEXT_SCHEDULED_CHECK, 0) + var previousManifestMd5 by blobValue(KEY_PREVIOUS_MANIFEST_MD5, ByteArray(0)) + var highestVersionNoteReceived by integerValue(KEY_HIGHEST_VERSION_NOTE_RECEIVED, 0) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java index dda3bfab8a..374eb9236b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -41,6 +41,7 @@ public final class SignalStore { private final ChatColorsValues chatColorsValues; private final ImageEditorValues imageEditorValues; private final NotificationProfileValues notificationProfileValues; + private final ReleaseChannelValues releaseChannelValues; private static volatile SignalStore instance; @@ -81,6 +82,7 @@ public final class SignalStore { this.chatColorsValues = new ChatColorsValues(store); this.imageEditorValues = new ImageEditorValues(store); this.notificationProfileValues = new NotificationProfileValues(store); + this.releaseChannelValues = new ReleaseChannelValues(store); } public static void onFirstEverAppLaunch() { @@ -107,6 +109,7 @@ public final class SignalStore { chatColorsValues().onFirstEverAppLaunch(); imageEditorValues().onFirstEverAppLaunch(); notificationProfileValues().onFirstEverAppLaunch(); + releaseChannelValues().onFirstEverAppLaunch(); } public static List getKeysToIncludeInBackup() { @@ -134,6 +137,7 @@ public final class SignalStore { keys.addAll(chatColorsValues().getKeysToIncludeInBackup()); keys.addAll(imageEditorValues().getKeysToIncludeInBackup()); keys.addAll(notificationProfileValues().getKeysToIncludeInBackup()); + keys.addAll(releaseChannelValues().getKeysToIncludeInBackup()); return keys; } @@ -238,6 +242,10 @@ public final class SignalStore { return getInstance().notificationProfileValues; } + public static @NonNull ReleaseChannelValues releaseChannelValues() { + return getInstance().releaseChannelValues; + } + public static @NonNull GroupsV2AuthorizationSignalStoreCache groupsV2AuthorizationCache() { return new GroupsV2AuthorizationSignalStoreCache(getStore()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java index 2b891df274..78d6c0e026 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java @@ -214,10 +214,10 @@ public final class LiveRecipient { avatarId = Optional.of(groupRecord.get().getAvatarId()); } - return new RecipientDetails(title, null, avatarId, false, false, settings.getRegistered(), settings, members); + return new RecipientDetails(title, null, avatarId, false, false, settings.getRegistered(), settings, members, false); } - return new RecipientDetails(null, null, Optional.absent(), false, false, settings.getRegistered(), settings, null); + return new RecipientDetails(null, null, Optional.absent(), false, false, settings.getRegistered(), settings, null, false); } synchronized void set(@NonNull Recipient recipient) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index e47d799d75..873effec9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -439,7 +439,7 @@ public class Recipient { this.extras = details.extras; this.hasGroupsInCommon = details.hasGroupsInCommon; this.badges = details.badges; - this.isReleaseNotesRecipient = false; + this.isReleaseNotesRecipient = details.isReleaseChannel; } public @NonNull RecipientId getId() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java index dd480da9be..bd6e9ffd9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java @@ -83,6 +83,7 @@ public class RecipientDetails { final Optional extras; final boolean hasGroupsInCommon; final List badges; + final boolean isReleaseChannel; public RecipientDetails(@Nullable String groupName, @Nullable String systemContactName, @@ -91,7 +92,8 @@ public class RecipientDetails { boolean isSelf, @NonNull RegisteredState registeredState, @NonNull RecipientRecord record, - @Nullable List participants) + @Nullable List participants, + boolean isReleaseChannel) { this.groupAvatarId = groupAvatarId; this.systemContactPhoto = Util.uri(record.getSystemContactPhotoUri()); @@ -144,6 +146,7 @@ public class RecipientDetails { this.extras = Optional.fromNullable(record.getExtras()); this.hasGroupsInCommon = record.hasGroupsInCommon(); this.badges = record.getBadges(); + this.isReleaseChannel = isReleaseChannel; } /** @@ -201,12 +204,14 @@ public class RecipientDetails { this.extras = Optional.absent(); this.hasGroupsInCommon = false; this.badges = Collections.emptyList(); + this.isReleaseChannel = false; } public static @NonNull RecipientDetails forIndividual(@NonNull Context context, @NonNull RecipientRecord settings) { - boolean systemContact = !settings.getSystemProfileName().isEmpty(); - boolean isSelf = (settings.getE164() != null && settings.getE164().equals(SignalStore.account().getE164())) || - (settings.getAci() != null && settings.getAci().equals(SignalStore.account().getAci())); + boolean systemContact = !settings.getSystemProfileName().isEmpty(); + boolean isSelf = (settings.getE164() != null && settings.getE164().equals(SignalStore.account().getE164())) || + (settings.getAci() != null && settings.getAci().equals(SignalStore.account().getAci())); + boolean isReleaseChannel = settings.getId().equals(SignalStore.releaseChannelValues().getReleaseChannelRecipientId()); RegisteredState registeredState = settings.getRegistered(); @@ -218,6 +223,6 @@ public class RecipientDetails { } } - return new RecipientDetails(null, settings.getSystemDisplayName(), Optional.absent(), systemContact, isSelf, registeredState, settings, null); + return new RecipientDetails(null, settings.getSystemDisplayName(), Optional.absent(), systemContact, isSelf, registeredState, settings, null, isReleaseChannel); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/s3/S3.kt b/app/src/main/java/org/thoughtcrime/securesms/s3/S3.kt new file mode 100644 index 0000000000..004769cda4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/s3/S3.kt @@ -0,0 +1,215 @@ +package org.thoughtcrime.securesms.s3 + +import android.content.Context +import androidx.annotation.WorkerThread +import okhttp3.Request +import okhttp3.Response +import okio.HashingSink +import okio.sink +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.util.EncryptedStreamUtils +import org.thoughtcrime.securesms.util.Hex +import org.thoughtcrime.securesms.util.JsonUtils +import org.whispersystems.signalservice.internal.ServiceResponse +import org.whispersystems.signalservice.internal.websocket.DefaultErrorMapper +import org.whispersystems.signalservice.internal.websocket.DefaultResponseMapper +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream +import java.nio.charset.Charset +import java.security.MessageDigest +import java.util.regex.Matcher +import java.util.regex.Pattern + +/** + * Generic methods for communicating with S3 + */ +object S3 { + private val TAG = Log.tag(S3::class.java) + + private val okHttpClient = ApplicationDependencies.getOkHttpClient() + + /** + * Fetches the content at the given endpoint and attempts to convert it into a long. + * + * @param endpoint The endpoint at which to get the long + * @return the long value of the body + * @throws IOException if the call fails or the response body cannot be parsed as a long + */ + @WorkerThread + fun getLong(endpoint: String): Long { + val request = Request.Builder() + .get() + .url(endpoint) + .build() + + try { + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IOException() + } + + return response.body()?.bytes()?.let { String(it).trim().toLongOrNull() } ?: throw IOException() + } + } catch (e: IOException) { + Log.w(TAG, "Failed to retreive long value from S3") + throw e + } + } + + /** + * Retrieves an S3 object from the given endpoint. + */ + @WorkerThread + fun getObject(endpoint: String): Response { + val request = Request.Builder() + .get() + .url(endpoint) + .build() + + return okHttpClient.newCall(request).execute() + } + + /** + * Retrieves an S3 object from the given endpoint and verifies the contents against the S3 MD5 ETag that is retrieved separately. + */ + @WorkerThread + fun getAndVerifyObject(endpoint: String, clazz: Class, md5: ByteArray? = getObjectMD5(endpoint)): ServiceResponse { + if (md5 == null) { + Log.w(TAG, "Failed to download s3 object MD5.") + return ServiceResponse.forExecutionError(Md5FailureException()) + } + + try { + getObject(endpoint).use { response -> + if (!response.isSuccessful) { + return ServiceResponse.forApplicationError( + DefaultErrorMapper.getDefault().parseError(response.code()), + response.code(), + "" + ) + } + + val source = response.body()?.source() + + val outputStream = ByteArrayOutputStream() + + val md5Result = outputStream.sink().use { sink -> + val hash = HashingSink.md5(sink) + source?.readAll(hash) + hash.hash.toByteArray() + } + + if (!MessageDigest.isEqual(md5, md5Result)) { + Log.w(TAG, "Content mismatch when downloading s3 object. Deleting.") + return ServiceResponse.forExecutionError(Md5FailureException()) + } + + return DefaultResponseMapper.extend(clazz) + .withResponseMapper { status, body, _, _ -> ServiceResponse.forResult(JsonUtils.fromJson(body, clazz), status, body) } + .build() + .map(200, String(outputStream.toByteArray(), Charset.forName("UTF-8")), { "" }, false) + } + } catch (e: IOException) { + Log.w(TAG, "Unable to get and verify", e) + return ServiceResponse.forUnknownError(e) + } + } + + /** + * This method will download content from the given network path, and store it at the given disk path. In addition, it will check and verify that the + * body's content MD5 matches the MD5 embedded in the S3 ETAG. If there is a mismatch, the local content will be deleted. + * + * @param context Application context. This may be long-lived so it's important that the caller does not pass an Activity. + * @param objectPathOnNetwork A fully formed URL to an S3 object containing the content to write to disk + * @param objectFileOnDisk A File on disk that can be written to. + * @param doNotEncrypt Defaults to false. It is generally an error to set this to true, and should only be used for writing font data. + * @return true on success, false otherwise. + */ + @WorkerThread + fun verifyAndWriteToDisk(context: Context, objectPathOnNetwork: String, objectFileOnDisk: File, doNotEncrypt: Boolean = false): Boolean { + val md5 = getObjectMD5(objectPathOnNetwork) + if (md5 == null) { + Log.w(TAG, "Failed to download s3 object MD5.") + return false + } + + try { + if (objectFileOnDisk.exists()) { + objectFileOnDisk.delete() + } + + getObject(objectPathOnNetwork).use { response -> + val source = response.body()?.source() + + val outputStream: OutputStream = if (doNotEncrypt) { + FileOutputStream(objectFileOnDisk) + } else { + EncryptedStreamUtils.getOutputStream(context, objectFileOnDisk) + } + + val md5Result = outputStream.sink().use { sink -> + val hash = HashingSink.md5(sink) + source?.readAll(hash) + hash.hash.toByteArray() + } + + if (!md5.contentEquals(md5Result)) { + Log.w(TAG, "Content mismatch when downloading s3 object. Deleting.") + objectFileOnDisk.delete() + return false + } + } + + return true + } catch (e: Exception) { + Log.w(TAG, "Failed to download s3 object", e) + return false + } + } + + /** + * Downloads and parses the ETAG from an S3 object, utilizing a HEAD request. + */ + @WorkerThread + fun getObjectMD5(endpoint: String): ByteArray? { + val request = Request.Builder() + .head() + .url(endpoint) + .build() + + try { + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return null + } + + val md5 = getMD5FromResponse(response) + return md5?.let { Hex.fromStringCondensed(md5) } + } + } catch (e: IOException) { + Log.w(TAG, "Could not retrieve md5", e) + return null + } + } + + /** + * Parses the MD5 from a response. + */ + private fun getMD5FromResponse(response: Response): String? { + val pattern: Pattern = Pattern.compile(".*([a-f0-9]{32}).*") + val header = response.header("etag") ?: return null + val matcher: Matcher = pattern.matcher(header) + + return if (matcher.find()) { + matcher.group(1) + } else { + null + } + } + + class Md5FailureException : IOException("Failed to getting or comparing MD5") +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/EncryptedStreamUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/EncryptedStreamUtils.kt new file mode 100644 index 0000000000..b1d53e139a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/EncryptedStreamUtils.kt @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.util + +import android.content.Context +import androidx.annotation.WorkerThread +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider +import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream +import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream +import java.io.File +import java.io.InputStream +import java.io.OutputStream + +/** + * Utilities for reading and writing to disk in an encrypted manner. + */ +object EncryptedStreamUtils { + @WorkerThread + fun getOutputStream(context: Context, outputFile: File): OutputStream { + val attachmentSecret = AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret + return ModernEncryptingPartOutputStream.createFor(attachmentSecret, outputFile, true).second + } + + @WorkerThread + fun getInputStream(context: Context, inputFile: File): InputStream { + val attachmentSecret = AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret + return ModernDecryptingPartInputStream.createFor(attachmentSecret, inputFile, 0) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/JsonUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/JsonUtils.java index 4a3cf0d7e3..0e6d66d426 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/JsonUtils.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/JsonUtils.java @@ -19,6 +19,7 @@ public class JsonUtils { objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING); objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); + com.fasterxml.jackson.module.kotlin.ExtensionsKt.registerKotlinModule(objectMapper); } public static T fromJson(byte[] serialized, Class clazz) throws IOException { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LocaleFeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/LocaleFeatureFlags.java index 81c9698b25..fa4342be2a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/LocaleFeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LocaleFeatureFlags.java @@ -54,6 +54,10 @@ public final class LocaleFeatureFlags { return !blacklist.contains(countryCode); } + public static boolean shouldShowReleaseNote(@NonNull String releaseNoteUuid, @NonNull String countries) { + return isEnabled(releaseNoteUuid, countries); + } + /** * Parses a comma-separated list of country codes colon-separated from how many buckets out of 1 million * should be enabled to see this megaphone in that country code. At the end of the list, an optional diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/VersionTracker.java b/app/src/main/java/org/thoughtcrime/securesms/util/VersionTracker.java index b5488b696d..f3d401d9f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/VersionTracker.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/VersionTracker.java @@ -8,6 +8,7 @@ import androidx.annotation.NonNull; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob; +import org.thoughtcrime.securesms.jobs.RetrieveReleaseChannelJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import java.io.IOException; @@ -29,6 +30,7 @@ public class VersionTracker { Log.i(TAG, "Upgraded from " + lastVersionCode + " to " + currentVersionCode); SignalStore.misc().clearClientDeprecated(); ApplicationDependencies.getJobManager().add(new RemoteConfigRefreshJob()); + RetrieveReleaseChannelJob.enqueue(true); LocalMetrics.getInstance().clear(); } diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt index 8618a08d19..f1018b9fcd 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt @@ -80,7 +80,8 @@ object RecipientDatabaseTestUtils { ), extras: Recipient.Extras? = null, hasGroupsInCommon: Boolean = false, - badges: List = emptyList() + badges: List = emptyList(), + isReleaseChannel: Boolean = false ): Recipient = Recipient( recipientId, RecipientDetails( @@ -142,7 +143,8 @@ object RecipientDatabaseTestUtils { hasGroupsInCommon, badges ), - participants + participants, + isReleaseChannel ), resolved ) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/websocket/ErrorMapper.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/websocket/ErrorMapper.java index c7812da265..2f8c2a3350 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/websocket/ErrorMapper.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/websocket/ErrorMapper.java @@ -11,4 +11,8 @@ import org.whispersystems.signalservice.api.push.exceptions.MalformedResponseExc */ public interface ErrorMapper { Throwable parseError(int status, String body, Function getHeader) throws MalformedResponseException; + + default Throwable parseError(int status) throws MalformedResponseException { + return parseError(status, "", s -> ""); + } }