/* * SPDX-FileCopyrightText: 2020, microG Project Team * SPDX-License-Identifier: Apache-2.0 */ package org.microg.gms.nearby.exposurenotification import android.annotation.TargetApi import android.content.ContentValues import android.content.Context import android.content.Intent import android.database.sqlite.SQLiteCursor import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase.CONFLICT_IGNORE import android.database.sqlite.SQLiteOpenHelper import android.net.Uri import android.os.Parcel import android.os.Parcelable import android.util.Log import androidx.core.content.FileProvider import com.google.android.gms.nearby.exposurenotification.* import kotlinx.coroutines.* import okio.ByteString import org.microg.gms.common.PackageUtils import java.io.File import java.lang.Runnable import java.nio.ByteBuffer import java.util.* import java.util.concurrent.* import kotlin.experimental.and @TargetApi(21) class ExposureDatabase private constructor(private val context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { private val createdAt: Exception = Exception("Database ${hashCode()} created") private var refCount = 1 init { setWriteAheadLoggingEnabled(true) } override fun onConfigure(db: SQLiteDatabase) { super.onConfigure(db) db.setForeignKeyConstraintsEnabled(true) } override fun onCreate(db: SQLiteDatabase) { onUpgrade(db, 0, DB_VERSION) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { Log.d(TAG, "Upgrading database from $oldVersion to $newVersion") if (oldVersion < 1) { Log.d(TAG, "Creating tables for version >= 1") db.execSQL("CREATE TABLE IF NOT EXISTS $TABLE_ADVERTISEMENTS(rpi BLOB NOT NULL, aem BLOB NOT NULL, timestamp INTEGER NOT NULL, rssi INTEGER NOT NULL, duration INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(rpi, timestamp));") db.execSQL("CREATE INDEX IF NOT EXISTS index_${TABLE_ADVERTISEMENTS}_rpi ON $TABLE_ADVERTISEMENTS(rpi);") db.execSQL("CREATE INDEX IF NOT EXISTS index_${TABLE_ADVERTISEMENTS}_timestamp ON $TABLE_ADVERTISEMENTS(timestamp);") db.execSQL("CREATE TABLE IF NOT EXISTS $TABLE_APP_LOG(package TEXT NOT NULL, timestamp INTEGER NOT NULL, method TEXT NOT NULL, args TEXT);") db.execSQL("CREATE INDEX IF NOT EXISTS index_${TABLE_APP_LOG}_package_timestamp ON $TABLE_APP_LOG(package, timestamp);") db.execSQL("CREATE TABLE IF NOT EXISTS $TABLE_TEK(keyData BLOB NOT NULL, rollingStartNumber INTEGER NOT NULL, rollingPeriod INTEGER NOT NULL);") } if (oldVersion < 3) { Log.d(TAG, "Creating tables for version >= 3") db.execSQL("CREATE TABLE $TABLE_APP_PERMS(package TEXT NOT NULL, sig TEXT NOT NULL, perm TEXT NOT NULL, timestamp INTEGER NOT NULL);") } if (oldVersion < 5) { Log.d(TAG, "Creating tables for version >= 5") db.execSQL("CREATE TABLE IF NOT EXISTS $TABLE_TOKENS(tid INTEGER PRIMARY KEY, package TEXT NOT NULL, token TEXT NOT NULL, timestamp INTEGER NOT NULL, configuration BLOB, diagnosisKeysDataMap BLOB);") db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_${TABLE_TOKENS}_package_token ON $TABLE_TOKENS(package, token);") db.execSQL("CREATE TABLE IF NOT EXISTS $TABLE_TEK_CHECK_SINGLE(tcsid INTEGER PRIMARY KEY, keyData BLOB NOT NULL, rollingStartNumber INTEGER NOT NULL, rollingPeriod INTEGER NOT NULL, matched INTEGER);") db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_${TABLE_TEK_CHECK_SINGLE}_key ON $TABLE_TEK_CHECK_SINGLE(keyData, rollingStartNumber, rollingPeriod);") db.execSQL("CREATE TABLE IF NOT EXISTS $TABLE_TEK_CHECK_SINGLE_TOKEN(tcsid INTEGER REFERENCES $TABLE_TEK_CHECK_SINGLE(tcsid) ON DELETE CASCADE, tid INTEGER REFERENCES $TABLE_TOKENS(tid) ON DELETE CASCADE, transmissionRiskLevel INTEGER NOT NULL, reportType INTEGER NOT NULL, daysSinceOnsetOfSymptoms INTEGER NOT NULL, UNIQUE(tcsid, tid));") db.execSQL("CREATE INDEX IF NOT EXISTS index_${TABLE_TEK_CHECK_SINGLE_TOKEN}_tid ON $TABLE_TEK_CHECK_SINGLE_TOKEN(tid);") db.execSQL("CREATE TABLE IF NOT EXISTS $TABLE_TEK_CHECK_FILE(tcfid INTEGER PRIMARY KEY, hash TEXT NOT NULL, endTimestamp INTEGER NOT NULL, keys INTEGER NOT NULL);") db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_${TABLE_TEK_CHECK_FILE}_hash ON $TABLE_TEK_CHECK_FILE(hash);") db.execSQL("CREATE TABLE IF NOT EXISTS $TABLE_TEK_CHECK_FILE_TOKEN(tcfid INTEGER REFERENCES $TABLE_TEK_CHECK_FILE(tcfid) ON DELETE CASCADE, tid INTEGER REFERENCES $TABLE_TOKENS(tid) ON DELETE CASCADE, UNIQUE(tcfid, tid));") db.execSQL("CREATE INDEX IF NOT EXISTS index_${TABLE_TEK_CHECK_FILE_TOKEN}_tid ON $TABLE_TEK_CHECK_FILE_TOKEN(tid);") db.execSQL("CREATE TABLE IF NOT EXISTS $TABLE_TEK_CHECK_FILE_MATCH(tcfid INTEGER REFERENCES $TABLE_TEK_CHECK_FILE(tcfid) ON DELETE CASCADE, keyData BLOB NOT NULL, rollingStartNumber INTEGER NOT NULL, rollingPeriod INTEGER NOT NULL, transmissionRiskLevel INTEGER NOT NULL, reportType INTEGER NOT NULL, daysSinceOnsetOfSymptoms INTEGER NOT NULL, UNIQUE(tcfid, keyData, rollingStartNumber, rollingPeriod));") db.execSQL("CREATE INDEX IF NOT EXISTS index_${TABLE_TEK_CHECK_FILE_MATCH}_tcfid ON $TABLE_TEK_CHECK_FILE_MATCH(tcfid);") db.execSQL("CREATE INDEX IF NOT EXISTS index_${TABLE_TEK_CHECK_FILE_MATCH}_key ON $TABLE_TEK_CHECK_FILE_MATCH(keyData, rollingStartNumber, rollingPeriod);") } if (oldVersion < 9) { Log.d(TAG, "Creating tables for version >= 9") db.execSQL("CREATE TABLE IF NOT EXISTS $TABLE_APP(package TEXT NOT NULL, sig TEXT NOT NULL, PRIMARY KEY (package, sig));") } if (oldVersion in 5 until 7) { Log.d(TAG, "Altering tables for version >= 7") db.execSQL("ALTER TABLE $TABLE_TOKENS ADD COLUMN diagnosisKeysDataMap BLOB;") } if (oldVersion in 5 until 11) { Log.d(TAG, "Altering tables for version >= 11") db.execSQL("ALTER TABLE $TABLE_TEK_CHECK_SINGLE_TOKEN ADD COLUMN reportType INTEGER NOT NULL DEFAULT ${ReportType.UNKNOWN};") db.execSQL("ALTER TABLE $TABLE_TEK_CHECK_SINGLE_TOKEN ADD COLUMN daysSinceOnsetOfSymptoms INTEGER NOT NULL DEFAULT ${TemporaryExposureKey.DAYS_SINCE_ONSET_OF_SYMPTOMS_UNKNOWN};") db.execSQL("ALTER TABLE $TABLE_TEK_CHECK_FILE_MATCH ADD COLUMN reportType INTEGER NOT NULL DEFAULT ${ReportType.UNKNOWN};") db.execSQL("ALTER TABLE $TABLE_TEK_CHECK_FILE_MATCH ADD COLUMN daysSinceOnsetOfSymptoms INTEGER NOT NULL DEFAULT ${TemporaryExposureKey.DAYS_SINCE_ONSET_OF_SYMPTOMS_UNKNOWN};") } if (oldVersion in 1 until 5) { Log.d(TAG, "Dropping legacy tables from version < 5") db.execSQL("DROP TABLE IF EXISTS $TABLE_CONFIGURATIONS;") db.execSQL("DROP TABLE IF EXISTS $TABLE_DIAGNOSIS;") db.execSQL("DROP TABLE IF EXISTS $TABLE_TEK_CHECK;") } if (oldVersion in 1 until 6) { Log.d(TAG, "Fixing invalid rssi values from version < 6") // There's no bluetooth chip with a sensitivity that would result in rssi -200, so this would be invalid. // RSSI of -100 is already extremely low and thus is a good "default" value db.execSQL("UPDATE $TABLE_ADVERTISEMENTS SET rssi = -100 WHERE rssi < -200;") } if (oldVersion in 5 until 7) { Log.d(TAG, "Clearing non-matching tek cache from version < 7") // Entries might be invalid due to previously missing support for new bluetooth AEM format db.execSQL("DELETE FROM $TABLE_TEK_CHECK_FILE WHERE tcfid NOT IN (SELECT tcfid FROM $TABLE_TEK_CHECK_FILE_MATCH);") } if (oldVersion in 1 until 9) { Log.d(TAG, "Migrating authorized apps from version < 9") val pm = context.packageManager db.query(true, TABLE_APP_LOG, arrayOf("package"), null, null, null, null, null, null).use { cursor -> while (cursor.moveToNext()) { val packageName = cursor.getString(0) val signatureDigest = PackageUtils.firstSignatureDigest(pm, packageName) if (signatureDigest != null) { db.insertWithOnConflict(TABLE_APP, "NULL", ContentValues().apply { put("package", packageName) put("sig", signatureDigest) }, CONFLICT_IGNORE) } } } } if (oldVersion == 9) { Log.d(TAG, "Get rid of isEnabled log entries") db.delete(TABLE_APP_LOG, "method = ?", arrayOf("isEnabled")); } Log.d(TAG, "Finished database upgrade") } fun SQLiteDatabase.delete(table: String, whereClause: String, args: LongArray): Int = compileStatement("DELETE FROM $table WHERE $whereClause").use { args.forEachIndexed { idx, l -> it.bindLong(idx + 1, l) } it.executeUpdateDelete() } fun dailyCleanup(): Boolean = writableDatabase.run { val start = System.currentTimeMillis() val rollingStartTime = currentDayRollingStartNumber.toLong() * ROLLING_WINDOW_LENGTH_MS - TimeUnit.DAYS.toMillis(KEEP_DAYS.toLong()) val advertisements = delete(TABLE_ADVERTISEMENTS, "timestamp < ?", longArrayOf(rollingStartTime)) Log.d(TAG, "Deleted on daily cleanup: $advertisements adv") if (start + MAX_DELETE_TIME < System.currentTimeMillis()) return@run false val appLogEntries = delete(TABLE_APP_LOG, "timestamp < ?", longArrayOf(rollingStartTime)) Log.d(TAG, "Deleted on daily cleanup: $appLogEntries applogs") if (start + MAX_DELETE_TIME < System.currentTimeMillis()) return@run false val temporaryExposureKeys = delete(TABLE_TEK, "(rollingStartNumber + rollingPeriod) < ?", longArrayOf(rollingStartTime / ROLLING_WINDOW_LENGTH_MS)) Log.d(TAG, "Deleted on daily cleanup: $temporaryExposureKeys teks") if (start + MAX_DELETE_TIME < System.currentTimeMillis()) return@run false val singleCheckedTemporaryExposureKeys = delete(TABLE_TEK_CHECK_SINGLE, "rollingStartNumber < ?", longArrayOf(rollingStartTime / ROLLING_WINDOW_LENGTH_MS - ROLLING_PERIOD)) Log.d(TAG, "Deleted on daily cleanup: $singleCheckedTemporaryExposureKeys tcss") if (start + MAX_DELETE_TIME < System.currentTimeMillis()) return@run false val fileCheckedTemporaryExposureKeys = delete(TABLE_TEK_CHECK_FILE, "endTimestamp < ?", longArrayOf(rollingStartTime)) Log.d(TAG, "Deleted on daily cleanup: $fileCheckedTemporaryExposureKeys tcfs") if (start + MAX_DELETE_TIME < System.currentTimeMillis()) return@run false val appPerms = delete(TABLE_APP_PERMS, "timestamp < ?", longArrayOf(System.currentTimeMillis() - CONFIRM_PERMISSION_VALIDITY)) Log.d(TAG, "Deleted on daily cleanup: $appPerms perms") if (start + MAX_DELETE_TIME < System.currentTimeMillis()) return@run false execSQL("VACUUM;") Log.d(TAG, "Done vacuuming") return@run true } fun grantPermission(packageName: String, signatureDigest: String, permission: String, timestamp: Long = System.currentTimeMillis()) = writableDatabase.run { insert(TABLE_APP_PERMS, "NULL", ContentValues().apply { put("package", packageName) put("sig", signatureDigest) put("perm", permission) put("timestamp", timestamp) }) } fun hasPermission(packageName: String, signatureDigest: String, permission: String, maxAge: Long = CONFIRM_PERMISSION_VALIDITY) = readableDatabase.run { query(TABLE_APP_PERMS, arrayOf("MAX(timestamp)"), "package = ? AND sig = ? and perm = ?", arrayOf(packageName, signatureDigest, permission), null, null, null).use { cursor -> cursor.moveToNext() && cursor.getLong(0) + maxAge > System.currentTimeMillis() } } fun noteAdvertisement(rpi: ByteArray, aem: ByteArray, rssi: Int, timestamp: Long = Date().time) = writableDatabase.run { val update = compileStatement("UPDATE $TABLE_ADVERTISEMENTS SET rssi = IFNULL(((rssi * duration) + (? * MAX(0, ? - timestamp - duration))) / MAX(duration, ? - timestamp), -100), duration = MAX(duration, ? - timestamp) WHERE rpi = ? AND timestamp > ? AND timestamp < ?").run { bindLong(1, rssi.toLong()) bindLong(2, timestamp) bindLong(3, timestamp) bindLong(4, timestamp) bindBlob(5, rpi) bindLong(6, timestamp - ALLOWED_KEY_OFFSET_MS) bindLong(7, timestamp + ALLOWED_KEY_OFFSET_MS) executeUpdateDelete() } if (update <= 0) { insert(TABLE_ADVERTISEMENTS, "NULL", ContentValues().apply { put("rpi", rpi) put("aem", aem) put("timestamp", timestamp) put("rssi", rssi) put("duration", MINIMUM_EXPOSURE_DURATION_MS) }) } } fun deleteAllCollectedAdvertisements() = writableDatabase.run { delete(TABLE_ADVERTISEMENTS, null, null) delete(TABLE_TEK_CHECK_FILE_MATCH, null, null) update(TABLE_TEK_CHECK_SINGLE, ContentValues().apply { put("matched", 0) }, null, null) } fun authorizeApp(packageName: String?, signatureDigest: String? = PackageUtils.firstSignatureDigest(context, packageName)) = writableDatabase.run { if (packageName == null || signatureDigest == null) return@run insertWithOnConflict(TABLE_APP, "NULL", ContentValues().apply { put("package", packageName) put("sig", signatureDigest) }, CONFLICT_IGNORE) } fun isAppAuthorized(packageName: String?, signatureDigest: String? = PackageUtils.firstSignatureDigest(context, packageName)): Boolean = readableDatabase.run { if (packageName == null || signatureDigest == null) return@run false query(TABLE_APP, arrayOf("package"), "package = ? AND sig = ?", arrayOf(packageName, signatureDigest), null, null, null).use { cursor -> return@use cursor.moveToNext() } } fun noteAppAction(packageName: String, method: String, args: String? = null, timestamp: Long = Date().time) = writableDatabase.run { insert(TABLE_APP_LOG, "NULL", ContentValues().apply { put("package", packageName) put("timestamp", timestamp) put("method", method) put("args", args) }) } private fun storeOwnKey(key: TemporaryExposureKey, database: SQLiteDatabase = writableDatabase) = database.run { insert(TABLE_TEK, "NULL", ContentValues().apply { put("keyData", key.keyData) put("rollingStartNumber", key.rollingStartIntervalNumber) put("rollingPeriod", key.rollingPeriod) }) } private fun getTekCheckSingleId(key: TemporaryExposureKey, mayInsert: Boolean = false, database: SQLiteDatabase = if (mayInsert) writableDatabase else readableDatabase): Long? = database.run { if (mayInsert) { insertWithOnConflict(TABLE_TEK_CHECK_SINGLE, "NULL", ContentValues().apply { put("keyData", key.keyData) put("rollingStartNumber", key.rollingStartIntervalNumber) put("rollingPeriod", key.rollingPeriod) }, CONFLICT_IGNORE) } compileStatement("SELECT tcsid FROM $TABLE_TEK_CHECK_SINGLE WHERE keyData = ? AND rollingStartNumber = ? AND rollingPeriod = ?").use { it.bindBlob(1, key.keyData) it.bindLong(2, key.rollingStartIntervalNumber.toLong()) it.bindLong(3, key.rollingPeriod.toLong()) it.simpleQueryForLong() } } fun getTokenId(packageName: String, token: String, database: SQLiteDatabase = readableDatabase) = database.run { query(TABLE_TOKENS, arrayOf("tid"), "package = ? AND token = ?", arrayOf(packageName, token), null, null, null, null).use { cursor -> if (cursor.moveToNext()) { cursor.getLong(0) } else { null } } } fun getOrCreateTokenId(packageName: String, token: String, database: SQLiteDatabase = writableDatabase) = database.run { val tid = getTokenId(packageName, token, this) if (tid != null) { tid } else { insert(TABLE_TOKENS, "NULL", ContentValues().apply { put("package", packageName) put("token", token) put("timestamp", System.currentTimeMillis()) }) getTokenId(packageName, token, this) } } private fun storeSingleDiagnosisKey(tid: Long, key: TemporaryExposureKey, database: SQLiteDatabase = writableDatabase) = database.run { val tcsid = getTekCheckSingleId(key, true, database) insert(TABLE_TEK_CHECK_SINGLE_TOKEN, "NULL", ContentValues().apply { put("tid", tid) put("tcsid", tcsid) put("transmissionRiskLevel", key.transmissionRiskLevel) put("reportType", key.reportType) put("daysSinceOnsetOfSymptoms", key.daysSinceOnsetOfSymptoms) }) } fun batchStoreSingleDiagnosisKey(tid: Long, keys: List, database: SQLiteDatabase = writableDatabase) = database.run { beginTransactionNonExclusive() try { keys.forEach { storeSingleDiagnosisKey(tid, it, database) } setTransactionSuccessful() } finally { endTransaction() } } fun getDiagnosisFileId(hash: ByteArray, database: SQLiteDatabase = readableDatabase) = database.run { val hexHash = ByteString.of(*hash).hex() query(TABLE_TEK_CHECK_FILE, arrayOf("tcfid"), "hash = ?", arrayOf(hexHash), null, null, null, null).use { cursor -> if (cursor.moveToNext()) { cursor.getLong(0) } else { null } } } fun storeDiagnosisFileUsed(tid: Long, tcfid: Long, database: SQLiteDatabase = writableDatabase) = database.run { insert(TABLE_TEK_CHECK_FILE_TOKEN, "NULL", ContentValues().apply { put("tid", tid) put("tcfid", tcfid) }) } fun storeDiagnosisFileUsed(tid: Long, hash: ByteArray, database: SQLiteDatabase = writableDatabase) = database.run { val hexHash = ByteString.of(*hash).hex() query(TABLE_TEK_CHECK_FILE, arrayOf("tcfid", "keys"), "hash = ?", arrayOf(hexHash), null, null, null, null).use { cursor -> if (cursor.moveToNext()) { insertWithOnConflict(TABLE_TEK_CHECK_FILE_TOKEN, "NULL", ContentValues().apply { put("tid", tid) put("tcfid", cursor.getLong(0)) }, CONFLICT_IGNORE) cursor.getLong(1) } else { null } } } private fun listSingleDiagnosisKeysPendingSearch(tid: Long, database: SQLiteDatabase = readableDatabase) = database.run { rawQuery(""" SELECT $TABLE_TEK_CHECK_SINGLE.keyData, $TABLE_TEK_CHECK_SINGLE.rollingStartNumber, $TABLE_TEK_CHECK_SINGLE.rollingPeriod FROM $TABLE_TEK_CHECK_SINGLE_TOKEN LEFT JOIN $TABLE_TEK_CHECK_SINGLE ON $TABLE_TEK_CHECK_SINGLE.tcsid = $TABLE_TEK_CHECK_SINGLE_TOKEN.tcsid WHERE $TABLE_TEK_CHECK_SINGLE_TOKEN.tid = ? AND $TABLE_TEK_CHECK_SINGLE.matched IS NULL """, arrayOf(tid.toString())).use { cursor -> val list = arrayListOf() while (cursor.moveToNext()) { list.add(TemporaryExposureKey.TemporaryExposureKeyBuilder() .setKeyData(cursor.getBlob(0)) .setRollingStartIntervalNumber(cursor.getLong(1).toInt()) .setRollingPeriod(cursor.getLong(2).toInt()) .build()) } list } } private fun applySingleDiagnosisKeySearchResult(key: TemporaryExposureKey, matched: Boolean, database: SQLiteDatabase = writableDatabase) = database.run { compileStatement("UPDATE $TABLE_TEK_CHECK_SINGLE SET matched = ? WHERE keyData = ? AND rollingStartNumber = ? AND rollingPeriod = ?;").use { it.bindLong(1, if (matched) 1 else 0) it.bindBlob(2, key.keyData) it.bindLong(3, key.rollingStartIntervalNumber.toLong()) it.bindLong(4, key.rollingPeriod.toLong()) it.executeUpdateDelete() } } private fun applyDiagnosisFileKeySearchResult(tcfid: Long, key: TemporaryExposureKey, database: SQLiteDatabase = writableDatabase) = database.run { insert(TABLE_TEK_CHECK_FILE_MATCH, "NULL", ContentValues().apply { put("tcfid", tcfid) put("keyData", key.keyData) put("rollingStartNumber", key.rollingStartIntervalNumber) put("rollingPeriod", key.rollingPeriod) put("transmissionRiskLevel", key.transmissionRiskLevel) put("reportType", key.reportType) put("daysSinceOnsetOfSymptoms", key.daysSinceOnsetOfSymptoms) }) } private fun listMatchedSingleDiagnosisKeys(tid: Long, database: SQLiteDatabase = readableDatabase) = database.run { rawQuery(""" SELECT $TABLE_TEK_CHECK_SINGLE.keyData, $TABLE_TEK_CHECK_SINGLE.rollingStartNumber, $TABLE_TEK_CHECK_SINGLE.rollingPeriod, $TABLE_TEK_CHECK_SINGLE_TOKEN.transmissionRiskLevel, $TABLE_TEK_CHECK_SINGLE_TOKEN.reportType, $TABLE_TEK_CHECK_SINGLE_TOKEN.daysSinceOnsetOfSymptoms FROM $TABLE_TEK_CHECK_SINGLE_TOKEN JOIN $TABLE_TEK_CHECK_SINGLE ON $TABLE_TEK_CHECK_SINGLE.tcsid = $TABLE_TEK_CHECK_SINGLE_TOKEN.tcsid WHERE $TABLE_TEK_CHECK_SINGLE_TOKEN.tid = ? AND $TABLE_TEK_CHECK_SINGLE.matched = 1 """, arrayOf(tid.toString())).use { cursor -> val list = arrayListOf() while (cursor.moveToNext()) { list.add(TemporaryExposureKey.TemporaryExposureKeyBuilder() .setKeyData(cursor.getBlob(0)) .setRollingStartIntervalNumber(cursor.getLong(1).toInt()) .setRollingPeriod(cursor.getLong(2).toInt()) .setTransmissionRiskLevel(cursor.getLong(3).toInt()) .setReportType(cursor.getLong(4).toInt()) .setDaysSinceOnsetOfSymptoms(cursor.getLong(5).toInt()) .build()) } list } } private fun listMatchedFileDiagnosisKeys(tid: Long, database: SQLiteDatabase = readableDatabase) = database.run { rawQuery(""" SELECT $TABLE_TEK_CHECK_FILE_MATCH.keyData, $TABLE_TEK_CHECK_FILE_MATCH.rollingStartNumber, $TABLE_TEK_CHECK_FILE_MATCH.rollingPeriod, $TABLE_TEK_CHECK_FILE_MATCH.transmissionRiskLevel, $TABLE_TEK_CHECK_FILE_MATCH.reportType, $TABLE_TEK_CHECK_FILE_MATCH.daysSinceOnsetOfSymptoms FROM $TABLE_TEK_CHECK_FILE_TOKEN JOIN $TABLE_TEK_CHECK_FILE_MATCH ON $TABLE_TEK_CHECK_FILE_MATCH.tcfid = $TABLE_TEK_CHECK_FILE_TOKEN.tcfid WHERE $TABLE_TEK_CHECK_FILE_TOKEN.tid = ? """, arrayOf(tid.toString())).use { cursor -> val list = arrayListOf() while (cursor.moveToNext()) { list.add(TemporaryExposureKey.TemporaryExposureKeyBuilder() .setKeyData(cursor.getBlob(0)) .setRollingStartIntervalNumber(cursor.getLong(1).toInt()) .setRollingPeriod(cursor.getLong(2).toInt()) .setTransmissionRiskLevel(cursor.getLong(3).toInt()) .setReportType(cursor.getLong(4).toInt()) .setDaysSinceOnsetOfSymptoms(cursor.getLong(5).toInt()) .build()) } list } } fun finishSingleMatching(tid: Long, database: SQLiteDatabase = writableDatabase): Int { val workQueue = LinkedBlockingQueue() val poolSize = Runtime.getRuntime().availableProcessors() val executor = ThreadPoolExecutor(poolSize, poolSize, 1, TimeUnit.SECONDS, workQueue) val futures = arrayListOf>() val keys = listSingleDiagnosisKeysPendingSearch(tid, database) val oldestRpi = oldestRpi for (key in keys) { if ((key.rollingStartIntervalNumber + key.rollingPeriod).toLong() * ROLLING_WINDOW_LENGTH_MS + ALLOWED_KEY_OFFSET_MS < oldestRpi) { // Early ignore because key is older than since we started scanning. applySingleDiagnosisKeySearchResult(key, false, database) } else { futures.add(executor.submit { applySingleDiagnosisKeySearchResult(key, findMeasuredExposures(key).isNotEmpty(), database) }) } } for (future in futures) { future.get() } executor.shutdown() return keys.size } fun finishFileMatching(tid: Long, hash: ByteArray, endTimestamp: Long, keys: List, updates: List, database: SQLiteDatabase = writableDatabase) = database.run { beginTransactionNonExclusive() try { insert(TABLE_TEK_CHECK_FILE, "NULL", ContentValues().apply { put("hash", ByteString.of(*hash).hex()) put("endTimestamp", endTimestamp) put("keys", keys.size + updates.size) }) val tcfid = getDiagnosisFileId(hash, this) ?: return val workQueue = LinkedBlockingQueue() val poolSize = Runtime.getRuntime().availableProcessors() val executor = ThreadPoolExecutor(poolSize, poolSize, 1, TimeUnit.SECONDS, workQueue) val futures = arrayListOf>() val oldestRpi = oldestRpi var ignored = 0 var processed = 0 var found = 0 var riskLogged = -1 var startLogged = -1 for (key in keys) { if (key.transmissionRiskLevel > riskLogged || key.rollingStartIntervalNumber > startLogged) { riskLogged = key.transmissionRiskLevel startLogged = key.rollingStartIntervalNumber Log.d(TAG, "First key with risk ${key.transmissionRiskLevel}: ${ByteString.of(*key.keyData).hex()} starts ${key.rollingStartIntervalNumber}") } if ((key.rollingStartIntervalNumber + key.rollingPeriod).toLong() * ROLLING_WINDOW_LENGTH_MS + ALLOWED_KEY_OFFSET_MS < oldestRpi) { // Early ignore because key is older than since we started scanning. ignored++; } else { futures.add(executor.submit(Callable { processed++ if (findMeasuredExposures(key).isNotEmpty()) { key } else { null } })) } } for (future in futures) { future.get()?.let { applyDiagnosisFileKeySearchResult(tcfid, it, this) found++ } } Log.d(TAG, "Processed $processed keys, found $found matches, ignored $ignored keys that are older than our scanning efforts ($oldestRpi)") executor.shutdown() for (update in updates) { val matched = compileStatement("SELECT COUNT(tcsid) FROM $TABLE_TEK_CHECK_FILE_MATCH WHERE keyData = ? AND rollingStartNumber = ? AND rollingPeriod = ?").use { it.bindBlob(1, update.keyData) it.bindLong(2, update.rollingStartIntervalNumber.toLong()) it.bindLong(3, update.rollingPeriod.toLong()) it.simpleQueryForLong() } if (matched > 0) { applyDiagnosisFileKeySearchResult(tcfid, update, this) } } insert(TABLE_TEK_CHECK_FILE_TOKEN, "NULL", ContentValues().apply { put("tid", tid) put("tcfid", tcfid) }) setTransactionSuccessful() } finally { endTransaction() } } private fun findAllSingleMeasuredExposures(tid: Long, database: SQLiteDatabase = readableDatabase): List { return listMatchedSingleDiagnosisKeys(tid, database).flatMap { findMeasuredExposures(it, database) } } private fun findAllFileMeasuredExposures(tid: Long, database: SQLiteDatabase = readableDatabase): List { return listMatchedFileDiagnosisKeys(tid, database).flatMap { findMeasuredExposures(it, database) } } fun findAllMeasuredExposures(tid: Long, database: SQLiteDatabase = readableDatabase) = findAllSingleMeasuredExposures(tid, database) + findAllFileMeasuredExposures(tid, database) private fun findMeasuredExposures(key: TemporaryExposureKey, database: SQLiteDatabase = readableDatabase): List { val allRpis = key.generateAllRpiIds() val rpis = (0 until key.rollingPeriod).map { i -> val pos = i * 16 allRpis.sliceArray(pos until (pos + 16)) } val measures = findExposures(rpis, key.rollingStartIntervalNumber.toLong() * ROLLING_WINDOW_LENGTH_MS - ALLOWED_KEY_OFFSET_MS, (key.rollingStartIntervalNumber.toLong() + key.rollingPeriod) * ROLLING_WINDOW_LENGTH_MS + ALLOWED_KEY_OFFSET_MS, database) return measures.filter { val index = rpis.indexOfFirst { rpi -> rpi.contentEquals(it.rpi) } val targetTimestamp = (key.rollingStartIntervalNumber + index).toLong() * ROLLING_WINDOW_LENGTH_MS it.timestamp >= targetTimestamp - ALLOWED_KEY_OFFSET_MS && it.timestamp <= targetTimestamp + ROLLING_WINDOW_LENGTH_MS + ALLOWED_KEY_OFFSET_MS }.mapNotNull { val decrypted = key.cryptAem(it.rpi, it.aem) val version = (decrypted[0] and 0xf0.toByte()) val txPower = if (decrypted.size >= 4 && version >= VERSION_1_0) decrypted[1].toInt() else averageDeviceInfo.txPowerCorrection.toInt() val confidence = if (decrypted.size >= 4 && version >= VERSION_1_1) ((decrypted[0] and 0xc) / 4) else (averageDeviceInfo.confidence) if (version > VERSION_1_1) { Log.w(TAG, "Unknown AEM version: 0x${version.toString(16)}") } MeasuredExposure(it.timestamp, it.duration, it.rssi, txPower, confidence, key) } } private fun findExposures(rpis: List, minTime: Long, maxTime: Long, database: SQLiteDatabase = readableDatabase): List = database.run { if (rpis.isEmpty()) return emptyList() val qs = rpis.map { "?" }.joinToString(",") queryWithFactory({ _, cursorDriver, editTable, query -> query.bindLong(1, minTime) query.bindLong(2, maxTime) rpis.forEachIndexed { index, rpi -> query.bindBlob(index + 3, rpi) } SQLiteCursor(cursorDriver, editTable, query) }, false, TABLE_ADVERTISEMENTS, arrayOf("rpi", "aem", "timestamp", "duration", "rssi"), "timestamp > ? AND timestamp < ? AND rpi IN ($qs)", null, null, null, null, null).use { cursor -> val list = arrayListOf() while (cursor.moveToNext()) { list.add(PlainExposure(cursor.getBlob(0), cursor.getBlob(1), cursor.getLong(2), cursor.getLong(3), cursor.getInt(4))) } list } } fun findExposure(rpi: ByteArray, minTime: Long, maxTime: Long): PlainExposure? = readableDatabase.run { queryWithFactory({ _, cursorDriver, editTable, query -> query.bindBlob(1, rpi) query.bindLong(2, minTime) query.bindLong(3, maxTime) SQLiteCursor(cursorDriver, editTable, query) }, false, TABLE_ADVERTISEMENTS, arrayOf("aem", "timestamp", "duration", "rssi"), "rpi = ? AND timestamp > ? AND timestamp < ?", null, null, null, null, null).use { cursor -> if (cursor.moveToNext()) { PlainExposure(rpi, cursor.getBlob(0), cursor.getLong(1), cursor.getLong(2), cursor.getInt(3)) } else { null } } } private fun findOwnKeyAt(intervalNumber: Int, database: SQLiteDatabase = readableDatabase): TemporaryExposureKey? = database.run { val dayRollingStartNumber = getDayRollingStartNumber(intervalNumber) query(TABLE_TEK, arrayOf("keyData", "rollingStartNumber", "rollingPeriod"), "rollingStartNumber >= ? AND (rollingStartNumber + rollingPeriod) < ?", arrayOf(dayRollingStartNumber.toString(), intervalNumber.toString()), null, null, "rollingStartNumber DESC").use { cursor -> if (cursor.moveToNext()) { TemporaryExposureKey.TemporaryExposureKeyBuilder() .setKeyData(cursor.getBlob(0)) .setRollingStartIntervalNumber(cursor.getLong(1).toInt()) .setRollingPeriod(cursor.getLong(2).toInt()) .build() } else { null } } } fun Parcelable.marshall(): ByteArray { val parcel = Parcel.obtain() writeToParcel(parcel, 0) val bytes = parcel.marshall() parcel.recycle() return bytes } fun Parcelable.Creator.unmarshall(data: ByteArray): T { val parcel = Parcel.obtain() parcel.unmarshall(data, 0, data.size) parcel.setDataPosition(0) val res = createFromParcel(parcel) parcel.recycle() return res } fun storeConfiguration(packageName: String, token: String, configuration: ExposureConfiguration, database: SQLiteDatabase = writableDatabase) = database.run { val update = update(TABLE_TOKENS, ContentValues().apply { put("configuration", configuration.marshall()) }, "package = ? AND token = ?", arrayOf(packageName, token)) if (update <= 0) { insert(TABLE_TOKENS, "NULL", ContentValues().apply { put("package", packageName) put("token", token) put("timestamp", System.currentTimeMillis()) put("configuration", configuration.marshall()) }) } getTokenId(packageName, token, database) } fun storeConfiguration(packageName: String, token: String, mapping: DiagnosisKeysDataMapping, database: SQLiteDatabase = writableDatabase) = database.run { val update = update(TABLE_TOKENS, ContentValues().apply { put("diagnosisKeysDataMap", mapping.marshall()) }, "package = ? AND token = ?", arrayOf(packageName, token)) if (update <= 0) { insert(TABLE_TOKENS, "NULL", ContentValues().apply { put("package", packageName) put("token", token) put("timestamp", System.currentTimeMillis()) put("diagnosisKeysDataMap", mapping.marshall()) }) } getTokenId(packageName, token, database) } fun loadConfiguration(packageName: String, token: String, database: SQLiteDatabase = readableDatabase): Triple? = database.run { query(TABLE_TOKENS, arrayOf("tid", "configuration", "diagnosisKeysDataMap"), "package = ? AND token = ?", arrayOf(packageName, token), null, null, null, null).use { cursor -> if (cursor.moveToNext()) { val configuration = try {ExposureConfiguration.CREATOR.unmarshall(cursor.getBlob(1)) } catch (e: Exception) { null } val diagnosisKeysDataMapping = try { DiagnosisKeysDataMapping.CREATOR.unmarshall(cursor.getBlob(2)) } catch (e: Exception) { null } Triple(cursor.getLong(0), configuration, diagnosisKeysDataMapping) } else { null } } } fun exportKeys(database: SQLiteDatabase = writableDatabase): List = database.run { database.beginTransactionNonExclusive() try { val intervalNumber = currentIntervalNumber val key = findOwnKeyAt(intervalNumber, database) if (key != null && intervalNumber != key.rollingStartIntervalNumber) { // Rotate key update(TABLE_TEK, ContentValues().apply { put("rollingPeriod", intervalNumber - key.rollingStartIntervalNumber) }, "rollingStartNumber = ?", arrayOf(key.rollingStartIntervalNumber.toString())) storeOwnKey(generateCurrentDayTemporaryExposureKey(), database) } database.setTransactionSuccessful() val startRollingNumber = (getDayRollingStartNumber(intervalNumber) - 14 * ROLLING_PERIOD) query(TABLE_TEK, arrayOf("keyData", "rollingStartNumber", "rollingPeriod"), "rollingStartNumber >= ? AND (rollingStartNumber + rollingPeriod) <= ?", arrayOf(startRollingNumber.toString(), intervalNumber.toString()), null, null, null).use { cursor -> val list = arrayListOf() while (cursor.moveToNext()) { list.add(TemporaryExposureKey.TemporaryExposureKeyBuilder() .setKeyData(cursor.getBlob(0)) .setRollingStartIntervalNumber(cursor.getLong(1).toInt()) .setRollingPeriod(cursor.getLong(2).toInt()) .build()) } list } } finally { database.endTransaction() } } val rpiHourHistogram: Set get() = readableDatabase.run { rawQuery("SELECT round(timestamp/(60*60*1000))*60*60*1000, COUNT(*), COUNT(*) FROM $TABLE_ADVERTISEMENTS WHERE timestamp > ? GROUP BY round(timestamp/(60*60*1000)) ORDER BY timestamp ASC;", arrayOf((System.currentTimeMillis() - (14 * 24 * 60 * 60 * 1000L)).toString())).use { cursor -> val set = hashSetOf() while (cursor.moveToNext()) { set.add(ExposureScanSummary(cursor.getLong(0), cursor.getInt(1), cursor.getInt(2))) } set } } val hourRpiCount: Long get() = readableDatabase.run { rawQuery("SELECT COUNT(*) FROM $TABLE_ADVERTISEMENTS WHERE timestamp > ?;", arrayOf((Date().time - (60 * 60 * 1000)).toString())).use { cursor -> if (cursor.moveToNext()) { cursor.getLong(0) } else { 0L } } } val oldestRpi: Long get() = readableDatabase.run { query(TABLE_ADVERTISEMENTS, arrayOf("MIN(timestamp)"), null, null, null, null, null).use { cursor -> if (cursor.moveToNext()) { cursor.getLong(0).let { if (it == 0L) System.currentTimeMillis() else it } } else { System.currentTimeMillis() } } } val appList: List get() = readableDatabase.run { query(true, TABLE_APP_LOG, arrayOf("package"), null, null, null, null, "timestamp DESC", null).use { cursor -> val list = arrayListOf() while (cursor.moveToNext()) { list.add(cursor.getString(0)) } list } } fun countMethodCalls(packageName: String, method: String): Int = readableDatabase.run { query(TABLE_APP_LOG, arrayOf("COUNT(*)"), "package = ? AND method = ? AND timestamp > ?", arrayOf(packageName, method, (System.currentTimeMillis() - TimeUnit.DAYS.toMillis(KEEP_DAYS.toLong())).toString()), null, null, null, null).use { cursor -> if (cursor.moveToNext()) { cursor.getInt(0) } else { 0 } } } fun lastMethodCall(packageName: String, method: String): Long? = readableDatabase.run { query(TABLE_APP_LOG, arrayOf("MAX(timestamp)"), "package = ? AND method = ?", arrayOf(packageName, method), null, null, null, null).use { cursor -> if (cursor.moveToNext()) { cursor.getLong(0) } else { null } } } fun lastMethodCallArgs(packageName: String, method: String): String? = readableDatabase.run { query(TABLE_APP_LOG, arrayOf("args"), "package = ? AND method = ?", arrayOf(packageName, method), null, null, "timestamp DESC", "1").use { cursor -> if (cursor.moveToNext()) { cursor.getString(0) } else { null } } } fun countDiagnosisKeysInvolved(tid: Long): Long = readableDatabase.run { val fromFile = rawQuery("SELECT SUM($TABLE_TEK_CHECK_FILE.keys) AS keys FROM $TABLE_TEK_CHECK_FILE_TOKEN JOIN $TABLE_TEK_CHECK_FILE ON $TABLE_TEK_CHECK_FILE_TOKEN.tcfid = $TABLE_TEK_CHECK_FILE.tcfid WHERE $TABLE_TEK_CHECK_FILE_TOKEN.tid = $tid;", null).use { cursor -> if (cursor.moveToNext()) { cursor.getLong(0) } else { 0 } } val single = rawQuery("SELECT COUNT(*) as keys FROM $TABLE_TEK_CHECK_SINGLE_TOKEN WHERE $TABLE_TEK_CHECK_SINGLE_TOKEN.tid = $tid;", null).use { cursor -> if (cursor.moveToNext()) { cursor.getLong(0) } else { 0 } } return fromFile + single } fun methodUsageHistogram(packageName: String): List> = readableDatabase.run { val list = arrayListOf>() rawQuery("SELECT method, COUNT(*) AS count FROM $TABLE_APP_LOG WHERE package = ? GROUP BY method;", arrayOf(packageName)).use { cursor -> while (cursor.moveToNext()) { list.add(cursor.getString(0) to cursor.getInt(1)) } } list.sortedByDescending { it.second } } private fun ensureTemporaryExposureKey(): TemporaryExposureKey = writableDatabase.let { database -> database.beginTransactionNonExclusive() try { var key = findOwnKeyAt(currentIntervalNumber, database) if (key == null) { key = generateCurrentDayTemporaryExposureKey() storeOwnKey(key, database) } database.setTransactionSuccessful() key } finally { database.endTransaction() } } val currentRpiId: UUID? get() { val key = findOwnKeyAt(currentIntervalNumber) ?: return null val buffer = ByteBuffer.wrap(key.generateRpiId(currentIntervalNumber)) return UUID(buffer.long, buffer.long) } fun generateCurrentPayload(metadata: ByteArray) = ensureTemporaryExposureKey().generatePayload(currentIntervalNumber, metadata) override fun getWritableDatabase(): SQLiteDatabase { requirePrimary(this) return super.getWritableDatabase() } @Synchronized fun ref(): ExposureDatabase { refCount++ return this } @Synchronized fun unref() { refCount-- if (refCount == 0) { clearInstance(this) close() } else if (refCount < 0) { throw IllegalStateException("ref/unref mismatch") } } companion object { private const val DB_NAME = "exposure.db" private const val DB_VERSION = 11 private const val DB_SIZE_TOO_LARGE = 256L * 1024 * 1024 private const val MAX_DELETE_TIME = 5000L private const val TABLE_ADVERTISEMENTS = "advertisements" private const val TABLE_APP = "app" private const val TABLE_APP_LOG = "app_log" private const val TABLE_TEK = "tek" private const val TABLE_APP_PERMS = "app_perms" private const val TABLE_TOKENS = "tokens" private const val TABLE_TEK_CHECK_SINGLE = "tek_check_single" private const val TABLE_TEK_CHECK_SINGLE_TOKEN = "tek_check_single_token" private const val TABLE_TEK_CHECK_FILE = "tek_check_file" private const val TABLE_TEK_CHECK_FILE_TOKEN = "tek_check_file_token" private const val TABLE_TEK_CHECK_FILE_MATCH = "tek_check_file_match" @Deprecated(message = "No longer supported") private const val TABLE_TEK_CHECK = "tek_check" @Deprecated(message = "No longer supported") private const val TABLE_DIAGNOSIS = "diagnosis" @Deprecated(message = "No longer supported") private const val TABLE_CONFIGURATIONS = "configurations" private var deferredInstance: Deferred? = null private var deferredRefCount: Int = 0 private var instance: ExposureDatabase? = null @Synchronized private fun requirePrimary(database: ExposureDatabase) { if (database != instance) { throw IllegalStateException("Operation requires ${database.hashCode()} to be a primary database instance, but ${instance?.hashCode()} is primary", database.createdAt) } } @Synchronized private fun clearInstance(database: ExposureDatabase, errorOnNull: Boolean = true) { if (database == instance) { if (deferredRefCount == 0) { deferredInstance = null instance = null } } else if (errorOnNull || instance != null) { throw IllegalStateException("Tried to remove database instance ${database.hashCode()}, but ${instance?.hashCode()} is primary", database.createdAt) } } @Synchronized private fun getDeferredInstance(): Pair, Boolean> { val deferredInstance = deferredInstance deferredRefCount++ return when { deferredInstance != null -> deferredInstance to false instance != null -> throw IllegalStateException("No deferred database instance, but instance ${instance?.hashCode()} is primary", instance?.createdAt) else -> { val newInstance = CompletableDeferred() this.deferredInstance = newInstance newInstance to true } } } @Synchronized private fun unrefDeferredInstance() { deferredRefCount--; } @Synchronized private fun completeInstance(database: ExposureDatabase) { if (instance != null) { throw IllegalStateException("Tried to make ${database.hashCode()} the primary, but ${instance?.hashCode()} is currently primary", instance?.createdAt) } instance = database } private fun prepareDatabaseMigration(context: Context): Pair { val dbFile = context.getDatabasePath(DB_NAME) val dbWalFile = context.getDatabasePath("$DB_NAME-wal") val dbMigrateFile = context.getDatabasePath("$DB_NAME-migrate") val dbMigrateWalFile = context.getDatabasePath("$DB_NAME-migrate-wal") if (dbFile.length() + dbWalFile.length() > DB_SIZE_TOO_LARGE) { Log.d(TAG, "Database file is larger than $DB_SIZE_TOO_LARGE, force clean up") if (dbFile.exists()) dbFile.renameTo(dbMigrateFile) if (dbWalFile.exists()) dbWalFile.renameTo(dbMigrateWalFile) } return dbMigrateFile to dbMigrateWalFile } private fun finishDatabaseMigration(database: ExposureDatabase, dbMigrateFile: File, dbMigrateWalFile: File) { if (dbMigrateFile.exists()) { val writableDatabase = database.writableDatabase writableDatabase.execSQL("ATTACH DATABASE '${dbMigrateFile.absolutePath}' AS old;") writableDatabase.beginTransaction() try { Log.d(TAG, "Migrating advertisements and TEKs from old database file") writableDatabase.execSQL("INSERT INTO $TABLE_ADVERTISEMENTS SELECT * FROM old.$TABLE_ADVERTISEMENTS;") writableDatabase.execSQL("INSERT INTO $TABLE_TEK SELECT * FROM old.$TABLE_TEK;") Log.d(TAG, "Migration finished successfully") writableDatabase.setTransactionSuccessful() } finally { writableDatabase.endTransaction() writableDatabase.execSQL("DETACH DATABASE old;") } } dbMigrateFile.delete() dbMigrateWalFile.delete() } suspend fun ref(context: Context): ExposureDatabase { val (instance, new) = getDeferredInstance() try { if (new) { val newInstance = instance as CompletableDeferred try { val (dbMigrateFile, dbMigrateWalFile) = prepareDatabaseMigration(context) val database = ExposureDatabase(context.applicationContext) try { completeInstance(database) finishDatabaseMigration(database, dbMigrateFile, dbMigrateWalFile) newInstance.complete(database) return database } catch (e: Exception) { clearInstance(database, false) database.close() throw e } } catch (e: Exception) { newInstance.completeExceptionally(e) throw e; } } else { return instance.await().ref() } } finally { unrefDeferredInstance() } } fun export(context: Context) { // FileProvider cannot directly access /databases, so we have to copy the database first // In addition, we filter out only the Advertisements table to not export sensitive TEKs // Create a new database which will store only the Advertisements table val exportDir = File(context.getCacheDir(), "exposureDatabase") exportDir.mkdir() val exportFile = File(exportDir, "exposure.db") if (exportFile.delete()) { Log.d("EN-DB-Exporter", "Deleted old export database.") } val db = SQLiteDatabase.openOrCreateDatabase(exportFile, null) // Attach the full database to the new empty db val databaseFile = context.getDatabasePath(DB_NAME); db.execSQL("ATTACH '$databaseFile' AS fulldb;") // copy TABLE_ADVERTISEMENTS over db.execSQL("CREATE TABLE $TABLE_ADVERTISEMENTS AS SELECT * FROM fulldb.$TABLE_ADVERTISEMENTS;") // Detach original db, close new db db.execSQL("DETACH DATABASE fulldb;") db.close() // Use the FileProvider to get a content URI for the new DB val fileUri: Uri? = try { FileProvider.getUriForFile(context,"${context.packageName}.microg.exposure.export", exportFile) } catch (e: IllegalArgumentException) { Log.e("EN-DB-Exporter", "The database file can't be shared: $exportFile $e") null } // Open a sharesheet if (fileUri != null) { // Grant temporary read permission to the content URI val sendIntent: Intent = Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_STREAM, fileUri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) type = "application/vnd.microg.exposure+sqlite3" } val shareIntent = Intent.createChooser(sendIntent, null) context.startActivity(shareIntent) } } @Deprecated(message = "Sync database access is slow", replaceWith = ReplaceWith("with(context, call)")) fun withSync(context: Context, call: (ExposureDatabase) -> T): T { val it = runBlocking { ref(context) } try { return call(it) } finally { it.unref() } } suspend fun with(context: Context, call: suspend (ExposureDatabase) -> T): T = withContext(Dispatchers.IO) { val it = ref(context) try { call(it) } finally { it.unref() } } } }