diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e30c8c7137..2fc587e570 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1288,6 +1288,12 @@
+
+
+
+
+
+
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
index ff9a15f1a3..e4645b9a13 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
@@ -83,6 +83,7 @@ import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.RegistrationUtil;
import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
+import org.thoughtcrime.securesms.service.AnalyzeDatabaseAlarmListener;
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.service.LocalBackupListener;
@@ -419,6 +420,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
LocalBackupListener.schedule(this);
RotateSenderCertificateListener.schedule(this);
RoutineMessageFetchReceiver.startOrUpdateAlarm(this);
+ AnalyzeDatabaseAlarmListener.schedule(this);
if (BuildConfig.MANAGES_APP_UPDATES) {
ApkUpdateRefreshListener.schedule(this);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AnalyzeDatabaseJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/AnalyzeDatabaseJob.kt
new file mode 100644
index 0000000000..9feac49dea
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AnalyzeDatabaseJob.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.jobs
+
+import org.signal.core.util.getAllTables
+import org.signal.core.util.logTime
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.database.SignalDatabase
+import org.thoughtcrime.securesms.jobmanager.Job
+import org.thoughtcrime.securesms.jobmanager.JsonJobData
+import kotlin.time.Duration.Companion.days
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Analyzes the database, updating statistics to ensure that sqlite is using the best indices possible for different queries.
+ *
+ * Given that analysis can be slow, this job will only analyze one table at a time, retrying itself so long as there are more tables to analyze.
+ * This should help protect it against getting canceled by the system for running for too long, while also giving it the ability to save it's place.
+ */
+class AnalyzeDatabaseJob private constructor(
+ parameters: Parameters,
+ private var lastCompletedTable: String?
+) : Job(parameters) {
+
+ companion object {
+ val TAG = Log.tag(AnalyzeDatabaseJob::class.java)
+
+ const val KEY = "AnalyzeDatabaseJob"
+
+ private const val KEY_LAST_COMPLETED_TABLE = "last_completed_table"
+ }
+
+ constructor() : this(
+ Parameters.Builder()
+ .setMaxInstancesForFactory(1)
+ .setLifespan(1.days.inWholeMilliseconds)
+ .setMaxAttempts(Parameters.UNLIMITED)
+ .build(),
+ null
+ )
+
+ override fun serialize(): ByteArray? {
+ return JsonJobData.Builder()
+ .putString(KEY_LAST_COMPLETED_TABLE, lastCompletedTable)
+ .build()
+ .serialize()
+ }
+
+ override fun getFactoryKey(): String = KEY
+
+ override fun run(): Result {
+ val tables = SignalDatabase.rawDatabase.getAllTables()
+ .sorted()
+ .filterNot { it.startsWith("sqlite_") || it.contains("fts_") }
+
+ if (tables.isEmpty()) {
+ Log.w(TAG, "Table list is empty!")
+ return Result.success()
+ }
+
+ val startingIndex = if (lastCompletedTable != null) {
+ tables.indexOf(lastCompletedTable) + 1
+ } else {
+ 0
+ }
+
+ if (startingIndex >= tables.size) {
+ Log.i(TAG, "Already finished all of the tables!")
+ return Result.success()
+ }
+
+ val table = tables[startingIndex]
+
+ logTime(TAG, "analyze-$table", decimalPlaces = 2) {
+ SignalDatabase.rawDatabase.execSQL("PRAGMA analysis_limit=1000")
+ SignalDatabase.rawDatabase.execSQL("ANALYZE $table")
+ }
+
+ if (startingIndex >= tables.size - 1) {
+ Log.i(TAG, "Finished all of the tables!")
+ return Result.success()
+ }
+
+ lastCompletedTable = table
+ return Result.retry(1.seconds.inWholeMilliseconds)
+ }
+
+ override fun onFailure() = Unit
+
+ class Factory : Job.Factory {
+ override fun create(parameters: Parameters, data: ByteArray?): AnalyzeDatabaseJob {
+ val builder = JsonJobData.deserialize(data)
+
+ return AnalyzeDatabaseJob(parameters, builder.getStringOrDefault(KEY_LAST_COMPLETED_TABLE, null))
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
index 98df8e82bf..cb14eba729 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
@@ -99,6 +99,7 @@ public final class JobManagerFactories {
public static Map getJobFactories(@NonNull Application application) {
return new HashMap() {{
put(AccountConsistencyWorkerJob.KEY, new AccountConsistencyWorkerJob.Factory());
+ put(AnalyzeDatabaseJob.KEY, new AnalyzeDatabaseJob.Factory());
put(AttachmentCompressionJob.KEY, new AttachmentCompressionJob.Factory());
put(AttachmentCopyJob.KEY, new AttachmentCopyJob.Factory());
put(AttachmentDownloadJob.KEY, new AttachmentDownloadJob.Factory());
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt
index d1aeeec4aa..9a2aaecfaa 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt
@@ -35,6 +35,7 @@ internal class MiscellaneousValues internal constructor(store: KeyValueStore) :
private const val LAST_CDS_FOREGROUND_SYNC = "misc.last_cds_foreground_sync"
private const val LINKED_DEVICE_LAST_ACTIVE_CHECK_TIME = "misc.linked_device.last_active_check_time"
private const val LEAST_ACTIVE_LINKED_DEVICE = "misc.linked_device.least_active"
+ private const val NEXT_DATABASE_ANALYSIS_TIME = "misc.next_database_analysis_time"
}
public override fun onFirstEverAppLaunch() {
@@ -236,4 +237,9 @@ internal class MiscellaneousValues internal constructor(store: KeyValueStore) :
* Details about the least-active linked device.
*/
var leastActiveLinkedDevice: LeastActiveLinkedDevice? by protoValue(LEAST_ACTIVE_LINKED_DEVICE, LeastActiveLinkedDevice.ADAPTER)
+
+ /**
+ * When the next scheduled database analysis is.
+ */
+ var nextDatabaseAnalysisTime: Long by longValue(NEXT_DATABASE_ANALYSIS_TIME, 0)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/AnalyzeDatabaseAlarmListener.kt b/app/src/main/java/org/thoughtcrime/securesms/service/AnalyzeDatabaseAlarmListener.kt
new file mode 100644
index 0000000000..9414b40726
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/AnalyzeDatabaseAlarmListener.kt
@@ -0,0 +1,54 @@
+package org.thoughtcrime.securesms.service
+
+import android.content.Context
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
+import org.thoughtcrime.securesms.jobs.AnalyzeDatabaseJob
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.util.toMillis
+import java.time.LocalDateTime
+
+/**
+ * Schedules database analysis to happen everyday at 3am.
+ */
+class AnalyzeDatabaseAlarmListener : PersistentAlarmManagerListener() {
+ companion object {
+ @JvmStatic
+ fun schedule(context: Context?) {
+ AnalyzeDatabaseAlarmListener().onReceive(context, getScheduleIntent())
+ }
+ }
+
+ override fun shouldScheduleExact(): Boolean {
+ return true
+ }
+
+ override fun getNextScheduledExecutionTime(context: Context): Long {
+ var nextTime = SignalStore.misc().nextDatabaseAnalysisTime
+
+ if (nextTime == 0L) {
+ nextTime = getNextTime()
+ SignalStore.misc().nextDatabaseAnalysisTime = nextTime
+ }
+
+ return nextTime
+ }
+
+ override fun onAlarm(context: Context, scheduledTime: Long): Long {
+ ApplicationDependencies.getJobManager().add(AnalyzeDatabaseJob())
+
+ val nextTime = getNextTime()
+ SignalStore.misc().nextDatabaseAnalysisTime = nextTime
+
+ return nextTime
+ }
+
+ private fun getNextTime(): Long {
+ return LocalDateTime
+ .now()
+ .plusDays(1)
+ .withHour(3)
+ .withMinute(0)
+ .withSecond(0)
+ .toMillis()
+ }
+}