That fuck shit the fascists are using
1package org.tm.archive.database
2
3import android.annotation.SuppressLint
4import android.app.Application
5import android.database.Cursor
6import net.zetetic.database.sqlcipher.SQLiteDatabase
7import net.zetetic.database.sqlcipher.SQLiteOpenHelper
8import org.signal.core.util.CursorUtil
9import org.signal.core.util.SqlUtil
10import org.signal.core.util.Stopwatch
11import org.signal.core.util.delete
12import org.signal.core.util.deleteAll
13import org.signal.core.util.exists
14import org.signal.core.util.getTableRowCount
15import org.signal.core.util.insertInto
16import org.signal.core.util.logging.Log
17import org.signal.core.util.mebiBytes
18import org.signal.core.util.readToList
19import org.signal.core.util.readToSingleInt
20import org.signal.core.util.requireLong
21import org.signal.core.util.requireNonNullString
22import org.signal.core.util.select
23import org.signal.core.util.update
24import org.signal.core.util.withinTransaction
25import org.tm.archive.crash.CrashConfig
26import org.tm.archive.crypto.DatabaseSecret
27import org.tm.archive.crypto.DatabaseSecretProvider
28import org.tm.archive.database.model.LogEntry
29import java.io.Closeable
30import kotlin.math.abs
31import kotlin.time.Duration.Companion.days
32
33/**
34 * Stores logs.
35 *
36 * Logs are very performance critical. Even though this database is written to on a low-priority background thread, we want to keep throughput high and ensure
37 * that we aren't creating excess garbage.
38 *
39 * This is it's own separate physical database, so it cannot do joins or queries with any other tables.
40 */
41class LogDatabase private constructor(
42 application: Application,
43 databaseSecret: DatabaseSecret
44) :
45 SQLiteOpenHelper(
46 application,
47 DATABASE_NAME,
48 databaseSecret.asString(),
49 null,
50 DATABASE_VERSION,
51 0,
52 SqlCipherDeletingErrorHandler(DATABASE_NAME),
53 SqlCipherDatabaseHook(),
54 true
55 ),
56 SignalDatabaseOpenHelper {
57
58 companion object {
59 private val TAG = Log.tag(LogDatabase::class.java)
60
61 private const val DATABASE_VERSION = 4
62 private const val DATABASE_NAME = "signal-logs.db"
63
64 @SuppressLint("StaticFieldLeak") // We hold an Application context, not a view context
65 @Volatile
66 private var instance: LogDatabase? = null
67
68 @JvmStatic
69 fun getInstance(context: Application): LogDatabase {
70 if (instance == null) {
71 synchronized(LogDatabase::class.java) {
72 if (instance == null) {
73 SqlCipherLibraryLoader.load()
74 instance = LogDatabase(context, DatabaseSecretProvider.getOrCreateDatabaseSecret(context))
75 }
76 }
77 }
78 return instance!!
79 }
80 }
81
82 @get:JvmName("logs")
83 val logs: LogTable by lazy { LogTable(this) }
84
85 @get:JvmName("crashes")
86 val crashes: CrashTable by lazy { CrashTable(this) }
87
88 @get:JvmName("anrs")
89 val anrs: AnrTable by lazy { AnrTable(this) }
90
91 override fun onCreate(db: SQLiteDatabase) {
92 Log.i(TAG, "onCreate()")
93
94 db.execSQL(LogTable.CREATE_TABLE)
95 db.execSQL(CrashTable.CREATE_TABLE)
96 db.execSQL(AnrTable.CREATE_TABLE)
97
98 LogTable.CREATE_INDEXES.forEach { db.execSQL(it) }
99 CrashTable.CREATE_INDEXES.forEach { db.execSQL(it) }
100 }
101
102 override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
103 Log.i(TAG, "onUpgrade($oldVersion, $newVersion)")
104
105 if (oldVersion < 2) {
106 db.execSQL("DROP TABLE log")
107 db.execSQL("CREATE TABLE log (_id INTEGER PRIMARY KEY, created_at INTEGER, keep_longer INTEGER DEFAULT 0, body TEXT, size INTEGER)")
108 db.execSQL("CREATE INDEX keep_longer_index ON log (keep_longer)")
109 db.execSQL("CREATE INDEX log_created_at_keep_longer_index ON log (created_at, keep_longer)")
110 }
111
112 if (oldVersion < 3) {
113 db.execSQL("CREATE TABLE crash (_id INTEGER PRIMARY KEY, created_at INTEGER, name TEXT, message TEXT, stack_trace TEXT, last_prompted_at INTEGER)")
114 db.execSQL("CREATE INDEX crash_created_at ON crash (created_at)")
115 db.execSQL("CREATE INDEX crash_name_message ON crash (name, message)")
116 }
117
118 if (oldVersion < 4) {
119 db.execSQL("CREATE TABLE anr (_id INTEGER PRIMARY KEY, created_at INTEGER NOT NULL, thread_dump TEXT NOT NULL)")
120 }
121 }
122
123 override fun onOpen(db: SQLiteDatabase) {
124 db.setForeignKeyConstraintsEnabled(true)
125 }
126
127 override fun getSqlCipherDatabase(): SQLiteDatabase {
128 return writableDatabase
129 }
130
131 class LogTable(private val openHelper: LogDatabase) {
132 companion object {
133 const val TABLE_NAME = "log"
134 const val ID = "_id"
135 const val CREATED_AT = "created_at"
136 const val KEEP_LONGER = "keep_longer"
137 const val BODY = "body"
138 const val SIZE = "size"
139
140 const val CREATE_TABLE = """
141 CREATE TABLE $TABLE_NAME (
142 $ID INTEGER PRIMARY KEY,
143 $CREATED_AT INTEGER,
144 $KEEP_LONGER INTEGER DEFAULT 0,
145 $BODY TEXT,
146 $SIZE INTEGER
147 )
148 """
149
150 val CREATE_INDEXES = arrayOf(
151 "CREATE INDEX keep_longer_index ON $TABLE_NAME ($KEEP_LONGER)",
152 "CREATE INDEX log_created_at_keep_longer_index ON $TABLE_NAME ($CREATED_AT, $KEEP_LONGER)"
153 )
154
155 val MAX_FILE_SIZE = 20L.mebiBytes.inWholeBytes
156 val DEFAULT_LIFESPAN = 3.days.inWholeMilliseconds
157 val LONGER_LIFESPAN = 21.days.inWholeMilliseconds
158 }
159
160 private val readableDatabase: SQLiteDatabase get() = openHelper.readableDatabase
161 private val writableDatabase: SQLiteDatabase get() = openHelper.writableDatabase
162
163 fun insert(logs: List<LogEntry>, currentTime: Long) {
164 writableDatabase.withinTransaction { db ->
165 logs.forEach { log ->
166 db.insertInto(TABLE_NAME)
167 .values(
168 CREATED_AT to log.createdAt,
169 KEEP_LONGER to if (log.keepLonger) 1 else 0,
170 BODY to log.body,
171 SIZE to log.body.length
172 )
173 .run()
174 }
175
176 db.delete(TABLE_NAME)
177 .where("($CREATED_AT < ? AND $KEEP_LONGER = 0) OR ($CREATED_AT < ? AND $KEEP_LONGER = 1)", currentTime - DEFAULT_LIFESPAN, currentTime - LONGER_LIFESPAN)
178 .run()
179 }
180 }
181
182 fun getAllBeforeTime(time: Long): Reader {
183 return readableDatabase
184 .select(BODY)
185 .from(TABLE_NAME)
186 .where("$CREATED_AT < $time")
187 .run()
188 .toReader()
189 }
190
191 fun getRangeBeforeTime(start: Int, length: Int, time: Long): List<String> {
192 return readableDatabase
193 .select(BODY)
194 .from(TABLE_NAME)
195 .where("$CREATED_AT < $time")
196 .limit(limit = length, offset = start)
197 .run()
198 .readToList { it.requireNonNullString(BODY) }
199 }
200
201 fun trimToSize() {
202 val currentTime = System.currentTimeMillis()
203 val stopwatch = Stopwatch("trim")
204
205 val sizeOfSpecialLogs: Long = getSize("$KEEP_LONGER = ?", arrayOf("1"))
206 val remainingSize = MAX_FILE_SIZE - sizeOfSpecialLogs
207
208 stopwatch.split("keepers-size")
209
210 if (remainingSize <= 0) {
211 if (abs(remainingSize) > MAX_FILE_SIZE / 2) {
212 // Not only are KEEP_LONGER logs putting us over the storage limit, it's doing it by a lot! Delete half.
213 val logCount = readableDatabase.getTableRowCount(TABLE_NAME)
214 writableDatabase.execSQL("DELETE FROM $TABLE_NAME WHERE $ID < (SELECT MAX($ID) FROM (SELECT $ID FROM $TABLE_NAME LIMIT ${logCount / 2}))")
215 } else {
216 writableDatabase
217 .delete(TABLE_NAME)
218 .where("$KEEP_LONGER = 0")
219 }
220 return
221 }
222
223 val sizeDiffThreshold = MAX_FILE_SIZE * 0.01
224
225 var lhs: Long = currentTime - DEFAULT_LIFESPAN
226 var rhs: Long = currentTime
227 var mid: Long = 0
228 var sizeOfChunk: Long
229
230 while (lhs < rhs - 2) {
231 mid = (lhs + rhs) / 2
232 sizeOfChunk = getSize("$CREATED_AT > ? AND $CREATED_AT < ? AND $KEEP_LONGER = ?", SqlUtil.buildArgs(mid, currentTime, 0))
233
234 if (sizeOfChunk > remainingSize) {
235 lhs = mid
236 } else if (sizeOfChunk < remainingSize) {
237 if (remainingSize - sizeOfChunk < sizeDiffThreshold) {
238 break
239 } else {
240 rhs = mid
241 }
242 } else {
243 break
244 }
245 }
246
247 stopwatch.split("binary-search")
248
249 writableDatabase.delete(TABLE_NAME, "$CREATED_AT < ? AND $KEEP_LONGER = ?", SqlUtil.buildArgs(mid, 0))
250
251 stopwatch.split("delete")
252 stopwatch.stop(TAG)
253 }
254
255 fun getLogCountBeforeTime(time: Long): Int {
256 return readableDatabase
257 .select("COUNT(*)")
258 .from(TABLE_NAME)
259 .where("$CREATED_AT < $time")
260 .run()
261 .readToSingleInt()
262 }
263
264 fun clearKeepLonger() {
265 writableDatabase
266 .delete(TABLE_NAME)
267 .where("$KEEP_LONGER = 1")
268 .run()
269 }
270
271 fun clearAll() {
272 writableDatabase.deleteAll(TABLE_NAME)
273 }
274
275 private fun getSize(query: String?, args: Array<String>?): Long {
276 readableDatabase.query(TABLE_NAME, arrayOf("SUM($SIZE)"), query, args, null, null, null).use { cursor ->
277 return if (cursor.moveToFirst()) {
278 cursor.getLong(0)
279 } else {
280 0
281 }
282 }
283 }
284
285 private fun Cursor.toReader(): CursorReader {
286 return CursorReader(this)
287 }
288
289 interface Reader : Iterator<String>, Closeable
290
291 class CursorReader(private val cursor: Cursor) : Reader {
292 override fun hasNext(): Boolean {
293 return !cursor.isLast && cursor.count > 0
294 }
295
296 override fun next(): String {
297 cursor.moveToNext()
298 return CursorUtil.requireString(cursor, LogTable.BODY)
299 }
300
301 override fun close() {
302 cursor.close()
303 }
304 }
305 }
306
307 class CrashTable(private val openHelper: LogDatabase) {
308 companion object {
309 const val TABLE_NAME = "crash"
310 const val ID = "_id"
311 const val CREATED_AT = "created_at"
312 const val NAME = "name"
313 const val MESSAGE = "message"
314 const val STACK_TRACE = "stack_trace"
315 const val LAST_PROMPTED_AT = "last_prompted_at"
316
317 const val CREATE_TABLE = """
318 CREATE TABLE $TABLE_NAME (
319 $ID INTEGER PRIMARY KEY,
320 $CREATED_AT INTEGER,
321 $NAME TEXT,
322 $MESSAGE TEXT,
323 $STACK_TRACE TEXT,
324 $LAST_PROMPTED_AT INTEGER
325 )
326 """
327
328 val CREATE_INDEXES = arrayOf(
329 "CREATE INDEX crash_created_at ON $TABLE_NAME ($CREATED_AT)",
330 "CREATE INDEX crash_name_message ON $TABLE_NAME ($NAME, $MESSAGE)"
331 )
332 }
333
334 private val readableDatabase: SQLiteDatabase get() = openHelper.readableDatabase
335 private val writableDatabase: SQLiteDatabase get() = openHelper.writableDatabase
336
337 fun saveCrash(createdAt: Long, name: String, message: String?, stackTrace: String) {
338 writableDatabase
339 .insertInto(TABLE_NAME)
340 .values(
341 CREATED_AT to createdAt,
342 NAME to name,
343 MESSAGE to message,
344 STACK_TRACE to stackTrace,
345 LAST_PROMPTED_AT to 0
346 )
347 .run()
348
349 trimToSize()
350 }
351
352 /**
353 * Returns true if crashes exists that
354 * (1) match any of the provided crash patterns
355 * (2) have not been prompted within the [promptThreshold]
356 */
357 fun anyMatch(patterns: Collection<CrashConfig.CrashPattern>, promptThreshold: Long): Boolean {
358 for (pattern in patterns) {
359 val (query, args) = pattern.asLikeQuery()
360
361 val found = readableDatabase
362 .exists(TABLE_NAME)
363 .where("$query AND $LAST_PROMPTED_AT < $promptThreshold", args)
364 .run()
365
366 if (found) {
367 return true
368 }
369 }
370
371 return false
372 }
373
374 /**
375 * Marks all crashes that match any of the provided patterns as being prompted at the provided [promptedAt] time.
376 */
377 fun markAsPrompted(patterns: Collection<CrashConfig.CrashPattern>, promptedAt: Long) {
378 for (pattern in patterns) {
379 val (query, args) = pattern.asLikeQuery()
380
381 readableDatabase
382 .update(TABLE_NAME)
383 .values(LAST_PROMPTED_AT to promptedAt)
384 .where(query, args)
385 .run()
386 }
387 }
388
389 fun trimToSize() {
390 // Delete crashes older than 30 days
391 val threshold = System.currentTimeMillis() - 30.days.inWholeMilliseconds
392 writableDatabase
393 .delete(TABLE_NAME)
394 .where("$CREATED_AT < $threshold")
395 .run()
396
397 // Only keep 100 most recent crashes to prevent crash loops from filling up the disk
398 writableDatabase
399 .delete(TABLE_NAME)
400 .where("$ID NOT IN (SELECT $ID FROM $TABLE_NAME ORDER BY $CREATED_AT DESC LIMIT 100)")
401 .run()
402 }
403
404 fun clear() {
405 writableDatabase.deleteAll(TABLE_NAME)
406 }
407
408 private fun CrashConfig.CrashPattern.asLikeQuery(): Pair<String, Array<String>> {
409 val query = StringBuilder()
410 var args = arrayOf<String>()
411
412 if (namePattern != null) {
413 query.append("$NAME LIKE ?")
414 args += "%$namePattern%"
415 }
416
417 if (messagePattern != null) {
418 if (query.isNotEmpty()) {
419 query.append(" AND ")
420 }
421 query.append("$MESSAGE LIKE ?")
422 args += "%$messagePattern%"
423 }
424
425 if (stackTracePattern != null) {
426 if (query.isNotEmpty()) {
427 query.append(" AND ")
428 }
429 query.append("$STACK_TRACE LIKE ?")
430 args += "%$stackTracePattern%"
431 }
432
433 return query.toString() to args
434 }
435 }
436
437 class AnrTable(private val openHelper: LogDatabase) {
438 companion object {
439 const val TABLE_NAME = "anr"
440 const val ID = "_id"
441 const val CREATED_AT = "created_at"
442 const val THREAD_DUMP = "thread_dump"
443
444 const val CREATE_TABLE = """
445 CREATE TABLE $TABLE_NAME (
446 $ID INTEGER PRIMARY KEY,
447 $CREATED_AT INTEGER NOT NULL,
448 $THREAD_DUMP TEXT NOT NULL
449 )
450 """
451 }
452
453 private val readableDatabase: SQLiteDatabase get() = openHelper.readableDatabase
454 private val writableDatabase: SQLiteDatabase get() = openHelper.writableDatabase
455
456 fun save(currentTime: Long, threadDumps: String) {
457 writableDatabase
458 .insertInto(TABLE_NAME)
459 .values(
460 CREATED_AT to currentTime,
461 THREAD_DUMP to threadDumps
462 )
463 .run()
464
465 val count = writableDatabase
466 .delete(TABLE_NAME)
467 .where(
468 """
469 $ID NOT IN (SELECT $ID FROM $TABLE_NAME ORDER BY $CREATED_AT DESC LIMIT 10)
470 """.trimIndent()
471 )
472 .run()
473
474 if (count > 0) {
475 Log.i(TAG, "Deleted $count old ANRs")
476 }
477 }
478
479 fun getAll(): List<AnrRecord> {
480 return readableDatabase
481 .select()
482 .from(TABLE_NAME)
483 .run()
484 .readToList { cursor ->
485 AnrRecord(
486 createdAt = cursor.requireLong(CREATED_AT),
487 threadDump = cursor.requireNonNullString(THREAD_DUMP)
488 )
489 }
490 .sortedBy { it.createdAt }
491 }
492
493 fun clear() {
494 writableDatabase.deleteAll(TABLE_NAME)
495 }
496
497 data class AnrRecord(
498 val createdAt: Long,
499 val threadDump: String
500 )
501 }
502}