Add remote megaphone.

This commit is contained in:
Cody Henthorne 2022-05-11 14:33:54 -04:00 committed by Alex Hart
parent 820277800b
commit bb963f9210
20 changed files with 1069 additions and 322 deletions

View file

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

View file

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

View file

@ -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<RemoteMegaphoneRecord> {
return readableDatabase
.select()
.from(TABLE_NAME)
.run()
.readToList { it.toRemoteMegaphoneRecord() }
}
fun getPotentialMegaphonesAndClearOld(now: Long = System.currentTimeMillis()): List<RemoteMegaphoneRecord> {
val records: List<RemoteMegaphoneRecord> = 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<RemoteMegaphoneRecord> = 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)
)
}
}

View file

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

View file

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

View file

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

View file

@ -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<FetchRemoteMegaphoneImageJob> {
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"
}
}

View file

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

View file

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

View file

@ -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<ReleaseNote>) {
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<FullReleaseNote?> = 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<RemoteMegaphone>) {
val resolvedMegaphones: List<FullRemoteMegaphone?> = 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<String> = mutableSetOf()
val existingMegaphones: Map<String, RemoteMegaphoneRecord> = 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<TranslatedReleaseNote> = 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<TranslatedRemoteMegaphone> = 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<String> {
val localeList: LocaleListCompat = LocaleListCompat.getDefault()
val potentialNoteUrls = mutableListOf<String>()
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<ReleaseNote>, @JsonProperty val megaphones: List<RemoteMegaphone>?)
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<RetrieveRemoteAnnouncementsJob> {
override fun create(parameters: Parameters, data: Data): RetrieveRemoteAnnouncementsJob {
return RetrieveRemoteAnnouncementsJob(data.getBoolean(KEY_FORCE), parameters)
}
}
}

View file

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

View file

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

View file

@ -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<Drawable> 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<Drawable> 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,
/**

View file

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

View file

@ -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<Event, MegaphoneRecord> records) {
long timeSinceLastDonatePrompt = timeSinceLastDonatePrompt(event, records);
@ -349,6 +392,12 @@ public final class Megaphones {
return true;
}
@WorkerThread
private static boolean shouldShowRemoteMegaphone(@NonNull Map<Event, MegaphoneRecord> 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;

View file

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

View file

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

View file

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

View file

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

View file

@ -55,4 +55,14 @@ fun Cursor.isNull(column: String): Boolean {
return CursorUtil.isNull(this, column)
}
inline fun <T> Cursor.readToList(mapper: (Cursor) -> T): List<T> {
val list = mutableListOf<T>()
use {
while (moveToNext()) {
list += mapper(this)
}
}
return list
}
fun Boolean.toInt(): Int = if (this) 1 else 0