EN: Make database access suspendable, add migration routine for oversized databases

This commit is contained in:
Marvin W 2020-10-12 21:25:34 +02:00
parent c4b480c5a9
commit 1deeb45834
No known key found for this signature in database
GPG Key ID: 072E9235DB996F2A
9 changed files with 392 additions and 240 deletions

View File

@ -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
}
}
}

View File

@ -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) {

View File

@ -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<String, Float>()
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<String, Float>()
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() }

View File

@ -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<out String>?) {
@ -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")

View File

@ -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) {

View File

@ -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<ExposureDatabase>? = 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 <T> 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<Deferred<ExposureDatabase>, 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<ExposureDatabase>()
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<File, File> {
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 <T> withSync(context: Context, call: (ExposureDatabase) -> T): T {
val it = runBlocking { ref(context) }
try {
return call(it)
} finally {
it.unref()
}
}
suspend fun <T> with(context: Context, call: suspend (ExposureDatabase) -> T): T = withContext(Dispatchers.IO) {
val it = ref(context)
try {
call(it)
} finally {
it.unref()
}
}
}
}

View File

@ -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)

View File

@ -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<Pair<String, String>> = hashSetOf()
}
}

View File

@ -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() {