Automatically snooze megaphones after 3 days.

This commit is contained in:
Greyson Parrelli
2026-05-13 10:30:37 -04:00
committed by Michelle Tang
parent 0e40acfdaa
commit 7dd6829bfa
5 changed files with 244 additions and 17 deletions
@@ -1,13 +1,14 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import androidx.annotation.VisibleForTesting
import androidx.sqlite.db.SupportSQLiteDatabase
import net.zetetic.database.sqlcipher.SQLiteDatabase
import net.zetetic.database.sqlcipher.SQLiteOpenHelper
import org.signal.core.util.delete
import org.signal.core.util.forEach
import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.Log.tag
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
@@ -25,7 +26,7 @@ import kotlin.concurrent.Volatile
/**
* IMPORTANT: Writes should only be made through [org.thoughtcrime.securesms.megaphone.MegaphoneRepository].
*/
class MegaphoneDatabase(
open class MegaphoneDatabase(
application: Application,
databaseSecret: DatabaseSecret
) : SQLiteOpenHelper(
@@ -42,17 +43,45 @@ class MegaphoneDatabase(
SignalDatabaseOpenHelper {
companion object {
private val TAG = tag(MegaphoneDatabase::class.java)
private val TAG = Log.tag(MegaphoneDatabase::class.java)
private const val DATABASE_VERSION = 1
private const val DATABASE_VERSION = 2
private const val DATABASE_NAME = "signal-megaphone.db"
private const val TABLE_NAME = "megaphone"
private const val ID = "_id"
/**
* The event name, which is a key we use to tie it to views and whatnot.
*/
private const val EVENT = "event"
private const val INTERACTION_COUNT = "seen_count"
private const val LAST_INTERACTION_TIMESTAMP = "last_seen"
/**
* How many times a megaphone was interacted with. This is most commonly the "snooze" count.
*/
private const val INTERACTION_COUNT = "interaction_count"
/**
* The last time a megaphone was interacted with. This is most commonly the "snooze" timestamp.
*/
private const val LAST_INTERACTION_TIMESTAMP = "last_interaction_timestamp"
/**
* The timestamp of when the megaphone was first shown to the user.
*/
private const val FIRST_VISIBLE = "first_visible"
/**
* The timestamp of then when the last "view cycle" started. For instance, if a megaphone was
* snoozed and then shown again, this will be the timestamp of when it was first shown again.
* It is *not* updated every time a megaphone is seen, just at the start of the view cycle.
* This is largely used to determine when to auto-snooze a megaphone.
*/
private const val LAST_VISIBLE = "last_visible"
/**
* Whether a megaphone has been fully completed. When it's finished, it'll never be shown again.
*/
private const val FINISHED = "finished"
const val CREATE_TABLE: String = """CREATE TABLE $TABLE_NAME(
@@ -61,6 +90,7 @@ class MegaphoneDatabase(
$INTERACTION_COUNT INTEGER,
$LAST_INTERACTION_TIMESTAMP INTEGER,
$FIRST_VISIBLE INTEGER,
$LAST_VISIBLE INTEGER DEFAULT 0,
$FINISHED INTEGER
)"""
@@ -81,6 +111,10 @@ class MegaphoneDatabase(
}
}
@get:VisibleForTesting
internal open val database: SupportSQLiteDatabase
get() = writableDatabase
override fun onCreate(db: SQLiteDatabase) {
Log.i(TAG, "onCreate()")
db.execSQL(CREATE_TABLE)
@@ -88,6 +122,12 @@ class MegaphoneDatabase(
override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
Log.i(TAG, "onUpgrade($oldVersion, $newVersion)")
if (oldVersion < 2) {
db!!.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN $LAST_VISIBLE INTEGER DEFAULT 0")
db.execSQL("ALTER TABLE $TABLE_NAME RENAME COLUMN seen_count TO interaction_count")
db.execSQL("ALTER TABLE $TABLE_NAME RENAME COLUMN last_seen TO last_interaction_timestamp")
}
}
override fun onOpen(db: SQLiteDatabase) {
@@ -96,7 +136,7 @@ class MegaphoneDatabase(
}
fun insert(events: Collection<Megaphones.Event>) {
writableDatabase.withinTransaction { db ->
database.withinTransaction { db ->
for (event in events) {
db.insertInto(TABLE_NAME)
.values(EVENT to event.key)
@@ -108,7 +148,7 @@ class MegaphoneDatabase(
fun getAllAndDeleteMissing(): MutableList<MegaphoneRecord> {
val records: MutableList<MegaphoneRecord> = mutableListOf()
writableDatabase.withinTransaction { db ->
database.withinTransaction { db ->
val missingKeys: MutableSet<String> = mutableSetOf()
db.select()
@@ -119,6 +159,7 @@ class MegaphoneDatabase(
val interactionCount = cursor.requireInt(INTERACTION_COUNT)
val lastInteractionTime = cursor.requireLong(LAST_INTERACTION_TIMESTAMP)
val firstVisible = cursor.requireLong(FIRST_VISIBLE)
val lastVisible = cursor.requireLong(LAST_VISIBLE)
val finished = cursor.requireBoolean(FINISHED)
if (Megaphones.Event.hasKey(event)) {
@@ -127,6 +168,7 @@ class MegaphoneDatabase(
interactionCount = interactionCount,
lastInteractionTime = lastInteractionTime,
firstVisible = firstVisible,
lastVisible = lastVisible,
finished = finished
)
} else {
@@ -146,26 +188,35 @@ class MegaphoneDatabase(
}
fun markFirstVisible(event: Megaphones.Event, time: Long) {
writableDatabase
database
.update(TABLE_NAME)
.values(FIRST_VISIBLE to time)
.where("$EVENT = ?", event.key)
.run()
}
fun markLastVisible(event: Megaphones.Event, time: Long) {
database
.update(TABLE_NAME)
.values(LAST_VISIBLE to time)
.where("$EVENT = ?", event.key)
.run()
}
fun markInteractedWith(event: Megaphones.Event, interactionCount: Int, lastInteractionTimestamp: Long) {
writableDatabase
database
.update(TABLE_NAME)
.values(
INTERACTION_COUNT to interactionCount,
LAST_INTERACTION_TIMESTAMP to lastInteractionTimestamp
LAST_INTERACTION_TIMESTAMP to lastInteractionTimestamp,
LAST_VISIBLE to 0L
)
.where("$EVENT = ?", event.key)
.run()
}
fun markFinished(event: Megaphones.Event) {
writableDatabase
database
.update(TABLE_NAME)
.values(FINISHED to 1)
.where("$EVENT = ?", event.key)
@@ -173,7 +224,7 @@ class MegaphoneDatabase(
}
fun delete(event: Megaphones.Event) {
writableDatabase
database
.delete(TABLE_NAME)
.where("$EVENT = ?", event.key)
.run()
@@ -7,5 +7,6 @@ data class MegaphoneRecord(
val interactionCount: Int,
val lastInteractionTime: Long,
val firstVisible: Long,
val lastVisible: Long,
val finished: Boolean
)
@@ -4,9 +4,11 @@ import android.app.Application
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.MegaphoneDatabase
import org.thoughtcrime.securesms.database.model.MegaphoneRecord
import java.util.concurrent.Executor
import kotlin.time.Duration.Companion.days
/**
* Synchronization of data structures is done using a serial executor. Do not access or change
@@ -19,6 +21,11 @@ class MegaphoneRepository(private val context: Application) {
private var enabled = false
companion object {
private val TAG = Log.tag(MegaphoneRepository::class.java)
private val MAX_DISPLAY_DURATION = 3.days.inWholeMilliseconds
}
init {
executor.execute {
this.init()
@@ -44,12 +51,33 @@ class MegaphoneRepository(private val context: Application) {
}
}
/**
* Note that if the next megaphone we'd choose needs to be auto-snoozed, this will result in an "off" cycle, where no megaphone will be shown.
* We could choose to keep looking, but given that auto-snooze is intended to give the user a break from megaphones, it's probably for the best that we take
* at least one cycle off.
*/
@AnyThread
fun getNextMegaphone(callback: Callback<Megaphone?>) {
executor.execute {
if (enabled) {
init()
callback.onResult(Megaphones.getNextMegaphone(context, databaseCache))
val currentTime = System.currentTimeMillis()
val next = Megaphones.getNextMegaphone(context, databaseCache)
if (next != null) {
val record = getRecord(next.event)
if (record.lastVisible > 0 && currentTime - record.lastVisible > MAX_DISPLAY_DURATION) {
Log.i(TAG, "Auto-snoozing ${next.event} after being visible for ${currentTime - record.lastVisible}ms without interaction.")
database.markInteractedWith(next.event, record.interactionCount + 1, currentTime)
enabled = false
resetDatabaseCache()
callback.onResult(null)
return@execute
}
}
callback.onResult(next)
} else {
callback.onResult(null)
}
@@ -61,8 +89,17 @@ class MegaphoneRepository(private val context: Application) {
val time = System.currentTimeMillis()
executor.execute {
if (getRecord(event).firstVisible == 0L) {
val record = getRecord(event)
var changed = false
if (record.firstVisible == 0L) {
database.markFirstVisible(event, time)
changed = true
}
if (record.lastVisible == 0L) {
database.markLastVisible(event, time)
changed = true
}
if (changed) {
resetDatabaseCache()
}
}