Periodically fetch release notes.

This commit is contained in:
Cody Henthorne 2022-02-02 10:45:04 -05:00
parent 9114dc83d7
commit 8348badcd6
25 changed files with 789 additions and 34 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<MarkedMessageInfo> setMessagesRead(String where, String[] arguments) {
SQLiteDatabase database = databaseHelper.getSignalWritableDatabase();
List<MarkedMessageInfo> result = new LinkedList<>();
Cursor cursor = null;
SQLiteDatabase database = databaseHelper.getSignalWritableDatabase();
List<MarkedMessageInfo> 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));
}
}
}

View file

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

View file

@ -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<MarkedMessageInfo> setMessagesRead(String where, String[] arguments) {
SQLiteDatabase database = databaseHelper.getSignalWritableDatabase();
List<MarkedMessageInfo> results = new LinkedList<>();
Cursor cursor = null;
SQLiteDatabase database = databaseHelper.getSignalWritableDatabase();
List<MarkedMessageInfo> 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));
}
}
}

View file

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

View file

@ -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<CreateReleaseChannelJob> {
override fun create(parameters: Parameters, data: Data): CreateReleaseChannelJob {
return CreateReleaseChannelJob(parameters)
}
}
}

View file

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

View file

@ -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<FullReleaseNote?> = 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<String>()
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<TranslatedReleaseNote> = 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<ReleaseNote>)
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<RetrieveReleaseChannelJob> {
override fun create(parameters: Parameters, data: Data): RetrieveReleaseChannelJob {
return RetrieveReleaseChannelJob(data.getBoolean(KEY_FORCE), parameters)
}
}
}

View file

@ -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<String> = 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)
}

View file

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

View file

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

View file

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

View file

@ -83,6 +83,7 @@ public class RecipientDetails {
final Optional<Recipient.Extras> extras;
final boolean hasGroupsInCommon;
final List<Badge> 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<Recipient> participants)
@Nullable List<Recipient> 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);
}
}

View file

@ -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 <T> getAndVerifyObject(endpoint: String, clazz: Class<T>, md5: ByteArray? = getObjectMD5(endpoint)): ServiceResponse<T> {
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")
}

View file

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

View file

@ -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> T fromJson(byte[] serialized, Class<T> clazz) throws IOException {

View file

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

View file

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

View file

@ -80,7 +80,8 @@ object RecipientDatabaseTestUtils {
),
extras: Recipient.Extras? = null,
hasGroupsInCommon: Boolean = false,
badges: List<Badge> = emptyList()
badges: List<Badge> = emptyList(),
isReleaseChannel: Boolean = false
): Recipient = Recipient(
recipientId,
RecipientDetails(
@ -142,7 +143,8 @@ object RecipientDatabaseTestUtils {
hasGroupsInCommon,
badges
),
participants
participants,
isReleaseChannel
),
resolved
)

View file

@ -11,4 +11,8 @@ import org.whispersystems.signalservice.api.push.exceptions.MalformedResponseExc
*/
public interface ErrorMapper {
Throwable parseError(int status, String body, Function<String, String> getHeader) throws MalformedResponseException;
default Throwable parseError(int status) throws MalformedResponseException {
return parseError(status, "", s -> "");
}
}