Convert megaphone database and repository to kotlin.

This commit is contained in:
Greyson Parrelli
2026-05-12 11:53:15 -04:00
committed by Michelle Tang
parent 5bcbbdf339
commit 0cf7705d4f
10 changed files with 327 additions and 385 deletions
@@ -1,190 +0,0 @@
package org.thoughtcrime.securesms.database;
import android.app.Application;
import android.content.ContentValues;
import android.database.Cursor;
import androidx.annotation.NonNull;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import net.zetetic.database.sqlcipher.SQLiteOpenHelper;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.crypto.DatabaseSecret;
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
import org.thoughtcrime.securesms.megaphone.Megaphones.Event;
import org.signal.core.util.CursorUtil;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* IMPORTANT: Writes should only be made through {@link org.thoughtcrime.securesms.megaphone.MegaphoneRepository}.
*/
public class MegaphoneDatabase extends SQLiteOpenHelper implements SignalDatabaseOpenHelper {
private static final String TAG = Log.tag(MegaphoneDatabase.class);
private static final int DATABASE_VERSION = 1;
private static final String DATABASE_NAME = "signal-megaphone.db";
private static final String TABLE_NAME = "megaphone";
private static final String ID = "_id";
private static final String EVENT = "event";
private static final String SEEN_COUNT = "seen_count";
private static final String LAST_SEEN = "last_seen";
private static final String FIRST_VISIBLE = "first_visible";
private static final String FINISHED = "finished";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
EVENT + " TEXT UNIQUE, " +
SEEN_COUNT + " INTEGER, " +
LAST_SEEN + " INTEGER, " +
FIRST_VISIBLE + " INTEGER, " +
FINISHED + " INTEGER)";
private static volatile MegaphoneDatabase instance;
private final Application application;
public static @NonNull MegaphoneDatabase getInstance(@NonNull Application context) {
if (instance == null) {
synchronized (MegaphoneDatabase.class) {
if (instance == null) {
SqlCipherLibraryLoader.load();
instance = new MegaphoneDatabase(context, DatabaseSecretProvider.getOrCreateDatabaseSecret(context));
}
}
}
return instance;
}
public MegaphoneDatabase(@NonNull Application application, @NonNull DatabaseSecret databaseSecret) {
super(application, DATABASE_NAME, databaseSecret.asString(), null, DATABASE_VERSION, 0, new SqlCipherErrorHandler(application, DATABASE_NAME), new SqlCipherDatabaseHook(), true);
this.application = application;
}
@Override
public void onCreate(SQLiteDatabase db) {
Log.i(TAG, "onCreate()");
db.execSQL(CREATE_TABLE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
Log.i(TAG, "onUpgrade(" + oldVersion + ", " + newVersion + ")");
}
@Override
public void onOpen(SQLiteDatabase db) {
Log.i(TAG, "onOpen()");
db.setForeignKeyConstraintsEnabled(true);
}
public void insert(@NonNull Collection<Event> events) {
SQLiteDatabase db = getWritableDatabase();
db.beginTransaction();
try {
for (Event event : events) {
ContentValues values = new ContentValues();
values.put(EVENT, event.getKey());
db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
public @NonNull List<MegaphoneRecord> getAllAndDeleteMissing() {
SQLiteDatabase db = getWritableDatabase();
List<MegaphoneRecord> records = new ArrayList<>();
db.beginTransaction();
try {
Set<String> missingKeys = new HashSet<>();
try (Cursor cursor = db.query(TABLE_NAME, null, null, null, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
String event = cursor.getString(cursor.getColumnIndexOrThrow(EVENT));
int seenCount = cursor.getInt(cursor.getColumnIndexOrThrow(SEEN_COUNT));
long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(LAST_SEEN));
long firstVisible = cursor.getLong(cursor.getColumnIndexOrThrow(FIRST_VISIBLE));
boolean finished = cursor.getInt(cursor.getColumnIndexOrThrow(FINISHED)) == 1;
if (Event.hasKey(event)) {
records.add(new MegaphoneRecord(Event.fromKey(event), seenCount, lastSeen, firstVisible, finished));
} else {
Log.w(TAG, "No in-app handing for event '" + event + "'! Deleting it from the database.");
missingKeys.add(event);
}
}
}
for (String missing : missingKeys) {
String query = EVENT + " = ?";
String[] args = new String[]{missing};
db.delete(TABLE_NAME, query, args);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
return records;
}
public void markFirstVisible(@NonNull Event event, long time) {
String query = EVENT + " = ?";
String[] args = new String[]{event.getKey()};
ContentValues values = new ContentValues();
values.put(FIRST_VISIBLE, time);
getWritableDatabase().update(TABLE_NAME, values, query, args);
}
public void markSeen(@NonNull Event event, int seenCount, long lastSeen) {
String query = EVENT + " = ?";
String[] args = new String[]{event.getKey()};
ContentValues values = new ContentValues();
values.put(SEEN_COUNT, seenCount);
values.put(LAST_SEEN, lastSeen);
getWritableDatabase().update(TABLE_NAME, values, query, args);
}
public void markFinished(@NonNull Event event) {
String query = EVENT + " = ?";
String[] args = new String[]{event.getKey()};
ContentValues values = new ContentValues();
values.put(FINISHED, 1);
getWritableDatabase().update(TABLE_NAME, values, query, args);
}
public void delete(@NonNull Event event) {
String query = EVENT + " = ?";
String[] args = new String[]{event.getKey()};
getWritableDatabase().delete(TABLE_NAME, query, args);
}
@Override
public @NonNull SQLiteDatabase getSqlCipherDatabase() {
return getWritableDatabase();
}
}
@@ -0,0 +1,185 @@
package org.thoughtcrime.securesms.database
import android.app.Application
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
import org.signal.core.util.requireNonNullString
import org.signal.core.util.select
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.crypto.DatabaseSecret
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader.load
import org.thoughtcrime.securesms.database.model.MegaphoneRecord
import org.thoughtcrime.securesms.megaphone.Megaphones
import kotlin.concurrent.Volatile
/**
* IMPORTANT: Writes should only be made through [org.thoughtcrime.securesms.megaphone.MegaphoneRepository].
*/
class MegaphoneDatabase(
application: Application,
databaseSecret: DatabaseSecret
) : SQLiteOpenHelper(
application,
DATABASE_NAME,
databaseSecret.asString(),
null,
DATABASE_VERSION,
0,
SqlCipherErrorHandler(application, DATABASE_NAME),
SqlCipherDatabaseHook(),
true
),
SignalDatabaseOpenHelper {
companion object {
private val TAG = tag(MegaphoneDatabase::class.java)
private const val DATABASE_VERSION = 1
private const val DATABASE_NAME = "signal-megaphone.db"
private const val TABLE_NAME = "megaphone"
private const val ID = "_id"
private const val EVENT = "event"
private const val INTERACTION_COUNT = "seen_count"
private const val LAST_INTERACTION_TIMESTAMP = "last_seen"
private const val FIRST_VISIBLE = "first_visible"
private const val FINISHED = "finished"
const val CREATE_TABLE: String = """CREATE TABLE $TABLE_NAME(
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$EVENT TEXT UNIQUE,
$INTERACTION_COUNT INTEGER,
$LAST_INTERACTION_TIMESTAMP INTEGER,
$FIRST_VISIBLE INTEGER,
$FINISHED INTEGER
)"""
@Volatile
private var instance: MegaphoneDatabase? = null
@JvmStatic
fun getInstance(context: Application): MegaphoneDatabase {
if (instance == null) {
synchronized(MegaphoneDatabase::class.java) {
if (instance == null) {
load()
instance = MegaphoneDatabase(context, DatabaseSecretProvider.getOrCreateDatabaseSecret(context))
}
}
}
return instance!!
}
}
override fun onCreate(db: SQLiteDatabase) {
Log.i(TAG, "onCreate()")
db.execSQL(CREATE_TABLE)
}
override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
Log.i(TAG, "onUpgrade($oldVersion, $newVersion)")
}
override fun onOpen(db: SQLiteDatabase) {
Log.i(TAG, "onOpen()")
db.setForeignKeyConstraintsEnabled(true)
}
fun insert(events: Collection<Megaphones.Event>) {
writableDatabase.withinTransaction { db ->
for (event in events) {
db.insertInto(TABLE_NAME)
.values(EVENT to event.key)
.run(SQLiteDatabase.CONFLICT_IGNORE)
}
}
}
fun getAllAndDeleteMissing(): MutableList<MegaphoneRecord> {
val records: MutableList<MegaphoneRecord> = mutableListOf()
writableDatabase.withinTransaction { db ->
val missingKeys: MutableSet<String> = mutableSetOf()
db.select()
.from(TABLE_NAME)
.run()
.forEach { cursor ->
val event = cursor.requireNonNullString(EVENT)
val interactionCount = cursor.requireInt(INTERACTION_COUNT)
val lastInteractionTime = cursor.requireLong(LAST_INTERACTION_TIMESTAMP)
val firstVisible = cursor.requireLong(FIRST_VISIBLE)
val finished = cursor.requireBoolean(FINISHED)
if (Megaphones.Event.hasKey(event)) {
records += MegaphoneRecord(
event = Megaphones.Event.fromKey(event),
interactionCount = interactionCount,
lastInteractionTime = lastInteractionTime,
firstVisible = firstVisible,
finished = finished
)
} else {
Log.w(TAG, "No in-app handing for event '$event'! Deleting it from the database.")
missingKeys += event
}
}
for (missing in missingKeys) {
db.delete(TABLE_NAME)
.where("$EVENT = ?", missing)
.run()
}
}
return records
}
fun markFirstVisible(event: Megaphones.Event, time: Long) {
writableDatabase
.update(TABLE_NAME)
.values(FIRST_VISIBLE to time)
.where("$EVENT = ?", event.key)
.run()
}
fun markInteractedWith(event: Megaphones.Event, interactionCount: Int, lastInteractionTimestamp: Long) {
writableDatabase
.update(TABLE_NAME)
.values(
INTERACTION_COUNT to interactionCount,
LAST_INTERACTION_TIMESTAMP to lastInteractionTimestamp
)
.where("$EVENT = ?", event.key)
.run()
}
fun markFinished(event: Megaphones.Event) {
writableDatabase
.update(TABLE_NAME)
.values(FINISHED to 1)
.where("$EVENT = ?", event.key)
.run()
}
fun delete(event: Megaphones.Event) {
writableDatabase
.delete(TABLE_NAME)
.where("$EVENT = ?", event.key)
.run()
}
override fun getSqlCipherDatabase(): SQLiteDatabase {
return writableDatabase
}
}
@@ -1,42 +0,0 @@
package org.thoughtcrime.securesms.database.model;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.megaphone.Megaphones;
public class MegaphoneRecord {
private final Megaphones.Event event;
private final int seenCount;
private final long lastSeen;
private final long firstVisible;
private final boolean finished;
public MegaphoneRecord(@NonNull Megaphones.Event event, int seenCount, long lastSeen, long firstVisible, boolean finished) {
this.event = event;
this.seenCount = seenCount;
this.lastSeen = lastSeen;
this.firstVisible = firstVisible;
this.finished = finished;
}
public @NonNull Megaphones.Event getEvent() {
return event;
}
public int getSeenCount() {
return seenCount;
}
public long getLastSeen() {
return lastSeen;
}
public long getFirstVisible() {
return firstVisible;
}
public boolean isFinished() {
return finished;
}
}
@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.database.model
import org.thoughtcrime.securesms.megaphone.Megaphones
data class MegaphoneRecord(
val event: Megaphones.Event,
val interactionCount: Int,
val lastInteractionTime: Long,
val firstVisible: Long,
val finished: Boolean
)
@@ -115,6 +115,6 @@ internal class ConfirmSvrPinFragment : BaseSvrPinFragment<ConfirmSvrPinViewModel
}
private fun markMegaphoneSeenIfNecessary() {
AppDependencies.megaphoneRepository.markSeen(Megaphones.Event.PINS_FOR_ALL)
AppDependencies.megaphoneRepository.markInteractedWith(Megaphones.Event.PINS_FOR_ALL)
}
}
@@ -308,7 +308,7 @@ class MainNavigationViewModel(
}
fun onMegaphoneSnoozed(event: Megaphones.Event) {
megaphoneRepository.markSeen(event)
megaphoneRepository.markInteractedWith(event)
internalMegaphone.update { Megaphone.NONE }
}
@@ -34,7 +34,7 @@ class BackupUpsellSchedule(
val lastSeenAnyBackupUpsell: Long = records.entries
.filter { it.key in BACKUP_UPSELL_EVENTS }
.mapNotNull { it.value.lastSeen.takeIf { t -> t > 0 } }
.mapNotNull { it.value.lastInteractionTime.takeIf { t -> t > 0 } }
.maxOrNull() ?: 0L
if (currentTime - lastSeenAnyBackupUpsell <= MIN_TIME_BETWEEN_BACKUP_UPSELLS) {
@@ -1,148 +0,0 @@
package org.thoughtcrime.securesms.megaphone;
import android.app.Application;
import androidx.annotation.AnyThread;
import androidx.annotation.Discouraged;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import java.util.stream.Collectors;
import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.database.MegaphoneDatabase;
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
import org.thoughtcrime.securesms.megaphone.Megaphones.Event;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.stream.Stream;
/**
* Synchronization of data structures is done using a serial executor. Do not access or change
* data structures or fields on anything except the executor.
*/
public class MegaphoneRepository {
private final Application context;
private final Executor executor;
private final MegaphoneDatabase database;
private final Map<Event, MegaphoneRecord> databaseCache;
private boolean enabled;
@Discouraged(message = "Instances of MegaphoneRepository should be accessed via ApplicationDependencies.")
public MegaphoneRepository(@NonNull Application context) {
this.context = context;
this.executor = SignalExecutors.SERIAL;
this.database = MegaphoneDatabase.getInstance(context);
this.databaseCache = new HashMap<>();
executor.execute(this::init);
}
/**
* Marks any megaphones a new user shouldn't see as "finished".
*/
@AnyThread
public void onFirstEverAppLaunch() {
executor.execute(() -> {
database.markFinished(Event.ADD_A_PROFILE_PHOTO);
database.markFinished(Event.PNP_LAUNCH);
resetDatabaseCache();
});
}
@AnyThread
public void onAppForegrounded() {
executor.execute(() -> enabled = true);
}
@AnyThread
public void getNextMegaphone(@NonNull Callback<Megaphone> callback) {
executor.execute(() -> {
if (enabled) {
init();
callback.onResult(Megaphones.getNextMegaphone(context, databaseCache));
} else {
callback.onResult(null);
}
});
}
@AnyThread
public void markVisible(@NonNull Megaphones.Event event) {
long time = System.currentTimeMillis();
executor.execute(() -> {
if (getRecord(event).getFirstVisible() == 0) {
database.markFirstVisible(event, time);
resetDatabaseCache();
}
});
}
@AnyThread
public void markSeen(@NonNull Event event) {
long lastSeen = System.currentTimeMillis();
executor.execute(() -> {
MegaphoneRecord record = getRecord(event);
database.markSeen(event, record.getSeenCount() + 1, lastSeen);
enabled = false;
resetDatabaseCache();
});
}
@AnyThread
public void markFinished(@NonNull Event event) {
markFinished(event, null);
}
@AnyThread
public void markFinished(@NonNull Event event, @Nullable Runnable onComplete) {
executor.execute(() -> {
MegaphoneRecord record = databaseCache.get(event);
if (record != null && record.isFinished()) {
return;
}
database.markFinished(event);
resetDatabaseCache();
if (onComplete != null) {
onComplete.run();
}
});
}
@WorkerThread
private void init() {
List<MegaphoneRecord> records = database.getAllAndDeleteMissing();
Set<Event> events = records.stream().map(MegaphoneRecord::getEvent).collect(Collectors.toSet());
Set<Event> missing = Stream.of(Event.values()).filter(o -> !events.contains(o)).collect(Collectors.toSet());
database.insert(missing);
resetDatabaseCache();
}
@WorkerThread
private @NonNull MegaphoneRecord getRecord(@NonNull Event event) {
//noinspection ConstantConditions
return databaseCache.get(event);
}
@WorkerThread
private void resetDatabaseCache() {
databaseCache.clear();
databaseCache.putAll(database.getAllAndDeleteMissing().stream().collect(Collectors.toMap(MegaphoneRecord::getEvent, m -> m)));
}
public interface Callback<E> {
void onResult(E result);
}
}
@@ -0,0 +1,126 @@
package org.thoughtcrime.securesms.megaphone
import android.app.Application
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.database.MegaphoneDatabase
import org.thoughtcrime.securesms.database.model.MegaphoneRecord
import java.util.concurrent.Executor
/**
* Synchronization of data structures is done using a serial executor. Do not access or change
* data structures or fields on anything except the executor.
*/
class MegaphoneRepository(private val context: Application) {
private val executor: Executor = SignalExecutors.SERIAL
private val database: MegaphoneDatabase = MegaphoneDatabase.getInstance(context)
private val databaseCache: MutableMap<Megaphones.Event?, MegaphoneRecord> = mutableMapOf()
private var enabled = false
init {
executor.execute {
this.init()
}
}
/**
* Marks any megaphones a new user shouldn't see as "finished".
*/
@AnyThread
fun onFirstEverAppLaunch() {
executor.execute {
database.markFinished(Megaphones.Event.ADD_A_PROFILE_PHOTO)
database.markFinished(Megaphones.Event.PNP_LAUNCH)
resetDatabaseCache()
}
}
@AnyThread
fun onAppForegrounded() {
executor.execute {
enabled = true
}
}
@AnyThread
fun getNextMegaphone(callback: Callback<Megaphone?>) {
executor.execute {
if (enabled) {
init()
callback.onResult(Megaphones.getNextMegaphone(context, databaseCache))
} else {
callback.onResult(null)
}
}
}
@AnyThread
fun markVisible(event: Megaphones.Event) {
val time = System.currentTimeMillis()
executor.execute {
if (getRecord(event).firstVisible == 0L) {
database.markFirstVisible(event, time)
resetDatabaseCache()
}
}
}
@AnyThread
fun markInteractedWith(event: Megaphones.Event) {
val currentTime = System.currentTimeMillis()
executor.execute {
val record = getRecord(event)
database.markInteractedWith(event, record.interactionCount + 1, currentTime)
enabled = false
resetDatabaseCache()
}
}
@AnyThread
fun markFinished(event: Megaphones.Event) {
markFinished(event, null)
}
@AnyThread
fun markFinished(event: Megaphones.Event, onComplete: Runnable?) {
executor.execute {
val record = databaseCache[event]
if (record != null && record.finished) {
return@execute
}
database.markFinished(event)
resetDatabaseCache()
onComplete?.run()
}
}
@WorkerThread
private fun init() {
val records: MutableList<MegaphoneRecord> = database.getAllAndDeleteMissing()
val events = records.map { it.event }.toSet()
val missing = Megaphones.Event.entries - events
database.insert(missing)
resetDatabaseCache()
}
@WorkerThread
private fun getRecord(event: Megaphones.Event): MegaphoneRecord {
return databaseCache.get(event)!!
}
@WorkerThread
private fun resetDatabaseCache() {
databaseCache.clear()
databaseCache += database.getAllAndDeleteMissing().associateBy { it.event }
}
fun interface Callback<E> {
fun onResult(result: E?)
}
}
@@ -95,7 +95,7 @@ public final class Megaphones {
MegaphoneRecord record = Objects.requireNonNull(records.get(e.getKey()));
MegaphoneSchedule schedule = e.getValue();
return !record.isFinished() && schedule.shouldDisplay(record.getSeenCount(), record.getLastSeen(), record.getFirstVisible(), currentTime);
return !record.getFinished() && schedule.shouldDisplay(record.getInteractionCount(), record.getLastInteractionTime(), record.getFirstVisible(), currentTime);
})
.map(Map.Entry::getKey)
.map(records::get)
@@ -600,7 +600,7 @@ public final class Megaphones {
*/
private static boolean shouldShowSetUpYourUsernameMegaphone(@NonNull Map<Event, MegaphoneRecord> records) {
boolean hasUsername = SignalStore.account().isRegistered() && SignalStore.account().getUsername() != null;
boolean hasCompleted = MapUtil.mapOrDefault(records, Event.SET_UP_YOUR_USERNAME, MegaphoneRecord::isFinished, false);
boolean hasCompleted = MapUtil.mapOrDefault(records, Event.SET_UP_YOUR_USERNAME, MegaphoneRecord::getFinished, false);
long phoneNumberDiscoveryDisabledAt = SignalStore.phoneNumberPrivacy().getPhoneNumberDiscoverabilityModeTimestamp();
PhoneNumberDiscoverabilityMode listingMode = SignalStore.phoneNumberPrivacy().getPhoneNumberDiscoverabilityMode();