mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 09:49:30 +01:00
Add remote megaphone snooze capabilities.
This commit is contained in:
@@ -6,7 +6,10 @@ import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.core.net.toUri
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.signal.core.util.delete
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
@@ -24,6 +27,8 @@ import java.util.concurrent.TimeUnit
|
||||
class RemoteMegaphoneDatabase(context: Context, databaseHelper: SignalDatabase) : Database(context, databaseHelper) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RemoteMegaphoneDatabase::class.java)
|
||||
|
||||
private const val TABLE_NAME = "remote_megaphone"
|
||||
private const val ID = "_id"
|
||||
private const val UUID = "uuid"
|
||||
@@ -44,6 +49,10 @@ class RemoteMegaphoneDatabase(context: Context, databaseHelper: SignalDatabase)
|
||||
private const val SECONDARY_ACTION_TEXT = "secondary_action_text"
|
||||
private const val SHOWN_AT = "shown_at"
|
||||
private const val FINISHED_AT = "finished_at"
|
||||
private const val PRIMARY_ACTION_DATA = "primary_action_data"
|
||||
private const val SECONDARY_ACTION_DATA = "secondary_action_data"
|
||||
private const val SNOOZED_AT = "snoozed_at"
|
||||
private const val SEEN_COUNT = "seen_count"
|
||||
|
||||
val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
@@ -65,7 +74,11 @@ class RemoteMegaphoneDatabase(context: Context, databaseHelper: SignalDatabase)
|
||||
$PRIMARY_ACTION_TEXT TEXT,
|
||||
$SECONDARY_ACTION_TEXT TEXT,
|
||||
$SHOWN_AT INTEGER DEFAULT 0,
|
||||
$FINISHED_AT INTEGER DEFAULT 0
|
||||
$FINISHED_AT INTEGER DEFAULT 0,
|
||||
$PRIMARY_ACTION_DATA TEXT DEFAULT NULL,
|
||||
$SECONDARY_ACTION_DATA TEXT DEFAULT NULL,
|
||||
$SNOOZED_AT INTEGER DEFAULT 0,
|
||||
$SEEN_COUNT INTEGER DEFAULT 0
|
||||
)
|
||||
""".trimIndent()
|
||||
|
||||
@@ -99,7 +112,7 @@ class RemoteMegaphoneDatabase(context: Context, databaseHelper: SignalDatabase)
|
||||
.readToList { it.toRemoteMegaphoneRecord() }
|
||||
}
|
||||
|
||||
fun getPotentialMegaphonesAndClearOld(now: Long = System.currentTimeMillis()): List<RemoteMegaphoneRecord> {
|
||||
fun getPotentialMegaphonesAndClearOld(now: Long): List<RemoteMegaphoneRecord> {
|
||||
val records: List<RemoteMegaphoneRecord> = readableDatabase
|
||||
.select()
|
||||
.from(TABLE_NAME)
|
||||
@@ -148,6 +161,17 @@ class RemoteMegaphoneDatabase(context: Context, databaseHelper: SignalDatabase)
|
||||
.run()
|
||||
}
|
||||
|
||||
fun snooze(remote: RemoteMegaphoneRecord) {
|
||||
writableDatabase
|
||||
.update(TABLE_NAME)
|
||||
.values(
|
||||
SEEN_COUNT to remote.seenCount + 1,
|
||||
SNOOZED_AT to System.currentTimeMillis()
|
||||
)
|
||||
.where("$UUID = ?", remote.uuid)
|
||||
.run()
|
||||
}
|
||||
|
||||
fun clearImageUrl(uuid: String) {
|
||||
writableDatabase
|
||||
.update(TABLE_NAME)
|
||||
@@ -192,7 +216,11 @@ class RemoteMegaphoneDatabase(context: Context, databaseHelper: SignalDatabase)
|
||||
BODY to body,
|
||||
PRIMARY_ACTION_TEXT to primaryActionText,
|
||||
SECONDARY_ACTION_TEXT to secondaryActionText,
|
||||
FINISHED_AT to finishedAt
|
||||
FINISHED_AT to finishedAt,
|
||||
PRIMARY_ACTION_DATA to primaryActionData?.toString(),
|
||||
SECONDARY_ACTION_DATA to secondaryActionData?.toString(),
|
||||
SNOOZED_AT to snoozedAt,
|
||||
SEEN_COUNT to seenCount
|
||||
)
|
||||
}
|
||||
|
||||
@@ -216,7 +244,24 @@ class RemoteMegaphoneDatabase(context: Context, databaseHelper: SignalDatabase)
|
||||
primaryActionText = requireString(PRIMARY_ACTION_TEXT),
|
||||
secondaryActionText = requireString(SECONDARY_ACTION_TEXT),
|
||||
shownAt = requireLong(SHOWN_AT),
|
||||
finishedAt = requireLong(FINISHED_AT)
|
||||
finishedAt = requireLong(FINISHED_AT),
|
||||
primaryActionData = requireString(PRIMARY_ACTION_DATA).parseJsonObject(),
|
||||
secondaryActionData = requireString(SECONDARY_ACTION_DATA).parseJsonObject(),
|
||||
snoozedAt = requireLong(SNOOZED_AT),
|
||||
seenCount = requireInt(SEEN_COUNT)
|
||||
)
|
||||
}
|
||||
|
||||
private fun String?.parseJsonObject(): JSONObject? {
|
||||
if (this == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
JSONObject(this)
|
||||
} catch (e: JSONException) {
|
||||
Log.w(TAG, "Unable to parse data", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V159_ThreadUnreadSe
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V160_SmsMmsExportedIndexMigration
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V161_StorySendMessageIdIndex
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V162_ThreadUnreadSelfMentionCountFixup
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V163_RemoteMegaphoneSnoozeSupportMigration
|
||||
|
||||
/**
|
||||
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
|
||||
@@ -26,7 +27,7 @@ object SignalDatabaseMigrations {
|
||||
|
||||
val TAG: String = Log.tag(SignalDatabaseMigrations.javaClass)
|
||||
|
||||
const val DATABASE_VERSION = 162
|
||||
const val DATABASE_VERSION = 163
|
||||
|
||||
@JvmStatic
|
||||
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
@@ -85,6 +86,10 @@ object SignalDatabaseMigrations {
|
||||
if (oldVersion < 162) {
|
||||
V162_ThreadUnreadSelfMentionCountFixup.migrate(context, db, oldVersion, newVersion)
|
||||
}
|
||||
|
||||
if (oldVersion < 163) {
|
||||
V163_RemoteMegaphoneSnoozeSupportMigration.migrate(context, db, oldVersion, newVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Add columns needed to track remote megaphone specific snooze rates.
|
||||
*/
|
||||
object V163_RemoteMegaphoneSnoozeSupportMigration : SignalDatabaseMigration {
|
||||
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
if (columnMissing(db, "primary_action_data")) {
|
||||
db.execSQL("ALTER TABLE remote_megaphone ADD COLUMN primary_action_data TEXT DEFAULT NULL")
|
||||
}
|
||||
|
||||
if (columnMissing(db, "secondary_action_data")) {
|
||||
db.execSQL("ALTER TABLE remote_megaphone ADD COLUMN secondary_action_data TEXT DEFAULT NULL")
|
||||
}
|
||||
|
||||
if (columnMissing(db, "snoozed_at")) {
|
||||
db.execSQL("ALTER TABLE remote_megaphone ADD COLUMN snoozed_at INTEGER DEFAULT 0")
|
||||
}
|
||||
|
||||
if (columnMissing(db, "seen_count")) {
|
||||
db.execSQL("ALTER TABLE remote_megaphone ADD COLUMN seen_count INTEGER DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
private fun columnMissing(db: SupportSQLiteDatabase, column: String): Boolean {
|
||||
db.query("PRAGMA table_info(remote_megaphone)", null).use { cursor ->
|
||||
val nameColumnIndex = cursor.getColumnIndexOrThrow("name")
|
||||
while (cursor.moveToNext()) {
|
||||
val name = cursor.getString(nameColumnIndex)
|
||||
if (name == column) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.database.model
|
||||
|
||||
import android.net.Uri
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Represents a Remote Megaphone.
|
||||
@@ -24,7 +25,11 @@ data class RemoteMegaphoneRecord(
|
||||
val primaryActionText: String?,
|
||||
val secondaryActionText: String?,
|
||||
val shownAt: Long = 0,
|
||||
val finishedAt: Long = 0
|
||||
val finishedAt: Long = 0,
|
||||
val primaryActionData: JSONObject? = null,
|
||||
val secondaryActionData: JSONObject? = null,
|
||||
val snoozedAt: Long = 0,
|
||||
val seenCount: Int = 0
|
||||
) {
|
||||
@get:JvmName("hasPrimaryAction")
|
||||
val hasPrimaryAction = primaryActionId != null && primaryActionText != null
|
||||
@@ -32,6 +37,16 @@ data class RemoteMegaphoneRecord(
|
||||
@get:JvmName("hasSecondaryAction")
|
||||
val hasSecondaryAction = secondaryActionId != null && secondaryActionText != null
|
||||
|
||||
fun getDataForAction(actionId: ActionId): JSONObject? {
|
||||
return if (primaryActionId == actionId) {
|
||||
primaryActionData
|
||||
} else if (secondaryActionId == actionId) {
|
||||
secondaryActionData
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
enum class ActionId(val id: String, val isDonateAction: Boolean = false) {
|
||||
SNOOZE("snooze"),
|
||||
FINISH("finish"),
|
||||
|
||||
@@ -2,6 +2,9 @@ package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import org.json.JSONObject
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
@@ -275,7 +278,9 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
|
||||
title = megaphone.translation.title,
|
||||
body = megaphone.translation.body,
|
||||
primaryActionText = megaphone.translation.primaryCtaText,
|
||||
secondaryActionText = megaphone.translation.secondaryCtaText
|
||||
secondaryActionText = megaphone.translation.secondaryCtaText,
|
||||
primaryActionData = megaphone.remoteMegaphone.primaryCtaData?.takeIf { it is ObjectNode }?.let { JSONObject(it.toString()) },
|
||||
secondaryActionData = megaphone.remoteMegaphone.secondaryCtaData?.takeIf { it is ObjectNode }?.let { JSONObject(it.toString()) }
|
||||
)
|
||||
|
||||
SignalDatabase.remoteMegaphones.insert(record)
|
||||
@@ -384,7 +389,9 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
|
||||
@JsonProperty val showForNumberOfDays: Long?,
|
||||
@JsonProperty val conditionalId: String?,
|
||||
@JsonProperty val primaryCtaId: String?,
|
||||
@JsonProperty val secondaryCtaId: String?
|
||||
@JsonProperty val secondaryCtaId: String?,
|
||||
@JsonProperty val primaryCtaData: JsonNode?,
|
||||
@JsonProperty val secondaryCtaData: JsonNode?
|
||||
)
|
||||
|
||||
data class TranslatedReleaseNote(
|
||||
|
||||
@@ -307,7 +307,7 @@ public final class Megaphones {
|
||||
}
|
||||
|
||||
private static @NonNull Megaphone buildRemoteMegaphone(@NonNull Context context) {
|
||||
RemoteMegaphoneRecord record = RemoteMegaphoneRepository.getRemoteMegaphoneToShow();
|
||||
RemoteMegaphoneRecord record = RemoteMegaphoneRepository.getRemoteMegaphoneToShow(System.currentTimeMillis());
|
||||
|
||||
if (record != null) {
|
||||
Megaphone.Builder builder = new Megaphone.Builder(Event.REMOTE_MEGAPHONE, Megaphone.Style.BASIC)
|
||||
|
||||
@@ -4,7 +4,10 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.database.RemoteMegaphoneDatabase
|
||||
@@ -19,16 +22,23 @@ import org.thoughtcrime.securesms.util.LocaleFeatureFlags
|
||||
import org.thoughtcrime.securesms.util.PlayServicesUtil
|
||||
import org.thoughtcrime.securesms.util.VersionTracker
|
||||
import java.util.Objects
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
/**
|
||||
* Access point for interacting with Remote Megaphones.
|
||||
*/
|
||||
object RemoteMegaphoneRepository {
|
||||
|
||||
private val TAG = Log.tag(RemoteMegaphoneRepository::class.java)
|
||||
|
||||
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 snooze: Action = Action { _, controller, remote ->
|
||||
controller.onMegaphoneSnooze(Megaphones.Event.REMOTE_MEGAPHONE)
|
||||
db.snooze(remote)
|
||||
}
|
||||
|
||||
private val finish: Action = Action { context, controller, remote ->
|
||||
if (remote.imageUri != null) {
|
||||
@@ -40,7 +50,7 @@ object RemoteMegaphoneRepository {
|
||||
|
||||
private val donate: Action = Action { context, controller, remote ->
|
||||
controller.onMegaphoneNavigationRequested(AppSettingsActivity.manageSubscriptions(context))
|
||||
finish.run(context, controller, remote)
|
||||
snooze.run(context, controller, remote)
|
||||
}
|
||||
|
||||
private val actions = mapOf(
|
||||
@@ -65,12 +75,13 @@ object RemoteMegaphoneRepository {
|
||||
|
||||
@WorkerThread
|
||||
@JvmStatic
|
||||
fun getRemoteMegaphoneToShow(): RemoteMegaphoneRecord? {
|
||||
return db.getPotentialMegaphonesAndClearOld()
|
||||
fun getRemoteMegaphoneToShow(now: Long = System.currentTimeMillis()): RemoteMegaphoneRecord? {
|
||||
return db.getPotentialMegaphonesAndClearOld(now)
|
||||
.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) }
|
||||
.filter { it.snoozedAt == 0L || checkSnooze(it, now) }
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
@@ -95,6 +106,17 @@ object RemoteMegaphoneRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkSnooze(record: RemoteMegaphoneRecord, now: Long): Boolean {
|
||||
if (record.seenCount == 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
val gaps: JSONArray? = record.getDataForAction(ActionId.SNOOZE)?.getJSONArray("snoozeDurationDays")?.takeIf { it.length() > 0 }
|
||||
val gapDays: Int? = gaps?.getIntOrNull(record.seenCount - 1)
|
||||
|
||||
return gapDays == null || (record.snoozedAt + gapDays.days.inWholeMilliseconds <= now)
|
||||
}
|
||||
|
||||
private fun shouldShowDonateMegaphone(): Boolean {
|
||||
return VersionTracker.getDaysSinceFirstInstalled(context) >= 7 &&
|
||||
PlayServicesUtil.getPlayServicesStatus(context) == PlayServicesUtil.PlayServicesStatus.SUCCESS &&
|
||||
@@ -108,4 +130,16 @@ object RemoteMegaphoneRepository {
|
||||
fun interface Action {
|
||||
fun run(context: Context, controller: MegaphoneActionController, remoteMegaphone: RemoteMegaphoneRecord)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the int at the specified index, or last index of array if larger then array length, or null if unable to parse json
|
||||
*/
|
||||
private fun JSONArray.getIntOrNull(index: Int): Int? {
|
||||
return try {
|
||||
getInt(min(index, length() - 1))
|
||||
} catch (e: JSONException) {
|
||||
Log.w(TAG, "Unable to parse", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user