diff --git a/play-services-nearby-core-ui/src/main/kotlin/org/microg/gms/nearby/core/ui/ExposureNotificationsAppPreferencesFragment.kt b/play-services-nearby-core-ui/src/main/kotlin/org/microg/gms/nearby/core/ui/ExposureNotificationsAppPreferencesFragment.kt index 51327995..aa71ecb5 100644 --- a/play-services-nearby-core-ui/src/main/kotlin/org/microg/gms/nearby/core/ui/ExposureNotificationsAppPreferencesFragment.kt +++ b/play-services-nearby-core-ui/src/main/kotlin/org/microg/gms/nearby/core/ui/ExposureNotificationsAppPreferencesFragment.kt @@ -8,6 +8,7 @@ package org.microg.gms.nearby.core.ui import android.content.Intent import android.os.Bundle import android.text.format.DateUtils +import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import org.json.JSONObject @@ -48,26 +49,28 @@ class ExposureNotificationsAppPreferencesFragment : PreferenceFragmentCompat() { fun updateContent() { packageName?.let { packageName -> - ExposureDatabase.with(requireContext()) { database -> - var str = getString(R.string.pref_exposure_app_checks_summary, database.countMethodCalls(packageName, "provideDiagnosisKeys")) - val lastCheckTime = database.lastMethodCall(packageName, "provideDiagnosisKeys") - if (lastCheckTime != null && lastCheckTime != 0L) { - str += "\n" + getString(R.string.pref_exposure_app_last_check_summary, DateUtils.getRelativeDateTimeString(context, lastCheckTime, DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, DateUtils.FORMAT_SHOW_TIME)) - } - val lastExposureSummaryTime = database.lastMethodCall(packageName, "getExposureSummary") - val lastExposureSummary = database.lastMethodCallArgs(packageName, "getExposureSummary") - if (lastExposureSummaryTime != null && lastExposureSummary != null && System.currentTimeMillis() - lastExposureSummaryTime <= TimeUnit.DAYS.toMillis(1)) { - try { - val json = JSONObject(lastExposureSummary) - val matchedKeys = json.optInt("response_matched_keys") - val daysSince = json.optInt("response_days_since", -1) - if (matchedKeys > 0 && daysSince >= 0) { - str += "\n" + resources.getQuantityString(R.plurals.pref_exposure_app_last_report_summary, matchedKeys, matchedKeys, daysSince) - } - } catch (ignored: Exception) { + lifecycleScope.launchWhenResumed { + checks.summary = ExposureDatabase.with(requireContext()) { database -> + var str = getString(R.string.pref_exposure_app_checks_summary, database.countMethodCalls(packageName, "provideDiagnosisKeys")) + val lastCheckTime = database.lastMethodCall(packageName, "provideDiagnosisKeys") + if (lastCheckTime != null && lastCheckTime != 0L) { + str += "\n" + getString(R.string.pref_exposure_app_last_check_summary, DateUtils.getRelativeDateTimeString(context, lastCheckTime, DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, DateUtils.FORMAT_SHOW_TIME)) } + val lastExposureSummaryTime = database.lastMethodCall(packageName, "getExposureSummary") + val lastExposureSummary = database.lastMethodCallArgs(packageName, "getExposureSummary") + if (lastExposureSummaryTime != null && lastExposureSummary != null && System.currentTimeMillis() - lastExposureSummaryTime <= TimeUnit.DAYS.toMillis(1)) { + try { + val json = JSONObject(lastExposureSummary) + val matchedKeys = json.optInt("response_matched_keys") + val daysSince = json.optInt("response_days_since", -1) + if (matchedKeys > 0 && daysSince >= 0) { + str += "\n" + resources.getQuantityString(R.plurals.pref_exposure_app_last_report_summary, matchedKeys, matchedKeys, daysSince) + } + } catch (ignored: Exception) { + } + } + str } - checks.summary = str } } } diff --git a/play-services-nearby-core-ui/src/main/kotlin/org/microg/gms/nearby/core/ui/ExposureNotificationsPreferencesFragment.kt b/play-services-nearby-core-ui/src/main/kotlin/org/microg/gms/nearby/core/ui/ExposureNotificationsPreferencesFragment.kt index 03bef33d..8ca0e269 100644 --- a/play-services-nearby-core-ui/src/main/kotlin/org/microg/gms/nearby/core/ui/ExposureNotificationsPreferencesFragment.kt +++ b/play-services-nearby-core-ui/src/main/kotlin/org/microg/gms/nearby/core/ui/ExposureNotificationsPreferencesFragment.kt @@ -74,28 +74,26 @@ class ExposureNotificationsPreferencesFragment : PreferenceFragmentCompat() { lifecycleScope.launchWhenResumed { handler.postDelayed(updateContentRunnable, UPDATE_CONTENT_INTERVAL) val context = requireContext() - val (apps, lastHourKeys, currentId) = withContext(Dispatchers.IO) { - ExposureDatabase.with(context) { database -> - val apps = database.appList.map { packageName -> - context.packageManager.getApplicationInfoIfExists(packageName) - }.filterNotNull().mapIndexed { idx, applicationInfo -> - val pref = AppIconPreference(context) - pref.order = idx - pref.title = applicationInfo.loadLabel(context.packageManager) - pref.icon = applicationInfo.loadIcon(context.packageManager) - pref.onPreferenceClickListener = Preference.OnPreferenceClickListener { - findNavController().navigate(requireContext(), R.id.openExposureAppDetails, bundleOf( - "package" to applicationInfo.packageName - )) - true - } - pref.key = "pref_exposure_app_" + applicationInfo.packageName - pref + val (apps, lastHourKeys, currentId) = ExposureDatabase.with(context) { database -> + val apps = database.appList.map { packageName -> + context.packageManager.getApplicationInfoIfExists(packageName) + }.filterNotNull().mapIndexed { idx, applicationInfo -> + val pref = AppIconPreference(context) + pref.order = idx + pref.title = applicationInfo.loadLabel(context.packageManager) + pref.icon = applicationInfo.loadIcon(context.packageManager) + pref.onPreferenceClickListener = Preference.OnPreferenceClickListener { + findNavController().navigate(requireContext(), R.id.openExposureAppDetails, bundleOf( + "package" to applicationInfo.packageName + )) + true } - val lastHourKeys = database.hourRpiCount - val currentId = database.currentRpiId - Triple(apps, lastHourKeys, currentId) + pref.key = "pref_exposure_app_" + applicationInfo.packageName + pref } + val lastHourKeys = database.hourRpiCount + val currentId = database.currentRpiId + Triple(apps, lastHourKeys, currentId) } collectedRpis.summary = getString(R.string.pref_exposure_collected_rpis_summary, lastHourKeys) if (currentId != null) { diff --git a/play-services-nearby-core-ui/src/main/kotlin/org/microg/gms/nearby/core/ui/ExposureNotificationsRpisFragment.kt b/play-services-nearby-core-ui/src/main/kotlin/org/microg/gms/nearby/core/ui/ExposureNotificationsRpisFragment.kt index 4ff1d8ee..387f491c 100644 --- a/play-services-nearby-core-ui/src/main/kotlin/org/microg/gms/nearby/core/ui/ExposureNotificationsRpisFragment.kt +++ b/play-services-nearby-core-ui/src/main/kotlin/org/microg/gms/nearby/core/ui/ExposureNotificationsRpisFragment.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.withContext import org.microg.gms.nearby.exposurenotification.ExposureDatabase import java.util.* import kotlin.math.roundToInt +import kotlin.math.roundToLong @TargetApi(21) class ExposureNotificationsRpisFragment : PreferenceFragmentCompat() { @@ -39,33 +40,31 @@ class ExposureNotificationsRpisFragment : PreferenceFragmentCompat() { fun updateChart() { lifecycleScope.launchWhenResumed { - val (totalRpiCount, rpiHistogram) = withContext(Dispatchers.IO) { - ExposureDatabase.with(requireContext()) { database -> - val map = linkedMapOf() - val lowestDate = Math.round((System.currentTimeMillis() / 24 / 60 / 60 / 1000 - 13).toDouble()) * 24 * 60 * 60 * 1000 - for (i in 0..13) { - val date = Calendar.getInstance().apply { this.time = Date(lowestDate + i * 24 * 60 * 60 * 1000) }.get(Calendar.DAY_OF_MONTH) - val str = when (i) { - 0, 13 -> DateFormat.format(DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMMd"), lowestDate + i * 24 * 60 * 60 * 1000).toString() - else -> IntArray(date).joinToString("").replace("0", "\u200B") - } - map[str] = 0f + val (totalRpiCount, rpiHistogram) = ExposureDatabase.with(requireContext()) { database -> + val map = linkedMapOf() + val lowestDate = (System.currentTimeMillis() / 24 / 60 / 60 / 1000 - 13).toDouble().roundToLong() * 24 * 60 * 60 * 1000 + for (i in 0..13) { + val date = Calendar.getInstance().apply { this.time = Date(lowestDate + i * 24 * 60 * 60 * 1000) }.get(Calendar.DAY_OF_MONTH) + val str = when (i) { + 0, 13 -> DateFormat.format(DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMMd"), lowestDate + i * 24 * 60 * 60 * 1000).toString() + else -> IntArray(date).joinToString("").replace("0", "\u200B") } - val refDateLow = Calendar.getInstance().apply { this.time = Date(lowestDate) }.get(Calendar.DAY_OF_MONTH) - val refDateHigh = Calendar.getInstance().apply { this.time = Date(lowestDate + 13 * 24 * 60 * 60 * 1000) }.get(Calendar.DAY_OF_MONTH) - for (entry in database.rpiHistogram) { - val time = Date(entry.key * 24 * 60 * 60 * 1000) - if (time.time < lowestDate) continue // Ignore old data - val date = Calendar.getInstance().apply { this.time = time }.get(Calendar.DAY_OF_MONTH) - val str = when (date) { - refDateLow, refDateHigh -> DateFormat.format(DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMMd"), entry.key * 24 * 60 * 60 * 1000).toString() - else -> IntArray(date).joinToString("").replace("0", "\u200B") - } - map[str] = entry.value.toFloat() - } - val totalRpiCount = database.totalRpiCount - totalRpiCount to map + map[str] = 0f } + val refDateLow = Calendar.getInstance().apply { this.time = Date(lowestDate) }.get(Calendar.DAY_OF_MONTH) + val refDateHigh = Calendar.getInstance().apply { this.time = Date(lowestDate + 13 * 24 * 60 * 60 * 1000) }.get(Calendar.DAY_OF_MONTH) + for (entry in database.rpiHistogram) { + val time = Date(entry.key * 24 * 60 * 60 * 1000) + if (time.time < lowestDate) continue // Ignore old data + val date = Calendar.getInstance().apply { this.time = time }.get(Calendar.DAY_OF_MONTH) + val str = when (date) { + refDateLow, refDateHigh -> DateFormat.format(DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMMd"), entry.key * 24 * 60 * 60 * 1000).toString() + else -> IntArray(date).joinToString("").replace("0", "\u200B") + } + map[str] = entry.value.toFloat() + } + val totalRpiCount = database.totalRpiCount + totalRpiCount to map } histogramCategory.title = getString(R.string.prefcat_exposure_rpis_histogram_title, totalRpiCount) histogram.labelsFormatter = { it.roundToInt().toString() } diff --git a/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/AdvertiserService.kt b/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/AdvertiserService.kt index 231356a7..fdfd6a54 100644 --- a/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/AdvertiserService.kt +++ b/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/AdvertiserService.kt @@ -20,6 +20,10 @@ import android.content.Intent import android.content.IntentFilter import android.os.* import android.util.Log +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.microg.gms.common.ForegroundServiceContext import java.io.FileDescriptor import java.io.PrintWriter @@ -27,7 +31,7 @@ import java.nio.ByteBuffer import java.util.* @TargetApi(21) -class AdvertiserService : Service() { +class AdvertiserService : LifecycleService() { private val version = VERSION_1_0 private var advertising = false private var wantStartAdvertising = false @@ -84,13 +88,13 @@ class AdvertiserService : Service() { handler.removeCallbacks(startLaterRunnable) } - override fun onBind(intent: Intent?): IBinder? { - return null - } - - fun startAdvertisingIfNeeded() { + private fun startAdvertisingIfNeeded() { if (ExposurePreferences(this).enabled) { - startAdvertising() + lifecycleScope.launchWhenStarted { + withContext(Dispatchers.IO) { + startAdvertising() + } + } } else { stopSelf() } @@ -98,57 +102,65 @@ class AdvertiserService : Service() { private var lastStartTime = System.currentTimeMillis() private var sendingBytes = ByteArray(0) + private var starting = false - @Synchronized - fun startAdvertising() { - if (advertising) return - val advertiser = advertiser ?: return - wantStartAdvertising = false - val aemBytes = when (version) { - VERSION_1_0 -> byteArrayOf( - version, // Version and flags - (currentDeviceInfo.txPowerCorrection + TX_POWER_LOW).toByte(), // TX power - 0x00, // Reserved - 0x00 // Reserved - ) - VERSION_1_1 -> byteArrayOf( - (version + currentDeviceInfo.confidence.toByte() * 4).toByte(), // Version and flags - (currentDeviceInfo.txPowerCorrection + TX_POWER_LOW).toByte(), // TX power - 0x00, // Reserved - 0x00 // Reserved - ) - else -> return + private suspend fun startAdvertising() { + val advertiser = synchronized(this) { + if (advertising || starting) return + val advertiser = advertiser ?: return + wantStartAdvertising = false + starting = true + advertiser } - var nextSend = nextKeyMillis.coerceAtLeast(10000) - val payload = ExposureDatabase.with(this@AdvertiserService) { database -> - database.generateCurrentPayload(aemBytes) + try { + val aemBytes = when (version) { + VERSION_1_0 -> byteArrayOf( + version, // Version and flags + (currentDeviceInfo.txPowerCorrection + TX_POWER_LOW).toByte(), // TX power + 0x00, // Reserved + 0x00 // Reserved + ) + VERSION_1_1 -> byteArrayOf( + (version + currentDeviceInfo.confidence.toByte() * 4).toByte(), // Version and flags + (currentDeviceInfo.txPowerCorrection + TX_POWER_LOW).toByte(), // TX power + 0x00, // Reserved + 0x00 // Reserved + ) + else -> return + } + var nextSend = nextKeyMillis.coerceAtLeast(10000) + val payload = ExposureDatabase.with(this@AdvertiserService) { database -> + database.generateCurrentPayload(aemBytes) + } + val data = AdvertiseData.Builder().addServiceUuid(SERVICE_UUID).addServiceData(SERVICE_UUID, payload).build() + val (uuid, _) = ByteBuffer.wrap(payload).let { UUID(it.long, it.long) to it.int } + Log.i(TAG, "Starting advertiser for RPI $uuid") + if (Build.VERSION.SDK_INT >= 26) { + setCallback = SetCallback() + val params = AdvertisingSetParameters.Builder() + .setInterval(AdvertisingSetParameters.INTERVAL_MEDIUM) + .setLegacyMode(true) + .setTxPowerLevel(AdvertisingSetParameters.TX_POWER_LOW) + .setConnectable(false) + .build() + advertiser.startAdvertisingSet(params, data, null, null, null, setCallback as AdvertisingSetCallback) + } else { + nextSend = nextSend.coerceAtMost(180000) + val settings = Builder() + .setTimeout(nextSend.toInt()) + .setAdvertiseMode(ADVERTISE_MODE_BALANCED) + .setTxPowerLevel(ADVERTISE_TX_POWER_LOW) + .setConnectable(false) + .build() + advertiser.startAdvertising(settings, data, callback) + } + synchronized(this) { advertising = true } + sendingBytes = payload + lastStartTime = System.currentTimeMillis() + scheduleRestartAdvertising(nextSend) + } finally { + synchronized(this) { starting = false } } - val data = AdvertiseData.Builder().addServiceUuid(SERVICE_UUID).addServiceData(SERVICE_UUID, payload).build() - val (uuid, _) = ByteBuffer.wrap(payload).let { UUID(it.long, it.long) to it.int } - Log.i(TAG, "Starting advertiser for RPI $uuid") - if (Build.VERSION.SDK_INT >= 26) { - setCallback = SetCallback() - val params = AdvertisingSetParameters.Builder() - .setInterval(AdvertisingSetParameters.INTERVAL_MEDIUM) - .setLegacyMode(true) - .setTxPowerLevel(AdvertisingSetParameters.TX_POWER_LOW) - .setConnectable(false) - .build() - advertiser.startAdvertisingSet(params, data, null, null, null, setCallback as AdvertisingSetCallback) - } else { - nextSend = nextSend.coerceAtMost(180000) - val settings = Builder() - .setTimeout(nextSend.toInt()) - .setAdvertiseMode(ADVERTISE_MODE_BALANCED) - .setTxPowerLevel(ADVERTISE_TX_POWER_LOW) - .setConnectable(false) - .build() - advertiser.startAdvertising(settings, data, callback) - } - advertising = true - sendingBytes = payload - lastStartTime = System.currentTimeMillis() - scheduleRestartAdvertising(nextSend) } override fun dump(fd: FileDescriptor?, writer: PrintWriter?, args: Array?) { @@ -182,7 +194,7 @@ class AdvertiserService : Service() { } @Synchronized - fun stopOrRestartAdvertising() { + private fun stopOrRestartAdvertising() { if (!advertising) return val (uuid, _) = ByteBuffer.wrap(sendingBytes).let { UUID(it.long, it.long) to it.int } Log.i(TAG, "Stopping advertiser for RPI $uuid") diff --git a/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/CleanupService.kt b/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/CleanupService.kt index bcc2bf58..d88e90b2 100644 --- a/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/CleanupService.kt +++ b/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/CleanupService.kt @@ -14,6 +14,7 @@ import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.microg.gms.common.ForegroundServiceContext class CleanupService : LifecycleService() { @@ -22,7 +23,7 @@ class CleanupService : LifecycleService() { super.onStartCommand(intent, flags, startId) if (isNeeded(this)) { lifecycleScope.launchWhenStarted { - launch(Dispatchers.IO) { + withContext(Dispatchers.IO) { var workPending = true while (workPending) { ExposureDatabase.with(this@CleanupService) { diff --git a/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ExposureDatabase.kt b/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ExposureDatabase.kt index 9735ce48..c1ced51c 100644 --- a/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ExposureDatabase.kt +++ b/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ExposureDatabase.kt @@ -17,16 +17,18 @@ import android.os.Parcelable import android.util.Log import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey +import kotlinx.coroutines.* import okio.ByteString import java.io.File +import java.lang.Runnable import java.nio.ByteBuffer import java.util.* import java.util.concurrent.* -import kotlin.math.roundToInt @TargetApi(21) class ExposureDatabase private constructor(private val context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { - private var refCount = 0 + private val createdAt: Exception = Exception("Database ${hashCode()} created") + private var refCount = 1 init { setWriteAheadLoggingEnabled(true) @@ -675,27 +677,21 @@ class ExposureDatabase private constructor(private val context: Context) : SQLit fun generateCurrentPayload(metadata: ByteArray) = ensureTemporaryExposureKey().generatePayload(currentIntervalNumber.toInt(), metadata) override fun getWritableDatabase(): SQLiteDatabase { - if (this != instance) { - throw IllegalStateException("Tried to open writable database from secondary instance. We are ${hashCode()} but primary is ${instance?.hashCode()}") - } + requirePrimary(this) return super.getWritableDatabase() } - override fun close() { - synchronized(Companion) { - super.close() - instance = null - } - } - - fun ref(): ExposureDatabase = synchronized(Companion) { + @Synchronized + fun ref(): ExposureDatabase { refCount++ return this } - fun unref() = synchronized(Companion) { + @Synchronized + fun unref() { refCount-- if (refCount == 0) { + clearInstance(this) close() } else if (refCount < 0) { throw IllegalStateException("ref/unref mismatch") @@ -705,6 +701,7 @@ class ExposureDatabase private constructor(private val context: Context) : SQLit companion object { private const val DB_NAME = "exposure.db" private const val DB_VERSION = 5 + 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_LOG = "app_log" @@ -726,22 +723,137 @@ class ExposureDatabase private constructor(private val context: Context) : SQLit @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 - fun ref(context: Context): ExposureDatabase = synchronized(this) { - if (instance == null) { - instance = ExposureDatabase(context.applicationContext) - Log.d(TAG, "Created instance ${instance?.hashCode()} of database for ${context.javaClass.name}") + + @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) } - instance!!.ref() } - fun with(context: Context, call: (ExposureDatabase) -> T): T { - val it = ref(context) + @Synchronized + private fun clearInstance(database: ExposureDatabase) { + if (database == instance) { + if (deferredRefCount == 0) { + deferredInstance = null + instance = null + } + } else { + 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 { + Log.d(TAG, "Created instance ${database.hashCode()} of database for ${context.javaClass.simpleName}") + finishDatabaseMigration(database, dbMigrateFile, dbMigrateWalFile) + completeInstance(database) + newInstance.complete(database) + return database + } catch (e: Exception) { + database.close() + throw e + } + } catch (e: Exception) { + newInstance.completeExceptionally(e) + throw e; + } + } else { + return instance.await().ref() + } + } finally { + unrefDeferredInstance() + } + } + + @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() + } + } } } diff --git a/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ExposureNotificationService.kt b/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ExposureNotificationService.kt index 6e443fa1..0ad60f08 100644 --- a/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ExposureNotificationService.kt +++ b/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ExposureNotificationService.kt @@ -40,7 +40,7 @@ class ExposureNotificationService : BaseService(TAG, GmsService.NEARBY_EXPOSURE) } Log.d(TAG, "handleServiceRequest: " + request.packageName) - callback.onPostInitCompleteWithConnectionInfo(SUCCESS, ExposureNotificationServiceImpl(this, request.packageName), ConnectionInfo().apply { + callback.onPostInitCompleteWithConnectionInfo(SUCCESS, ExposureNotificationServiceImpl(this, lifecycle, request.packageName), ConnectionInfo().apply { features = arrayOf( Feature("nearby_exposure_notification", 3), Feature("nearby_exposure_notification_get_version", 1) diff --git a/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ExposureNotificationServiceImpl.kt b/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ExposureNotificationServiceImpl.kt index 283bcd3d..b734fb6e 100644 --- a/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ExposureNotificationServiceImpl.kt +++ b/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ExposureNotificationServiceImpl.kt @@ -12,9 +12,11 @@ import android.content.Context import android.content.Intent import android.os.* import android.util.Log +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope import com.google.android.gms.common.api.Status import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration -import com.google.android.gms.nearby.exposurenotification.ExposureInformation import com.google.android.gms.nearby.exposurenotification.ExposureNotificationStatusCodes.* import com.google.android.gms.nearby.exposurenotification.ExposureSummary import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey @@ -29,12 +31,14 @@ import org.microg.gms.nearby.exposurenotification.proto.TemporaryExposureKeyProt import java.io.File import java.io.InputStream import java.security.MessageDigest -import java.util.* import java.util.zip.ZipFile import kotlin.math.roundToInt import kotlin.random.Random -class ExposureNotificationServiceImpl(private val context: Context, private val packageName: String) : INearbyExposureNotificationService.Stub() { +class ExposureNotificationServiceImpl(private val context: Context, private val lifecycle: Lifecycle, private val packageName: String) : INearbyExposureNotificationService.Stub(), LifecycleOwner { + + override fun getLifecycle(): Lifecycle = lifecycle + private fun pendingConfirm(permission: String): PendingIntent { val intent = Intent(ACTION_CONFIRM) intent.`package` = context.packageName @@ -43,7 +47,7 @@ class ExposureNotificationServiceImpl(private val context: Context, private val intent.putExtra(KEY_CONFIRM_RECEIVER, object : ResultReceiver(null) { override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { if (resultCode == Activity.RESULT_OK) { - ExposureDatabase.with(context) { database -> database.grantPermission(packageName, PackageUtils.firstSignatureDigest(context, packageName)!!, permission) } + tempGrantedPermissions.add(packageName to permission) } } }) @@ -58,10 +62,14 @@ class ExposureNotificationServiceImpl(private val context: Context, private val return pi } - private fun confirmPermission(permission: String): Status { + private suspend fun confirmPermission(permission: String): Status { if (packageName == context.packageName) return Status.SUCCESS return ExposureDatabase.with(context) { database -> - if (!database.hasPermission(packageName, PackageUtils.firstSignatureDigest(context, packageName)!!, permission)) { + if (tempGrantedPermissions.contains(packageName to permission)) { + database.grantPermission(packageName, PackageUtils.firstSignatureDigest(context, packageName)!!, permission) + tempGrantedPermissions.remove(packageName to permission) + Status.SUCCESS + } else if (!database.hasPermission(packageName, PackageUtils.firstSignatureDigest(context, packageName)!!, permission)) { Status(RESOLUTION_REQUIRED, "Permission EN#$permission required.", pendingConfirm(permission)) } else { Status.SUCCESS @@ -79,29 +87,34 @@ class ExposureNotificationServiceImpl(private val context: Context, private val override fun start(params: StartParams) { if (ExposurePreferences(context).enabled) return - val status = confirmPermission(CONFIRM_ACTION_START) - if (status.isSuccess) { - ExposurePreferences(context).enabled = true - ExposureDatabase.with(context) { database -> database.noteAppAction(packageName, "start") } - } - try { - params.callback.onResult(status) - } catch (e: Exception) { - Log.w(TAG, "Callback failed", e) + lifecycleScope.launchWhenStarted { + val status = confirmPermission(CONFIRM_ACTION_START) + if (status.isSuccess) { + ExposurePreferences(context).enabled = true + ExposureDatabase.with(context) { database -> database.noteAppAction(packageName, "start") } + } + try { + params.callback.onResult(status) + } catch (e: Exception) { + Log.w(TAG, "Callback failed", e) + } } } override fun stop(params: StopParams) { - ExposurePreferences(context).enabled = false - ExposureDatabase.with(context) { database -> - database.noteAppAction(packageName, "stop") - } - try { - params.callback.onResult(Status.SUCCESS) - } catch (e: Exception) { - Log.w(TAG, "Callback failed", e) + lifecycleScope.launchWhenStarted { + ExposurePreferences(context).enabled = false + ExposureDatabase.with(context) { database -> + database.noteAppAction(packageName, "stop") + } + try { + params.callback.onResult(Status.SUCCESS) + } catch (e: Exception) { + Log.w(TAG, "Callback failed", e) + } } } + override fun isEnabled(params: IsEnabledParams) { try { params.callback.onResult(Status.SUCCESS, ExposurePreferences(context).enabled) @@ -111,24 +124,26 @@ class ExposureNotificationServiceImpl(private val context: Context, private val } override fun getTemporaryExposureKeyHistory(params: GetTemporaryExposureKeyHistoryParams) { - val status = confirmPermission(CONFIRM_ACTION_KEYS) - val response = when { - status.isSuccess -> ExposureDatabase.with(context) { database -> - database.allKeys + lifecycleScope.launchWhenStarted { + val status = confirmPermission(CONFIRM_ACTION_KEYS) + val response = when { + status.isSuccess -> ExposureDatabase.with(context) { database -> + database.allKeys + } + else -> emptyList() } - else -> emptyList() - } - ExposureDatabase.with(context) { database -> - database.noteAppAction(packageName, "getTemporaryExposureKeyHistory", JSONObject().apply { - put("result", status.statusCode) - put("response_keys_size", response.size) - }.toString()) - } - try { - params.callback.onResult(status, response) - } catch (e: Exception) { - Log.w(TAG, "Callback failed", e) + ExposureDatabase.with(context) { database -> + database.noteAppAction(packageName, "getTemporaryExposureKeyHistory", JSONObject().apply { + put("result", status.statusCode) + put("response_keys_size", response.size) + }.toString()) + } + try { + params.callback.onResult(status, response) + } catch (e: Exception) { + Log.w(TAG, "Callback failed", e) + } } } @@ -157,7 +172,7 @@ class ExposureNotificationServiceImpl(private val context: Context, private val digest() } - private fun buildExposureSummary(token: String): ExposureSummary = ExposureDatabase.with(context) { database -> + private suspend fun buildExposureSummary(token: String): ExposureSummary = ExposureDatabase.with(context) { database -> val pair = database.loadConfiguration(packageName, token) val (configuration, exposures) = if (pair != null) { pair.second to database.findAllMeasuredExposures(pair.first).merge() @@ -180,23 +195,23 @@ class ExposureNotificationServiceImpl(private val context: Context, private val override fun provideDiagnosisKeys(params: ProvideDiagnosisKeysParams) { Log.w(TAG, "provideDiagnosisKeys() with $packageName/${params.token}") - val tid = ExposureDatabase.with(context) { database -> - if (params.configuration != null) { - database.storeConfiguration(packageName, params.token, params.configuration) - } else { - database.getTokenId(packageName, params.token) + lifecycleScope.launchWhenStarted { + val tid = ExposureDatabase.with(context) { database -> + if (params.configuration != null) { + database.storeConfiguration(packageName, params.token, params.configuration) + } else { + database.getTokenId(packageName, params.token) + } } - } - if (tid == null) { - Log.w(TAG, "Unknown token without configuration: $packageName/${params.token}") - try { - params.callback.onResult(Status.INTERNAL_ERROR) - } catch (e: Exception) { - Log.w(TAG, "Callback failed", e) + if (tid == null) { + Log.w(TAG, "Unknown token without configuration: $packageName/${params.token}") + try { + params.callback.onResult(Status.INTERNAL_ERROR) + } catch (e: Exception) { + Log.w(TAG, "Callback failed", e) + } + return@launchWhenStarted } - return - } - Thread(Runnable { ExposureDatabase.with(context) { database -> val start = System.currentTimeMillis() @@ -293,47 +308,55 @@ class ExposureNotificationServiceImpl(private val context: Context, private val Log.w(TAG, "Callback failed", e) } } - }).start() - } - - override fun getExposureSummary(params: GetExposureSummaryParams): Unit = ExposureDatabase.with(context) { database -> - val response = buildExposureSummary(params.token) - - database.noteAppAction(packageName, "getExposureSummary", JSONObject().apply { - put("request_token", params.token) - put("response_days_since", response.daysSinceLastExposure) - put("response_matched_keys", response.matchedKeyCount) - put("response_max_risk", response.maximumRiskScore) - put("response_attenuation_durations", JSONArray().apply { - response.attenuationDurationsInMinutes.forEach { put(it) } - }) - put("response_summation_risk", response.summationRiskScore) - }.toString()) - try { - params.callback.onResult(Status.SUCCESS, response) - } catch (e: Exception) { - Log.w(TAG, "Callback failed", e) } } - override fun getExposureInformation(params: GetExposureInformationParams): Unit = ExposureDatabase.with(context) { database -> - val pair = database.loadConfiguration(packageName, params.token) - val response = if (pair != null) { - database.findAllMeasuredExposures(pair.first).merge().map { - it.toExposureInformation(pair.second) + override fun getExposureSummary(params: GetExposureSummaryParams) { + lifecycleScope.launchWhenStarted { + val response = buildExposureSummary(params.token) + + ExposureDatabase.with(context) { database -> + database.noteAppAction(packageName, "getExposureSummary", JSONObject().apply { + put("request_token", params.token) + put("response_days_since", response.daysSinceLastExposure) + put("response_matched_keys", response.matchedKeyCount) + put("response_max_risk", response.maximumRiskScore) + put("response_attenuation_durations", JSONArray().apply { + response.attenuationDurationsInMinutes.forEach { put(it) } + }) + put("response_summation_risk", response.summationRiskScore) + }.toString()) + } + try { + params.callback.onResult(Status.SUCCESS, response) + } catch (e: Exception) { + Log.w(TAG, "Callback failed", e) } - } else { - emptyList() } + } - database.noteAppAction(packageName, "getExposureInformation", JSONObject().apply { - put("request_token", params.token) - put("response_size", response.size) - }.toString()) - try { - params.callback.onResult(Status.SUCCESS, response) - } catch (e: Exception) { - Log.w(TAG, "Callback failed", e) + override fun getExposureInformation(params: GetExposureInformationParams) { + lifecycleScope.launchWhenStarted { + ExposureDatabase.with(context) { database -> + val pair = database.loadConfiguration(packageName, params.token) + val response = if (pair != null) { + database.findAllMeasuredExposures(pair.first).merge().map { + it.toExposureInformation(pair.second) + } + } else { + emptyList() + } + + database.noteAppAction(packageName, "getExposureInformation", JSONObject().apply { + put("request_token", params.token) + put("response_size", response.size) + }.toString()) + try { + params.callback.onResult(Status.SUCCESS, response) + } catch (e: Exception) { + Log.w(TAG, "Callback failed", e) + } + } } } @@ -362,4 +385,8 @@ class ExposureNotificationServiceImpl(private val context: Context, private val Log.d(TAG, "onTransact [unknown]: $code, $data, $flags") return false } + + companion object { + private val tempGrantedPermissions: MutableSet> = hashSetOf() + } } diff --git a/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ScannerService.kt b/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ScannerService.kt index 4df0159a..b346ce9f 100644 --- a/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ScannerService.kt +++ b/play-services-nearby-core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ScannerService.kt @@ -18,13 +18,15 @@ import android.content.Intent import android.content.IntentFilter import android.os.* import android.util.Log +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope import org.microg.gms.common.ForegroundServiceContext import java.io.FileDescriptor import java.io.PrintWriter import java.util.* @TargetApi(21) -class ScannerService : Service() { +class ScannerService : LifecycleService() { private var scanning = false private var lastStartTime = 0L private var seenAdvertisements = 0L @@ -81,11 +83,13 @@ class ScannerService : Service() { fun onScanResult(result: ScanResult) { val data = result.scanRecord?.serviceData?.get(SERVICE_UUID) ?: return if (data.size < 16) return // Ignore invalid advertisements - ExposureDatabase.with(this) { database -> - database.noteAdvertisement(data.sliceArray(0..15), data.drop(16).toByteArray(), result.rssi) - } seenAdvertisements++ lastAdvertisement = System.currentTimeMillis() + lifecycleScope.launchWhenStarted { + ExposureDatabase.with(this@ScannerService) { database -> + database.noteAdvertisement(data.sliceArray(0..15), data.drop(16).toByteArray(), result.rssi) + } + } } fun startScanIfNeeded() { @@ -107,10 +111,6 @@ class ScannerService : Service() { stopScan() } - override fun onBind(intent: Intent?): IBinder? { - return null - } - @SuppressLint("WakelockTimeout") @Synchronized private fun startScan() {