Add remote megaphone snooze capabilities.

This commit is contained in:
Cody Henthorne
2022-10-27 16:43:23 -04:00
parent 2ea5c7e3bc
commit c357c35303
11 changed files with 542 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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