Periodically fetch release notes.
This commit is contained in:
parent
9114dc83d7
commit
8348badcd6
25 changed files with 789 additions and 34 deletions
|
@ -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");
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
215
app/src/main/java/org/thoughtcrime/securesms/s3/S3.kt
Normal file
215
app/src/main/java/org/thoughtcrime/securesms/s3/S3.kt
Normal 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")
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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 -> "");
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue