diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 29af73f20f..93b4f70a9f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -61,7 +61,7 @@ import org.thoughtcrime.securesms.jobs.ProfileUploadJob; import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob; import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; -import org.thoughtcrime.securesms.jobs.RetrieveReleaseChannelJob; +import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob; import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger; @@ -201,7 +201,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) + .addPostRender(RetrieveRemoteAnnouncementsJob::enqueue) .addPostRender(() -> AndroidTelecomUtil.registerPhoneAccount()) .addPostRender(() -> ApplicationDependencies.getJobManager().add(new FontDownloaderJob())) .addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 027c1bb7b1..8ad93202b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -28,7 +28,7 @@ import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob import org.thoughtcrime.securesms.jobs.RefreshAttributesJob import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob -import org.thoughtcrime.securesms.jobs.RetrieveReleaseChannelJob +import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob import org.thoughtcrime.securesms.jobs.StorageForcePushJob import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob @@ -418,7 +418,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter title = DSLSettingsText.from(R.string.preferences__internal_fetch_release_channel), onClick = { SignalStore.releaseChannelValues().previousManifestMd5 = ByteArray(0) - RetrieveReleaseChannelJob.enqueue(force = true) + RetrieveRemoteAnnouncementsJob.enqueue(force = true) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RemoteMegaphoneDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RemoteMegaphoneDatabase.kt new file mode 100644 index 0000000000..6c59822340 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RemoteMegaphoneDatabase.kt @@ -0,0 +1,214 @@ +package org.thoughtcrime.securesms.database + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.net.Uri +import androidx.core.content.contentValuesOf +import androidx.core.net.toUri +import org.signal.core.util.readToList +import org.signal.core.util.requireInt +import org.signal.core.util.requireLong +import org.signal.core.util.requireNonNullString +import org.signal.core.util.requireString +import org.signal.core.util.select +import org.signal.core.util.update +import org.thoughtcrime.securesms.BuildConfig +import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord +import java.util.concurrent.TimeUnit + +/** + * Stores remotely configured megaphones. + */ +class RemoteMegaphoneDatabase(context: Context, databaseHelper: SignalDatabase) : Database(context, databaseHelper) { + + companion object { + private const val TABLE_NAME = "remote_megaphone" + private const val ID = "_id" + private const val UUID = "uuid" + private const val COUNTRIES = "countries" + private const val PRIORITY = "priority" + private const val MINIMUM_VERSION = "minimum_version" + private const val DONT_SHOW_BEFORE = "dont_show_before" + private const val DONT_SHOW_AFTER = "dont_show_after" + private const val SHOW_FOR_DAYS = "show_for_days" + private const val CONDITIONAL_ID = "conditional_id" + private const val PRIMARY_ACTION_ID = "primary_action_id" + private const val SECONDARY_ACTION_ID = "secondary_action_id" + private const val IMAGE_URL = "image_url" + private const val IMAGE_BLOB_URI = "image_uri" + private const val TITLE = "title" + private const val BODY = "body" + private const val PRIMARY_ACTION_TEXT = "primary_action_text" + private const val SECONDARY_ACTION_TEXT = "secondary_action_text" + private const val SHOWN_AT = "shown_at" + private const val FINISHED_AT = "finished_at" + + val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY, + $UUID TEXT UNIQUE NOT NULL, + $PRIORITY INTEGER NOT NULL, + $COUNTRIES TEXT, + $MINIMUM_VERSION INTEGER NOT NULL, + $DONT_SHOW_BEFORE INTEGER NOT NULL, + $DONT_SHOW_AFTER INTEGER NOT NULL, + $SHOW_FOR_DAYS INTEGER NOT NULL, + $CONDITIONAL_ID TEXT, + $PRIMARY_ACTION_ID TEXT, + $SECONDARY_ACTION_ID TEXT, + $IMAGE_URL TEXT, + $IMAGE_BLOB_URI TEXT DEFAULT NULL, + $TITLE TEXT NOT NULL, + $BODY TEXT NOT NULL, + $PRIMARY_ACTION_TEXT TEXT, + $SECONDARY_ACTION_TEXT TEXT, + $SHOWN_AT INTEGER DEFAULT 0, + $FINISHED_AT INTEGER DEFAULT 0 + ) + """.trimIndent() + + const val VERSION_FINISHED = Int.MAX_VALUE + } + + fun insert(record: RemoteMegaphoneRecord) { + writableDatabase.insert(TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, record.toContentValues()) + } + + fun update(uuid: String, priority: Long, countries: String?, title: String, body: String, primaryActionText: String?, secondaryActionText: String?) { + writableDatabase + .update(TABLE_NAME) + .values( + PRIORITY to priority, + COUNTRIES to countries, + TITLE to title, + BODY to body, + PRIMARY_ACTION_TEXT to primaryActionText, + SECONDARY_ACTION_TEXT to secondaryActionText + ) + .where("$UUID = ?", uuid) + .run() + } + + fun getAll(): List { + return readableDatabase + .select() + .from(TABLE_NAME) + .run() + .readToList { it.toRemoteMegaphoneRecord() } + } + + fun getPotentialMegaphonesAndClearOld(now: Long = System.currentTimeMillis()): List { + val records: List = readableDatabase + .select() + .from(TABLE_NAME) + .where("$FINISHED_AT = ? AND $MINIMUM_VERSION <= ? AND ($DONT_SHOW_AFTER > ? AND $DONT_SHOW_BEFORE < ?)", 0, BuildConfig.CANONICAL_VERSION_CODE, now, now) + .orderBy("$PRIORITY DESC") + .run() + .readToList { it.toRemoteMegaphoneRecord() } + + val oldRecords: Set = records + .filter { it.shownAt > 0 && it.showForNumberOfDays > 0 } + .filter { it.shownAt + TimeUnit.DAYS.toMillis(it.showForNumberOfDays) < now } + .toSet() + + for (oldRecord in oldRecords) { + clear(oldRecord.uuid) + } + + return records - oldRecords + } + + fun setImageUri(uuid: String, uri: Uri?) { + writableDatabase + .update(TABLE_NAME) + .values(IMAGE_BLOB_URI to uri?.toString()) + .where("$UUID = ?", uuid) + .run() + } + + fun markShown(uuid: String) { + writableDatabase + .update(TABLE_NAME) + .values(SHOWN_AT to System.currentTimeMillis()) + .where("$UUID = ?", uuid) + .run() + } + + fun markFinished(uuid: String) { + writableDatabase + .update(TABLE_NAME) + .values( + IMAGE_URL to null, + IMAGE_BLOB_URI to null, + FINISHED_AT to System.currentTimeMillis() + ) + .where("$UUID = ?", uuid) + .run() + } + + fun clearImageUrl(uuid: String) { + writableDatabase + .update(TABLE_NAME) + .values(IMAGE_URL to null) + .where("$UUID = ?", uuid) + .run() + } + + fun clear(uuid: String) { + writableDatabase + .update(TABLE_NAME) + .values( + MINIMUM_VERSION to VERSION_FINISHED, + IMAGE_URL to null, + IMAGE_BLOB_URI to null + ) + .where("$UUID = ?", uuid) + .run() + } + + private fun RemoteMegaphoneRecord.toContentValues(): ContentValues { + return contentValuesOf( + UUID to uuid, + PRIORITY to priority, + COUNTRIES to countries, + MINIMUM_VERSION to minimumVersion, + DONT_SHOW_BEFORE to doNotShowBefore, + DONT_SHOW_AFTER to doNotShowAfter, + SHOW_FOR_DAYS to showForNumberOfDays, + CONDITIONAL_ID to conditionalId, + PRIMARY_ACTION_ID to primaryActionId?.id, + SECONDARY_ACTION_ID to secondaryActionId?.id, + IMAGE_URL to imageUrl, + TITLE to title, + BODY to body, + PRIMARY_ACTION_TEXT to primaryActionText, + SECONDARY_ACTION_TEXT to secondaryActionText, + FINISHED_AT to finishedAt + ) + } + + private fun Cursor.toRemoteMegaphoneRecord(): RemoteMegaphoneRecord { + return RemoteMegaphoneRecord( + id = requireLong(ID), + uuid = requireNonNullString(UUID), + priority = requireLong(PRIORITY), + countries = requireString(COUNTRIES), + minimumVersion = requireInt(MINIMUM_VERSION), + doNotShowBefore = requireLong(DONT_SHOW_BEFORE), + doNotShowAfter = requireLong(DONT_SHOW_AFTER), + showForNumberOfDays = requireLong(SHOW_FOR_DAYS), + conditionalId = requireString(CONDITIONAL_ID), + primaryActionId = RemoteMegaphoneRecord.ActionId.from(requireString(PRIMARY_ACTION_ID)), + secondaryActionId = RemoteMegaphoneRecord.ActionId.from(requireString(SECONDARY_ACTION_ID)), + imageUrl = requireString(IMAGE_URL), + imageUri = requireString(IMAGE_BLOB_URI)?.toUri(), + title = requireNonNullString(TITLE), + body = requireNonNullString(BODY), + primaryActionText = requireString(PRIMARY_ACTION_TEXT), + secondaryActionText = requireString(SECONDARY_ACTION_TEXT), + shownAt = requireLong(SHOWN_AT), + finishedAt = requireLong(FINISHED_AT) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt index 25601f05a5..102541a45f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt @@ -72,6 +72,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data val distributionListDatabase: DistributionListDatabase = DistributionListDatabase(context, this) val storySendsDatabase: StorySendsDatabase = StorySendsDatabase(context, this) val cdsDatabase: CdsDatabase = CdsDatabase(context, this) + val remoteMegaphoneDatabase: RemoteMegaphoneDatabase = RemoteMegaphoneDatabase(context, this) override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) { db.enableWriteAheadLogging() @@ -107,6 +108,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data db.execSQL(DonationReceiptDatabase.CREATE_TABLE) db.execSQL(StorySendsDatabase.CREATE_TABLE) db.execSQL(CdsDatabase.CREATE_TABLE) + db.execSQL(RemoteMegaphoneDatabase.CREATE_TABLE) executeStatements(db, SearchDatabase.CREATE_TABLE) executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE) executeStatements(db, MessageSendLogDatabase.CREATE_TABLE) @@ -495,5 +497,10 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data @get:JvmName("unknownStorageIds") val unknownStorageIds: UnknownStorageIdDatabase get() = instance!!.storageIdDatabase + + @get:JvmStatic + @get:JvmName("remoteMegaphones") + val remoteMegaphones: RemoteMegaphoneDatabase + get() = instance!!.remoteMegaphoneDatabase } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 2fb100eede..1e9ccbbe19 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -199,8 +199,9 @@ object SignalDatabaseMigrations { private const val STORY_SYNCS = 143 private const val GROUP_STORY_NOTIFICATIONS = 144 private const val GROUP_STORY_REPLY_CLEANUP = 145 + private const val REMOTE_MEGAPHONE = 146 - const val DATABASE_VERSION = 145 + const val DATABASE_VERSION = 146 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -2584,6 +2585,34 @@ object SignalDatabaseMigrations { """.trimIndent() ) } + + if (oldVersion < REMOTE_MEGAPHONE) { + db.execSQL( + """ + CREATE TABLE remote_megaphone ( + _id INTEGER PRIMARY KEY, + uuid TEXT UNIQUE NOT NULL, + priority INTEGER NOT NULL, + countries TEXT, + minimum_version INTEGER NOT NULL, + dont_show_before INTEGER NOT NULL, + dont_show_after INTEGER NOT NULL, + show_for_days INTEGER NOT NULL, + conditional_id TEXT, + primary_action_id TEXT, + secondary_action_id TEXT, + image_url TEXT, + image_uri TEXT DEFAULT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + primary_action_text TEXT, + secondary_action_text TEXT, + shown_at INTEGER DEFAULT 0, + finished_at INTEGER DEFAULT 0 + ) + """ + ) + } } @JvmStatic diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/RemoteMegaphoneRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/RemoteMegaphoneRecord.kt new file mode 100644 index 0000000000..d0b46ec5f0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/RemoteMegaphoneRecord.kt @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.database.model + +import android.net.Uri + +/** + * Represents a Remote Megaphone. + */ +data class RemoteMegaphoneRecord( + val id: Long = -1, + val priority: Long, + val uuid: String, + val countries: String?, + val minimumVersion: Int, + val doNotShowBefore: Long, + val doNotShowAfter: Long, + val showForNumberOfDays: Long, + val conditionalId: String?, + val primaryActionId: ActionId?, + val secondaryActionId: ActionId?, + val imageUrl: String?, + val imageUri: Uri? = null, + val title: String, + val body: String, + val primaryActionText: String?, + val secondaryActionText: String?, + val shownAt: Long = 0, + val finishedAt: Long = 0 +) { + @get:JvmName("hasPrimaryAction") + val hasPrimaryAction = primaryActionId != null && primaryActionText != null + + @get:JvmName("hasSecondaryAction") + val hasSecondaryAction = secondaryActionId != null && secondaryActionText != null + + enum class ActionId(val id: String, val isDonateAction: Boolean = false) { + SNOOZE("snooze"), + FINISH("finish"), + DONATE("donate", true); + + companion object { + fun from(id: String?): ActionId? { + return values().firstOrNull { it.id == id } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/FetchRemoteMegaphoneImageJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/FetchRemoteMegaphoneImageJob.kt new file mode 100644 index 0000000000..0444ed7c5b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/FetchRemoteMegaphoneImageJob.kt @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.jobs + +import okhttp3.Request +import okhttp3.ResponseBody +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobmanager.Data +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.AutoDownloadEmojiConstraint +import org.thoughtcrime.securesms.providers.BlobProvider +import org.thoughtcrime.securesms.transport.RetryLaterException +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Fetch an image associated with a remote megaphone. + */ +class FetchRemoteMegaphoneImageJob(parameters: Parameters, private val uuid: String, private val imageUrl: String) : BaseJob(parameters) { + + constructor(uuid: String, imageUrl: String) : this( + parameters = Parameters.Builder() + .setQueue(KEY) + .addConstraint(AutoDownloadEmojiConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(TimeUnit.DAYS.toMillis(7)) + .build(), + uuid = uuid, + imageUrl = imageUrl + ) + + override fun serialize(): Data { + return Data.Builder() + .putString(KEY_UUID, uuid) + .putString(KEY_IMAGE_URL, imageUrl) + .build() + } + + override fun getFactoryKey(): String { + return KEY + } + + override fun onRun() { + val request = Request.Builder() + .get() + .url(imageUrl) + .build() + + try { + ApplicationDependencies.getOkHttpClient().newCall(request).execute().use { response -> + if (response.isSuccessful) { + val body: ResponseBody? = response.body() + if (body != null) { + val uri = BlobProvider.getInstance() + .forData(body.byteStream(), body.contentLength()) + .createForMultipleSessionsOnDisk(context) + + SignalDatabase.remoteMegaphones.setImageUri(uuid, uri) + } + } else { + throw NonSuccessfulResponseCodeException(response.code()) + } + } + } catch (e: IOException) { + Log.i(TAG, "Encountered unknown IO error while fetching image for $uuid", e) + throw RetryLaterException() + } + } + + override fun onShouldRetry(e: Exception): Boolean = e is RetryLaterException + + override fun onFailure() { + Log.i(TAG, "Failed to fetch image for $uuid, clearing to present without one") + SignalDatabase.remoteMegaphones.clearImageUrl(uuid) + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, data: Data): FetchRemoteMegaphoneImageJob { + return FetchRemoteMegaphoneImageJob(parameters, data.getString(KEY_UUID), data.getString(KEY_IMAGE_URL)) + } + } + + companion object { + const val KEY = "FetchRemoteMegaphoneImageJob" + + private val TAG = Log.tag(FetchRemoteMegaphoneImageJob::class.java) + + private const val KEY_UUID = "uuid" + private const val KEY_IMAGE_URL = "image_url" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index dd410b63fd..0b99355f53 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -93,6 +93,7 @@ public final class JobManagerFactories { put(DownloadLatestEmojiDataJob.KEY, new DownloadLatestEmojiDataJob.Factory()); put(EmojiSearchIndexDownloadJob.KEY, new EmojiSearchIndexDownloadJob.Factory()); put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory()); + put(FetchRemoteMegaphoneImageJob.KEY, new FetchRemoteMegaphoneImageJob.Factory()); put(FontDownloaderJob.KEY, new FontDownloaderJob.Factory()); put(GiftSendJob.KEY, new GiftSendJob.Factory()); put(GroupV1MigrationJob.KEY, new GroupV1MigrationJob.Factory()); @@ -161,7 +162,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(RetrieveRemoteAnnouncementsJob.KEY, new RetrieveRemoteAnnouncementsJob.Factory()); put(RotateCertificateJob.KEY, new RotateCertificateJob.Factory()); put(RotateProfileKeyJob.KEY, new RotateProfileKeyJob.Factory()); put(RotateSignedPreKeyJob.KEY, new RotateSignedPreKeyJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveReleaseChannelJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveReleaseChannelJob.kt deleted file mode 100644 index 179b88e500..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveReleaseChannelJob.kt +++ /dev/null @@ -1,271 +0,0 @@ -package org.thoughtcrime.securesms.jobs - -import androidx.core.os.LocaleListCompat -import com.fasterxml.jackson.annotation.JsonProperty -import org.signal.core.util.Hex -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.notifications.v2.NotificationThread -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.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 (values.previousManifestMd5.isEmpty() && (SignalDatabase.threads.getArchivedConversationListCount() + SignalDatabase.threads.getUnarchivedConversationListCount()) < 6) { - Log.i(TAG, "User does not have enough conversations to show release channel") - values.nextScheduledCheck = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7) - 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.orElse(null) - - if (allReleaseNotes != null) { - val resolvedNotes: List = allReleaseNotes.announcements.asSequence() - .filter { it.androidMinVersion != null } - .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) } - .toList() - - 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, - imageWidth = note.translation.imageWidth?.toIntOrNull() ?: 0, - imageHeight = note.translation.imageHeight?.toIntOrNull() ?: 0 - ) - - 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, NotificationThread.forConversation(insertResult.threadId)) - TrimThreadJob.enqueueAsync(insertResult.threadId) - - highestVersion = max(highestVersion, note.releaseNote.androidMinVersion!!.toInt()) - } - } - - values.highestVersionNoteReceived = highestVersion - } else { - Log.w(TAG, "Unable to retrieve manifest json") - } - } - - private fun resolveReleaseNote(releaseNote: ReleaseNote): FullReleaseNote? { - val urlBase = "$BASE_RELEASE_NOTE/${releaseNote.uuid}" - val localeList: LocaleListCompat = LocaleListCompat.getDefault() - - val potentialNoteUrls = mutableListOf() - - if (SignalStore.settings().language != "zz") { - potentialNoteUrls += "$urlBase/${SignalStore.settings().language}.json" - } - - for (index in 0 until localeList.size()) { - val locale: Locale = localeList.get(index) - if (locale.language.isNotEmpty()) { - if (locale.country.isNotEmpty()) { - potentialNoteUrls += "$urlBase/${locale.language}_${locale.country}.json" - } - potentialNoteUrls += "$urlBase/${locale.language}.json" - } - } - - potentialNoteUrls += "$urlBase/en.json" - - for (potentialUrl: String in potentialNoteUrls) { - val translationJson: ServiceResponse = S3.getAndVerifyObject(potentialUrl, TranslatedReleaseNote::class.java) - - if (translationJson.result.isPresent) { - return FullReleaseNote(releaseNote, translationJson.result.get()) - } else if (translationJson.status != 404 && translationJson.executionError.orElse(null) !is S3.Md5FailureException) { - throw RetryLaterException() - } - } - - return null - } - - override fun onShouldRetry(e: Exception): Boolean { - return e is RetryLaterException || e is IOException - } - - data class FullReleaseNote(val releaseNote: ReleaseNote, val translation: TranslatedReleaseNote) - - data class ReleaseNotes(@JsonProperty val announcements: List) - - data class ReleaseNote( - @JsonProperty val uuid: String, - @JsonProperty val countries: String?, - @JsonProperty val androidMinVersion: String?, - @JsonProperty val link: String?, - @JsonProperty val ctaId: String? - ) - - data class TranslatedReleaseNote( - @JsonProperty val uuid: String, - @JsonProperty val image: String?, - @JsonProperty val imageWidth: String?, - @JsonProperty val imageHeight: String?, - @JsonProperty val linkText: String?, - @JsonProperty val title: String, - @JsonProperty val body: String, - @JsonProperty val callToActionText: String?, - ) - - class Factory : Job.Factory { - override fun create(parameters: Parameters, data: Data): RetrieveReleaseChannelJob { - return RetrieveReleaseChannelJob(data.getBoolean(KEY_FORCE), parameters) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveRemoteAnnouncementsJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveRemoteAnnouncementsJob.kt new file mode 100644 index 0000000000..85c6e20cd4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveRemoteAnnouncementsJob.kt @@ -0,0 +1,409 @@ +package org.thoughtcrime.securesms.jobs + +import androidx.core.os.LocaleListCompat +import com.fasterxml.jackson.annotation.JsonProperty +import org.signal.core.util.Hex +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.RemoteMegaphoneDatabase +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord +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.notifications.v2.NotificationThread +import org.thoughtcrime.securesms.providers.BlobProvider +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.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 RetrieveRemoteAnnouncementsJob 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(RetrieveRemoteAnnouncementsJob::class.java) + private val RETRIEVE_FREQUENCY = TimeUnit.DAYS.toMillis(3) + + @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 = RetrieveRemoteAnnouncementsJob( + 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 + + 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 (!force && System.currentTimeMillis() < values.nextScheduledCheck) { + Log.i(TAG, "Too soon to check for updated release notes") + 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 -> fetchManifest(manifestMd5) + } + + values.previousManifestMd5 = manifestMd5 + values.nextScheduledCheck = System.currentTimeMillis() + RETRIEVE_FREQUENCY + } + + private fun fetchManifest(manifestMd5: ByteArray) { + Log.i(TAG, "Updating release notes to ${Hex.toStringCondensed(manifestMd5)}") + + val allReleaseNotes: ReleaseNotes? = S3.getAndVerifyObject(MANIFEST, ReleaseNotes::class.java, manifestMd5).result.orElse(null) + + if (allReleaseNotes != null) { + updateReleaseNotes(allReleaseNotes.announcements) + updateMegaphones(allReleaseNotes.megaphones ?: emptyList()) + } else { + Log.w(TAG, "Unable to retrieve manifest json") + } + } + + @Suppress("UsePropertyAccessSyntax") + private fun updateReleaseNotes(announcements: List) { + val values = SignalStore.releaseChannelValues() + + if (Recipient.resolved(values.releaseChannelRecipientId!!).isBlocked) { + Log.i(TAG, "Release channel is blocked, do not bother with updates") + values.highestVersionNoteReceived = announcements.mapNotNull { it.androidMinVersion?.toIntOrNull() }.maxOrNull() ?: values.highestVersionNoteReceived + return + } + + if (!values.hasMetConversationRequirement) { + if ((SignalDatabase.threads.getArchivedConversationListCount() + SignalDatabase.threads.getUnarchivedConversationListCount()) < 6) { + Log.i(TAG, "User does not have enough conversations to show release channel") + values.nextScheduledCheck = System.currentTimeMillis() + RETRIEVE_FREQUENCY + return + } else { + values.hasMetConversationRequirement = true + } + } + + val resolvedNotes: List = announcements + .asSequence() + .filter { + val minVersion = it.androidMinVersion?.toIntOrNull() + if (minVersion != null) { + minVersion > values.highestVersionNoteReceived && minVersion <= BuildConfig.CANONICAL_VERSION_CODE + } else { + false + } + } + .filter { it.countries == null || LocaleFeatureFlags.shouldShowReleaseNote(it.uuid, it.countries) } + .sortedBy { it.androidMinVersion!!.toInt() } + .map { resolveReleaseNote(it) } + .toList() + + 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, + imageWidth = note.translation.imageWidth?.toIntOrNull() ?: 0, + imageHeight = note.translation.imageHeight?.toIntOrNull() ?: 0 + ) + + 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, NotificationThread.forConversation(insertResult.threadId)) + TrimThreadJob.enqueueAsync(insertResult.threadId) + + highestVersion = max(highestVersion, note.releaseNote.androidMinVersion!!.toInt()) + } + } + + values.highestVersionNoteReceived = highestVersion + } + + private fun updateMegaphones(megaphones: List) { + val resolvedMegaphones: List = megaphones + .asSequence() + .filter { it.androidMinVersion != null } + .map { resolveMegaphone(it) } + .toList() + + if (resolvedMegaphones.any { it == null }) { + Log.w(TAG, "Some megaphones did not resolve, will retry later.") + throw RetryLaterException() + } + + val manifestMegaphones: MutableSet = mutableSetOf() + val existingMegaphones: Map = SignalDatabase.remoteMegaphones.getAll().associateBy { it.uuid } + + resolvedMegaphones + .filterNotNull() + .forEach { megaphone -> + val uuid = megaphone.remoteMegaphone.uuid + manifestMegaphones += uuid + if (existingMegaphones.contains(uuid)) { + SignalDatabase.remoteMegaphones.update( + uuid = uuid, + priority = megaphone.remoteMegaphone.priority, + countries = megaphone.remoteMegaphone.countries, + title = megaphone.translation.title, + body = megaphone.translation.body, + primaryActionText = megaphone.translation.primaryCtaText, + secondaryActionText = megaphone.translation.secondaryCtaText + ) + } else { + val record = RemoteMegaphoneRecord( + uuid = uuid, + priority = megaphone.remoteMegaphone.priority, + countries = megaphone.remoteMegaphone.countries, + minimumVersion = megaphone.remoteMegaphone.androidMinVersion!!.toInt(), + doNotShowBefore = megaphone.remoteMegaphone.dontShowBeforeEpochSeconds?.let { TimeUnit.SECONDS.toMillis(it) } ?: 0, + doNotShowAfter = megaphone.remoteMegaphone.dontShowAfterEpochSeconds?.let { TimeUnit.SECONDS.toMillis(it) } ?: Long.MAX_VALUE, + showForNumberOfDays = megaphone.remoteMegaphone.showForNumberOfDays ?: 0, + conditionalId = megaphone.remoteMegaphone.conditionalId, + primaryActionId = RemoteMegaphoneRecord.ActionId.from(megaphone.remoteMegaphone.primaryCtaId), + secondaryActionId = RemoteMegaphoneRecord.ActionId.from(megaphone.remoteMegaphone.secondaryCtaId), + imageUrl = megaphone.translation.image, + title = megaphone.translation.title, + body = megaphone.translation.body, + primaryActionText = megaphone.translation.primaryCtaText, + secondaryActionText = megaphone.translation.secondaryCtaText + ) + + SignalDatabase.remoteMegaphones.insert(record) + + if (record.imageUrl != null) { + ApplicationDependencies.getJobManager().add(FetchRemoteMegaphoneImageJob(record.uuid, record.imageUrl)) + } + } + } + + val megaphonesToDelete = existingMegaphones + .filterKeys { !manifestMegaphones.contains(it) } + .filterValues { it.minimumVersion != RemoteMegaphoneDatabase.VERSION_FINISHED } + + if (megaphonesToDelete.isNotEmpty()) { + Log.i(TAG, "Clearing ${megaphonesToDelete.size} stale megaphones ${megaphonesToDelete.keys}") + for ((uuid, megaphone) in megaphonesToDelete) { + if (megaphone.imageUri != null) { + BlobProvider.getInstance().delete(context, megaphone.imageUri) + } + SignalDatabase.remoteMegaphones.clear(uuid) + } + } + } + + private fun resolveReleaseNote(releaseNote: ReleaseNote): FullReleaseNote? { + val potentialNoteUrls = "$BASE_RELEASE_NOTE/${releaseNote.uuid}".getLocaleUrls() + for (potentialUrl: String in potentialNoteUrls) { + val translationJson: ServiceResponse = S3.getAndVerifyObject(potentialUrl, TranslatedReleaseNote::class.java) + + if (translationJson.result.isPresent) { + return FullReleaseNote(releaseNote, translationJson.result.get()) + } else if (translationJson.status != 404 && translationJson.executionError.orElse(null) !is S3.Md5FailureException) { + throw RetryLaterException() + } + } + + return null + } + + private fun resolveMegaphone(remoteMegaphone: RemoteMegaphone): FullRemoteMegaphone? { + val potentialNoteUrls = "$BASE_RELEASE_NOTE/${remoteMegaphone.uuid}".getLocaleUrls() + for (potentialUrl: String in potentialNoteUrls) { + val translationJson: ServiceResponse = S3.getAndVerifyObject(potentialUrl, TranslatedRemoteMegaphone::class.java) + + if (translationJson.result.isPresent) { + return FullRemoteMegaphone(remoteMegaphone, translationJson.result.get()) + } else if (translationJson.status != 404 && translationJson.executionError.orElse(null) !is S3.Md5FailureException) { + throw RetryLaterException() + } + } + + return null + } + + override fun onShouldRetry(e: Exception): Boolean { + return e is RetryLaterException || e is IOException + } + + private fun String.getLocaleUrls(): List { + val localeList: LocaleListCompat = LocaleListCompat.getDefault() + + val potentialNoteUrls = mutableListOf() + + if (SignalStore.settings().language != "zz") { + potentialNoteUrls += "$this/${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 += "$this/${locale.language}_${locale.country}.json" + } + potentialNoteUrls += "$this/${locale.language}.json" + } + } + + potentialNoteUrls += "$this/en.json" + + return potentialNoteUrls + } + + data class FullReleaseNote(val releaseNote: ReleaseNote, val translation: TranslatedReleaseNote) + + data class FullRemoteMegaphone(val remoteMegaphone: RemoteMegaphone, val translation: TranslatedRemoteMegaphone) + + data class ReleaseNotes(@JsonProperty val announcements: List, @JsonProperty val megaphones: List?) + + data class ReleaseNote( + @JsonProperty val uuid: String, + @JsonProperty val countries: String?, + @JsonProperty val androidMinVersion: String?, + @JsonProperty val link: String?, + @JsonProperty val ctaId: String? + ) + + data class RemoteMegaphone( + @JsonProperty val uuid: String, + @JsonProperty val priority: Long, + @JsonProperty val countries: String?, + @JsonProperty val androidMinVersion: String?, + @JsonProperty val dontShowBeforeEpochSeconds: Long?, + @JsonProperty val dontShowAfterEpochSeconds: Long?, + @JsonProperty val showForNumberOfDays: Long?, + @JsonProperty val conditionalId: String?, + @JsonProperty val primaryCtaId: String?, + @JsonProperty val secondaryCtaId: String? + ) + + data class TranslatedReleaseNote( + @JsonProperty val uuid: String, + @JsonProperty val image: String?, + @JsonProperty val imageWidth: String?, + @JsonProperty val imageHeight: String?, + @JsonProperty val linkText: String?, + @JsonProperty val title: String, + @JsonProperty val body: String, + @JsonProperty val callToActionText: String?, + ) + + data class TranslatedRemoteMegaphone( + @JsonProperty val uuid: String, + @JsonProperty val image: String?, + @JsonProperty val title: String, + @JsonProperty val body: String, + @JsonProperty val primaryCtaText: String?, + @JsonProperty val secondaryCtaText: String?, + ) + + class Factory : Job.Factory { + override fun create(parameters: Parameters, data: Data): RetrieveRemoteAnnouncementsJob { + return RetrieveRemoteAnnouncementsJob(data.getBoolean(KEY_FORCE), parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ReleaseChannelValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ReleaseChannelValues.kt index b38312533d..ce86d2c621 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ReleaseChannelValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ReleaseChannelValues.kt @@ -9,6 +9,7 @@ internal class ReleaseChannelValues(store: KeyValueStore) : SignalStoreValues(st 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" + private const val KEY_MET_CONVERSATION_REQUIREMENT = "releasechannel.met_conversation_requirement" } override fun onFirstEverAppLaunch() = Unit @@ -34,4 +35,5 @@ internal class ReleaseChannelValues(store: KeyValueStore) : SignalStoreValues(st 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) + var hasMetConversationRequirement by booleanValue(KEY_MET_CONVERSATION_REQUIREMENT, false) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java index f6092406e4..1baa1be3ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java @@ -71,23 +71,23 @@ public class BasicMegaphoneView extends FrameLayout { image.setVisibility(GONE); } - if (megaphone.getTitle() != 0) { + if (megaphone.getTitle().hasText()) { titleText.setVisibility(VISIBLE); - titleText.setText(megaphone.getTitle()); + titleText.setText(megaphone.getTitle().resolve(getContext())); } else { titleText.setVisibility(GONE); } - if (megaphone.getBody() != 0) { + if (megaphone.getBody().hasText()) { bodyText.setVisibility(VISIBLE); - bodyText.setText(megaphone.getBody()); + bodyText.setText(megaphone.getBody().resolve(getContext())); } else { bodyText.setVisibility(GONE); } if (megaphone.hasButton()) { actionButton.setVisibility(VISIBLE); - actionButton.setText(megaphone.getButtonText()); + actionButton.setText(megaphone.getButtonText().resolve(getContext())); actionButton.setOnClickListener(v -> { if (megaphone.getButtonClickListener() != null) { megaphone.getButtonClickListener().onEvent(megaphone, megaphoneListener); @@ -109,7 +109,7 @@ public class BasicMegaphoneView extends FrameLayout { } }); } else { - secondaryButton.setText(megaphone.getSecondaryButtonText()); + secondaryButton.setText(megaphone.getSecondaryButtonText().resolve(getContext())); secondaryButton.setOnClickListener(v -> { if (megaphone.getSecondaryButtonClickListener() != null) { megaphone.getSecondaryButtonClickListener().onEvent(megaphone, megaphoneListener); diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java index 027dabe1bf..16fb641edb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java @@ -20,15 +20,15 @@ public class Megaphone { private final Event event; private final Style style; private final boolean canSnooze; - private final int titleRes; - private final int bodyRes; + private final MegaphoneText titleText; + private final MegaphoneText bodyText; private final int imageRes; private final int lottieRes; private final GlideRequest imageRequest; - private final int buttonTextRes; + private final MegaphoneText buttonText; private final EventListener buttonListener; private final EventListener snoozeListener; - private final int secondaryButtonTextRes; + private final MegaphoneText secondaryButtonText; private final EventListener secondaryButtonListener; private final EventListener onVisibleListener; @@ -36,15 +36,15 @@ public class Megaphone { this.event = builder.event; this.style = builder.style; this.canSnooze = builder.canSnooze; - this.titleRes = builder.titleRes; - this.bodyRes = builder.bodyRes; + this.titleText = builder.titleText; + this.bodyText = builder.bodyText; this.imageRes = builder.imageRes; this.lottieRes = builder.lottieRes; this.imageRequest = builder.imageRequest; - this.buttonTextRes = builder.buttonTextRes; + this.buttonText = builder.buttonText; this.buttonListener = builder.buttonListener; this.snoozeListener = builder.snoozeListener; - this.secondaryButtonTextRes = builder.secondaryButtonTextRes; + this.secondaryButtonText = builder.secondaryButtonText; this.secondaryButtonListener = builder.secondaryButtonListener; this.onVisibleListener = builder.onVisibleListener; } @@ -61,12 +61,12 @@ public class Megaphone { return style; } - public @StringRes int getTitle() { - return titleRes; + public @NonNull MegaphoneText getTitle() { + return titleText; } - public @StringRes int getBody() { - return bodyRes; + public @NonNull MegaphoneText getBody() { + return bodyText; } public @RawRes int getLottieRes() { @@ -81,12 +81,12 @@ public class Megaphone { return imageRequest; } - public @StringRes int getButtonText() { - return buttonTextRes; + public @Nullable MegaphoneText getButtonText() { + return buttonText; } public boolean hasButton() { - return buttonTextRes != 0; + return buttonText != null && buttonText.hasText(); } public @Nullable EventListener getButtonClickListener() { @@ -97,12 +97,12 @@ public class Megaphone { return snoozeListener; } - public @StringRes int getSecondaryButtonText() { - return secondaryButtonTextRes; + public @Nullable MegaphoneText getSecondaryButtonText() { + return secondaryButtonText; } public boolean hasSecondaryButton() { - return secondaryButtonTextRes != 0; + return secondaryButtonText != null && secondaryButtonText.hasText(); } public @Nullable EventListener getSecondaryButtonClickListener() { @@ -119,19 +119,18 @@ public class Megaphone { private final Style style; private boolean canSnooze; - private int titleRes; - private int bodyRes; + private MegaphoneText titleText; + private MegaphoneText bodyText; private int imageRes; private int lottieRes; private GlideRequest imageRequest; - private int buttonTextRes; + private MegaphoneText buttonText; private EventListener buttonListener; private EventListener snoozeListener; - private int secondaryButtonTextRes; + private MegaphoneText secondaryButtonText; private EventListener secondaryButtonListener; private EventListener onVisibleListener; - public Builder(@NonNull Event event, @NonNull Style style) { this.event = event; this.style = style; @@ -144,18 +143,28 @@ public class Megaphone { } public @NonNull Builder disableSnooze() { - this.canSnooze = false; + this.canSnooze = false; this.snoozeListener = null; return this; } public @NonNull Builder setTitle(@StringRes int titleRes) { - this.titleRes = titleRes; + this.titleText = MegaphoneText.from(titleRes); + return this; + } + + public @NonNull Builder setTitle(@Nullable String title) { + this.titleText = MegaphoneText.from(title); return this; } public @NonNull Builder setBody(@StringRes int bodyRes) { - this.bodyRes = bodyRes; + this.bodyText = MegaphoneText.from(bodyRes); + return this; + } + + public @NonNull Builder setBody(String body) { + this.bodyText = MegaphoneText.from(body); return this; } @@ -175,13 +184,25 @@ public class Megaphone { } public @NonNull Builder setActionButton(@StringRes int buttonTextRes, @NonNull EventListener listener) { - this.buttonTextRes = buttonTextRes; + this.buttonText = MegaphoneText.from(buttonTextRes); + this.buttonListener = listener; + return this; + } + + public @NonNull Builder setActionButton(@NonNull String buttonText, @NonNull EventListener listener) { + this.buttonText = MegaphoneText.from(buttonText); this.buttonListener = listener; return this; } public @NonNull Builder setSecondaryButton(@StringRes int secondaryButtonTextRes, @NonNull EventListener listener) { - this.secondaryButtonTextRes = secondaryButtonTextRes; + this.secondaryButtonText = MegaphoneText.from(secondaryButtonTextRes); + this.secondaryButtonListener = listener; + return this; + } + + public @NonNull Builder setSecondaryButton(@NonNull String secondaryButtonText, @NonNull EventListener listener) { + this.secondaryButtonText = MegaphoneText.from(secondaryButtonText); this.secondaryButtonListener = listener; return this; } @@ -197,10 +218,14 @@ public class Megaphone { } enum Style { - /** Specialized style for onboarding. */ + /** + * Specialized style for onboarding. + */ ONBOARDING, - /** Basic bottom of the screen megaphone with optional snooze and action buttons. */ + /** + * Basic bottom of the screen megaphone with optional snooze and action buttons. + */ BASIC, /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneText.kt b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneText.kt new file mode 100644 index 0000000000..5a3a40e6bb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneText.kt @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.megaphone + +import android.content.Context +import androidx.annotation.StringRes + +/** + * Allows setting of megaphone text by string resource or string literal. + */ +data class MegaphoneText(@StringRes private val stringRes: Int = 0, private val string: String? = null) { + @get:JvmName("hasText") val hasText = stringRes != 0 || string != null + + fun resolve(context: Context): String? { + return if (stringRes != 0) context.getString(stringRes) else string + } + + companion object { + @JvmStatic + fun from(@StringRes stringRes: Int): MegaphoneText = MegaphoneText(stringRes) + + @JvmStatic + fun from(string: String): MegaphoneText = MegaphoneText(string = string) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java index 9768c6e274..af734ad2c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java @@ -11,12 +11,14 @@ import androidx.annotation.WorkerThread; import com.annimon.stream.Stream; +import org.signal.core.util.SetUtil; import org.signal.core.util.TranslationDetection; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.badges.models.Badge; import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity; import org.thoughtcrime.securesms.database.model.MegaphoneRecord; +import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -24,6 +26,7 @@ import org.thoughtcrime.securesms.lock.SignalPinReminderDialog; import org.thoughtcrime.securesms.lock.SignalPinReminders; import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity; import org.thoughtcrime.securesms.lock.v2.KbsMigrationActivity; +import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.profiles.manage.ManageProfileActivity; @@ -31,7 +34,6 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.LocaleFeatureFlags; import org.thoughtcrime.securesms.util.PlayServicesUtil; -import org.signal.core.util.SetUtil; import org.thoughtcrime.securesms.util.VersionTracker; import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper; import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity; @@ -106,6 +108,7 @@ public final class Megaphones { put(Event.ONBOARDING, shouldShowOnboardingMegaphone(context) ? ALWAYS : NEVER); put(Event.TURN_OFF_CENSORSHIP_CIRCUMVENTION, shouldShowTurnOffCircumventionMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(7)) : NEVER); put(Event.DONATE_Q2_2022, shouldShowDonateMegaphone(context, Event.DONATE_Q2_2022, records) ? ShowForDurationSchedule.showForDays(7) : NEVER); + put(Event.REMOTE_MEGAPHONE, shouldShowRemoteMegaphone(records) ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(3)) : NEVER); put(Event.PIN_REMINDER, new SignalPinReminderSchedule()); // Feature-introduction megaphones should *probably* be added below this divider @@ -133,6 +136,8 @@ public final class Megaphones { return buildDonateQ2Megaphone(context); case TURN_OFF_CENSORSHIP_CIRCUMVENTION: return buildTurnOffCircumventionMegaphone(context); + case REMOTE_MEGAPHONE: + return buildRemoteMegaphone(context); default: throw new IllegalArgumentException("Event not handled!"); } @@ -292,6 +297,44 @@ public final class Megaphones { .build(); } + private static @NonNull Megaphone buildRemoteMegaphone(@NonNull Context context) { + RemoteMegaphoneRecord record = RemoteMegaphoneRepository.getRemoteMegaphoneToShow(); + + if (record != null) { + Megaphone.Builder builder = new Megaphone.Builder(Event.REMOTE_MEGAPHONE, Megaphone.Style.BASIC) + .setTitle(record.getTitle()) + .setBody(record.getBody()); + + if (record.getImageUri() != null) { + builder.setImageRequest(GlideApp.with(context).asDrawable().load(record.getImageUri())); + } + + if (record.hasPrimaryAction()) { + //noinspection ConstantConditions + builder.setActionButton(record.getPrimaryActionText(), (megaphone, controller) -> { + RemoteMegaphoneRepository.getAction(Objects.requireNonNull(record.getPrimaryActionId())) + .run(context, controller, record); + }); + } + + if (record.hasSecondaryAction()) { + //noinspection ConstantConditions + builder.setSecondaryButton(record.getSecondaryActionText(), (megaphone, controller) -> { + RemoteMegaphoneRepository.getAction(Objects.requireNonNull(record.getSecondaryActionId())) + .run(context, controller, record); + }); + } + + builder.setOnVisibleListener((megaphone, controller) -> { + RemoteMegaphoneRepository.markShown(record.getUuid()); + }); + + return builder.build(); + } else { + throw new IllegalStateException("No record to show"); + } + } + private static boolean shouldShowDonateMegaphone(@NonNull Context context, @NonNull Event event, @NonNull Map records) { long timeSinceLastDonatePrompt = timeSinceLastDonatePrompt(event, records); @@ -349,6 +392,12 @@ public final class Megaphones { return true; } + @WorkerThread + private static boolean shouldShowRemoteMegaphone(@NonNull Map records) { + boolean canShowLocalDonate = timeSinceLastDonatePrompt(Event.REMOTE_MEGAPHONE, records) > MIN_TIME_BETWEEN_DONATE_MEGAPHONES; + return RemoteMegaphoneRepository.hasRemoteMegaphoneToShow(canShowLocalDonate); + } + /** * Unfortunately lastSeen is only set today upon snoozing, which never happens to donate prompts. * So we use firstVisible as a proxy. @@ -376,7 +425,8 @@ public final class Megaphones { ADD_A_PROFILE_PHOTO("add_a_profile_photo"), BECOME_A_SUSTAINER("become_a_sustainer"), DONATE_Q2_2022("donate_q2_2022"), - TURN_OFF_CENSORSHIP_CIRCUMVENTION("turn_off_censorship_circumvention"); + TURN_OFF_CENSORSHIP_CIRCUMVENTION("turn_off_censorship_circumvention"), + REMOTE_MEGAPHONE("remote_megaphone"); private final String key; diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java index dc79f0ec92..cbcba6bb84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java @@ -66,16 +66,16 @@ public class PopupMegaphoneView extends FrameLayout { image.setVisibility(GONE); } - if (megaphone.getTitle() != 0) { + if (megaphone.getTitle().hasText()) { titleText.setVisibility(VISIBLE); - titleText.setText(megaphone.getTitle()); + titleText.setText(megaphone.getTitle().resolve(getContext())); } else { titleText.setVisibility(GONE); } - if (megaphone.getBody() != 0) { + if (megaphone.getBody().hasText()) { bodyText.setVisibility(VISIBLE); - bodyText.setText(megaphone.getBody()); + bodyText.setText(megaphone.getBody().resolve(getContext())); } else { bodyText.setVisibility(GONE); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/RemoteMegaphoneRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/megaphone/RemoteMegaphoneRepository.kt new file mode 100644 index 0000000000..99b66875f9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/RemoteMegaphoneRepository.kt @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.megaphone + +import android.app.Application +import android.content.Context +import androidx.annotation.AnyThread +import androidx.annotation.WorkerThread +import org.signal.core.util.concurrent.SignalExecutors +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity +import org.thoughtcrime.securesms.database.RemoteMegaphoneDatabase +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord +import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord.ActionId +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.megaphone.RemoteMegaphoneRepository.Action +import org.thoughtcrime.securesms.providers.BlobProvider +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.LocaleFeatureFlags +import org.thoughtcrime.securesms.util.PlayServicesUtil +import org.thoughtcrime.securesms.util.VersionTracker +import java.util.Objects + +/** + * Access point for interacting with Remote Megaphones. + */ +object RemoteMegaphoneRepository { + + private val db: RemoteMegaphoneDatabase = SignalDatabase.remoteMegaphones + private val context: Application = ApplicationDependencies.getApplication() + + private val snooze: Action = Action { _, controller, _ -> controller.onMegaphoneSnooze(Megaphones.Event.REMOTE_MEGAPHONE) } + + private val finish: Action = Action { context, controller, remote -> + if (remote.imageUri != null) { + BlobProvider.getInstance().delete(context, remote.imageUri) + } + controller.onMegaphoneSnooze(Megaphones.Event.REMOTE_MEGAPHONE) + db.markFinished(remote.uuid) + } + + private val donate: Action = Action { context, controller, remote -> + controller.onMegaphoneNavigationRequested(AppSettingsActivity.subscriptions(context)) + finish.run(context, controller, remote) + } + + private val actions = mapOf( + ActionId.SNOOZE.id to snooze, + ActionId.FINISH.id to finish, + ActionId.DONATE.id to donate + ) + + @WorkerThread + @JvmStatic + fun hasRemoteMegaphoneToShow(canShowLocalDonate: Boolean): Boolean { + val record = getRemoteMegaphoneToShow() + + return if (record == null) { + false + } else if (record.primaryActionId?.isDonateAction == true) { + canShowLocalDonate + } else { + true + } + } + + @WorkerThread + @JvmStatic + fun getRemoteMegaphoneToShow(): RemoteMegaphoneRecord? { + return db.getPotentialMegaphonesAndClearOld() + .asSequence() + .filter { it.imageUrl == null || it.imageUri != null } + .filter { it.countries == null || LocaleFeatureFlags.shouldShowReleaseNote(it.uuid, it.countries) } + .filter { it.conditionalId == null || checkCondition(it.conditionalId) } + .firstOrNull() + } + + @AnyThread + @JvmStatic + fun getAction(action: ActionId): Action { + return actions[action.id] ?: finish + } + + @AnyThread + @JvmStatic + fun markShown(uuid: String) { + SignalExecutors.BOUNDED_IO.execute { + db.markShown(uuid) + } + } + + private fun checkCondition(conditionalId: String): Boolean { + return when (conditionalId) { + "standard_donate" -> shouldShowDonateMegaphone() + else -> false + } + } + + private fun shouldShowDonateMegaphone(): Boolean { + return VersionTracker.getDaysSinceFirstInstalled(context) >= 7 && + PlayServicesUtil.getPlayServicesStatus(context) == PlayServicesUtil.PlayServicesStatus.SUCCESS && + Recipient.self() + .badges + .stream() + .filter { obj: Badge? -> Objects.nonNull(obj) } + .noneMatch { (_, category): Badge -> category === Badge.Category.Donor } + } + + fun interface Action { + fun run(context: Context, controller: MegaphoneActionController, remoteMegaphone: RemoteMegaphoneRecord) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java index e39991bf96..7a0a002ad0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java @@ -482,7 +482,6 @@ public class BlobProvider { * Create a blob that will exist for multiple app sessions. It is the caller's responsibility to * eventually call {@link BlobProvider#delete(Context, Uri)} when the blob is no longer in use. */ - @Deprecated @WorkerThread public Uri createForMultipleSessionsOnDisk(@NonNull Context context) throws IOException { return writeBlobSpecToDisk(context, buildBlobSpec(StorageType.MULTI_SESSION_DISK)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/VersionTracker.java b/app/src/main/java/org/thoughtcrime/securesms/util/VersionTracker.java index 2703435753..43a7a5360d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/VersionTracker.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/VersionTracker.java @@ -8,7 +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.jobs.RetrieveRemoteAnnouncementsJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import java.util.concurrent.TimeUnit; @@ -29,7 +29,7 @@ public class VersionTracker { Log.i(TAG, "Upgraded from " + lastVersionCode + " to " + currentVersionCode); SignalStore.misc().clearClientDeprecated(); ApplicationDependencies.getJobManager().add(new RemoteConfigRefreshJob()); - RetrieveReleaseChannelJob.enqueue(true); + RetrieveRemoteAnnouncementsJob.enqueue(true); LocalMetrics.getInstance().clear(); } diff --git a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt index 62464f808c..a597c4936b 100644 --- a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt @@ -55,4 +55,14 @@ fun Cursor.isNull(column: String): Boolean { return CursorUtil.isNull(this, column) } +inline fun Cursor.readToList(mapper: (Cursor) -> T): List { + val list = mutableListOf() + use { + while (moveToNext()) { + list += mapper(this) + } + } + return list +} + fun Boolean.toInt(): Int = if (this) 1 else 0