Add remote megaphone.
This commit is contained in:
parent
820277800b
commit
bb963f9210
20 changed files with 1069 additions and 322 deletions
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue