Use PendingIntentCompat for mutable flag

This commit is contained in:
Marvin W 2024-03-20 15:11:57 +01:00 • committed by Cuynu
commit 00bb582614
16 changed files with 1272 additions and 11 deletions

View file

@ -25,6 +25,7 @@ import android.telephony.SmsMessage
import android.text.TextUtils
import android.util.Base64
import android.util.Log
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.lifecycle.DefaultLifecycleObserver
@ -160,7 +161,7 @@ class SmsRetrieverCore(private val context: Context, override val lifecycle: Lif
private fun getTimeoutPendingIntent(context: Context, packageName: String): PendingIntent {
val intent = Intent(ACTION_SMS_RETRIEVE_TIMEOUT)
intent.setPackage(packageName)
return PendingIntent.getBroadcast(context, ++requestCode, intent, PendingIntent.FLAG_IMMUTABLE)
return PendingIntentCompat.getBroadcast(context, ++requestCode, intent, 0, false)!!
}
private fun tryHandleIncomingMessageAsRetrieverMessage(message: SmsMessage): Boolean {

View file

@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2020, microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'maven-publish'
apply plugin: 'signing'
dependencies {
api project(':play-services-basement-ktx')
implementation "androidx.annotation:annotation:$annotationVersion"
implementation "androidx.appcompat:appcompat:$appcompatVersion"
implementation "androidx.core:core-ktx:$coreVersion"
implementation "androidx.lifecycle:lifecycle-service:$lifecycleVersion"
implementation "androidx.navigation:navigation-fragment-ktx:$navigationVersion"
implementation "androidx.navigation:navigation-ui-ktx:$navigationVersion"
implementation "androidx.preference:preference-ktx:$preferenceVersion"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
}
android {
namespace "org.microg.gms.base.core"
compileSdkVersion androidCompileSdk
buildToolsVersion "$androidBuildVersionTools"
defaultConfig {
versionName version
minSdkVersion androidMinSdk
targetSdkVersion androidTargetSdk
}
buildFeatures {
dataBinding = true
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
kotlinOptions {
jvmTarget = 1.8
}
lintOptions {
disable 'MissingTranslation'
}
}
apply from: '../../gradle/publish-android.gradle'
description = 'microG service implementation for play-services-base'

View file

@ -0,0 +1,149 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.utils
import android.app.AlarmManager
import android.app.AlarmManager.ELAPSED_REALTIME_WAKEUP
import android.app.PendingIntent.FLAG_NO_CREATE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build.VERSION.SDK_INT
import android.os.Parcelable
import android.os.SystemClock
import android.util.Log
import androidx.core.app.PendingIntentCompat
import androidx.core.content.getSystemService
import java.util.UUID
class IntentCacheManager<S : Service, T : Parcelable>(private val context: Context, private val clazz: Class<S>, private val type: Int) {
private val lock = Any()
private lateinit var content: ArrayList<T>
private lateinit var id: String
private var isReady: Boolean = false
private val pendingActions: MutableList<() -> Unit> = arrayListOf()
init {
val pendingIntent = PendingIntentCompat.getService(context, type, getIntent(), 0, true)!!
val alarmManager = context.getSystemService<AlarmManager>()
if (SDK_INT >= 19) {
alarmManager?.setWindow(ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + TEN_YEARS, -1, pendingIntent)
} else {
alarmManager?.set(ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + TEN_YEARS, pendingIntent)
}
pendingIntent.send()
}
private fun getIntent() = Intent(context, clazz).apply {
action = ACTION
putExtra(EXTRA_IS_CACHE, true)
putExtra(EXTRA_CACHE_TYPE, this@IntentCacheManager.type)
}
fun add(entry: T, check: (T) -> Boolean = { false }) = runIfReady {
val iterator = content.iterator()
while (iterator.hasNext()) {
if (check(iterator.next())) {
iterator.remove()
}
}
content.add(entry)
updateIntent()
}
fun remove(entry: T) = runIfReady {
if (content.remove(entry)) updateIntent()
}
fun removeIf(check: (T) -> Boolean) = runIfReady {
var removed = false
val iterator = content.iterator()
while (iterator.hasNext()) {
if (check(iterator.next())) {
iterator.remove()
removed = true
}
}
if (removed) updateIntent()
}
fun clear() = runIfReady {
content.clear()
updateIntent()
}
fun getId(): String? = if (this::id.isInitialized) id else null
fun getEntries(): List<T> = if (this::content.isInitialized) content else emptyList()
fun processIntent(intent: Intent) {
if (isCache(intent) && getType(intent) == type) {
synchronized(lock) {
content = intent.getParcelableArrayListExtra(EXTRA_DATA) ?: arrayListOf()
id = intent.getStringExtra(EXTRA_ID) ?: UUID.randomUUID().toString()
if (!intent.hasExtra(EXTRA_ID)) {
Log.d(TAG, "Created new intent cache with id $id")
} else if (intent.hasExtra(EXTRA_DATA)) {
Log.d(TAG, "Recovered data from intent cache with id $id")
}
pendingActions.forEach { it() }
pendingActions.clear()
isReady = true
updateIntent()
}
}
}
private fun runIfReady(action: () -> Unit) {
synchronized(lock) {
if (isReady) {
action()
} else {
pendingActions.add(action)
}
}
}
private fun updateIntent() {
synchronized(lock) {
if (isReady) {
val intent = getIntent().apply {
putExtra(EXTRA_ID, id)
putParcelableArrayListExtra(EXTRA_DATA, content)
}
val pendingIntent = PendingIntentCompat.getService(context, type, intent, FLAG_NO_CREATE or FLAG_UPDATE_CURRENT, true)
if (pendingIntent == null) {
Log.w(TAG, "Failed to update existing pending intent, will likely have a loss of information")
}
}
}
}
companion object {
private const val TAG = "IntentCacheManager"
private const val TEN_YEARS = 315360000000L
private const val ACTION = "org.microg.gms.ACTION_INTENT_CACHE_MANAGER"
private const val EXTRA_IS_CACHE = "org.microg.gms.IntentCacheManager.is_cache"
private const val EXTRA_CACHE_TYPE = "org.microg.gms.IntentCacheManager.cache_type"
private const val EXTRA_ID = "org.microg.gms.IntentCacheManager.id"
private const val EXTRA_DATA = "org.microg.gms.IntentCacheManager.data"
inline fun<reified S: Service, T: Parcelable> create(context: Context, type: Int) = IntentCacheManager<S, T>(context, S::class.java, type)
fun isCache(intent: Intent): Boolean = try {
intent.getBooleanExtra(EXTRA_IS_CACHE, false)
} catch (e: Exception) {
false
}
fun getType(intent: Intent): Int {
val ret = intent.getIntExtra(EXTRA_CACHE_TYPE, -1)
if (ret == -1) throw IllegalArgumentException()
return ret
}
}
}

View file

@ -11,6 +11,7 @@ import android.content.Context
import android.content.Intent
import android.os.Parcel
import android.util.Log
import androidx.core.app.PendingIntentCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
@ -51,10 +52,10 @@ class GamesConnectServiceImpl(val context: Context, override val lifecycle: Life
override fun signIn(callback: IGamesConnectCallbacks?, request: GamesSignInRequest?) {
Log.d(TAG, "signIn($request)")
fun sendSignInRequired() {
val resolution = PendingIntent.getActivity(context, packageName.hashCode(), Intent(context, GamesSignInActivity::class.java).apply {
val resolution = PendingIntentCompat.getActivity(context, packageName.hashCode(), Intent(context, GamesSignInActivity::class.java).apply {
putExtra(EXTRA_GAME_PACKAGE_NAME, packageName)
putExtra(EXTRA_SCOPES, arrayOf(Scopes.GAMES_LITE))
}, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}, PendingIntent.FLAG_UPDATE_CURRENT, false)
when (request?.signInType) {
0 -> { // Manual sign in, provide resolution
callback?.onSignIn(Status(CommonStatusCodes.SIGN_IN_REQUIRED, null, resolution), null)

View file

@ -14,6 +14,7 @@ import android.os.Bundle
import android.os.IBinder
import android.os.Parcel
import android.util.Log
import androidx.core.app.PendingIntentCompat
import androidx.core.os.bundleOf
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
@ -55,7 +56,7 @@ class GamesService : BaseService(TAG, GmsService.GAMES) {
Log.d(TAG, "Sending SIGN_IN_REQUIRED to $packageName")
callback.onPostInitCompleteWithConnectionInfo(ConnectionResult.SIGN_IN_REQUIRED, null, ConnectionInfo().apply {
params = bundleOf(
"pendingIntent" to PendingIntent.getActivity(
"pendingIntent" to PendingIntentCompat.getActivity(
this@GamesService,
packageName.hashCode(),
Intent(this@GamesService, GamesSignInActivity::class.java).apply {
@ -63,7 +64,8 @@ class GamesService : BaseService(TAG, GmsService.GAMES) {
putExtra(EXTRA_ACCOUNT, request.account)
putExtra(EXTRA_SCOPES, request.scopes)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
PendingIntent.FLAG_UPDATE_CURRENT,
false
)
)
})

View file

@ -6,14 +6,13 @@
package org.microg.gms.fido.core.privileged
import android.app.KeyguardManager
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Context.KEYGUARD_SERVICE
import android.content.Intent
import android.os.Build.VERSION.SDK_INT
import android.os.Parcel
import androidx.core.app.PendingIntentCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
@ -70,7 +69,7 @@ class Fido2PrivilegedServiceImpl(private val context: Context, override val life
.putExtra(KEY_OPTIONS, options.serializeToBytes())
val pendingIntent =
PendingIntent.getActivity(context, options.hashCode(), intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
PendingIntentCompat.getActivity(context, options.hashCode(), intent, FLAG_UPDATE_CURRENT, false)
callbacks.onPendingIntent(Status.SUCCESS, pendingIntent)
}
}
@ -84,7 +83,7 @@ class Fido2PrivilegedServiceImpl(private val context: Context, override val life
.putExtra(KEY_OPTIONS, options.serializeToBytes())
val pendingIntent =
PendingIntent.getActivity(context, options.hashCode(), intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
PendingIntentCompat.getActivity(context, options.hashCode(), intent, FLAG_UPDATE_CURRENT, false)
callbacks.onPendingIntent(Status.SUCCESS, pendingIntent)
}
}

View file

@ -0,0 +1,113 @@
/*
* SPDX-FileCopyrightText: 2022 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.fido.core.transport.nfc
import android.app.Activity
import android.app.PendingIntent
import android.content.Intent
import android.content.IntentFilter
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.util.Log
import androidx.core.app.OnNewIntentProvider
import androidx.core.app.PendingIntentCompat
import androidx.core.util.Consumer
import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse
import com.google.android.gms.fido.fido2.api.common.AuthenticatorAttestationResponse
import com.google.android.gms.fido.fido2.api.common.AuthenticatorResponse
import com.google.android.gms.fido.fido2.api.common.RequestOptions
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import org.microg.gms.fido.core.RequestOptionsType
import org.microg.gms.fido.core.transport.Transport
import org.microg.gms.fido.core.transport.TransportHandler
import org.microg.gms.fido.core.transport.TransportHandlerCallback
import org.microg.gms.fido.core.type
class NfcTransportHandler(private val activity: Activity, callback: TransportHandlerCallback? = null) :
TransportHandler(Transport.NFC, callback) {
override val isSupported: Boolean
get() = NfcAdapter.getDefaultAdapter(activity)?.isEnabled == true && activity is OnNewIntentProvider
private var deferred = CompletableDeferred<Tag>()
private suspend fun waitForNewNfcTag(adapter: NfcAdapter): Tag {
val intent = Intent(activity, activity.javaClass).apply { addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) }
val pendingIntent: PendingIntent = PendingIntentCompat.getActivity(activity, 0, intent, 0, true)!!
adapter.enableForegroundDispatch(
activity,
pendingIntent,
arrayOf(IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED)),
arrayOf(arrayOf(IsoDep::class.java.name))
)
invokeStatusChanged(TransportHandlerCallback.STATUS_WAITING_FOR_DEVICE)
val tag = deferred.await()
deferred = CompletableDeferred()
return tag
}
suspend fun register(
options: RequestOptions,
callerPackage: String,
tag: Tag
): AuthenticatorAttestationResponse {
return CtapNfcConnection(activity, tag).open {
register(it, activity, options, callerPackage)
}
}
suspend fun sign(
options: RequestOptions,
callerPackage: String,
tag: Tag
): AuthenticatorAssertionResponse {
return CtapNfcConnection(activity, tag).open {
sign(it, activity, options, callerPackage)
}
}
suspend fun handle(
options: RequestOptions,
callerPackage: String,
tag: Tag
): AuthenticatorResponse {
return when (options.type) {
RequestOptionsType.REGISTER -> register(options, callerPackage, tag)
RequestOptionsType.SIGN -> sign(options, callerPackage, tag)
}
}
override suspend fun start(options: RequestOptions, callerPackage: String): AuthenticatorResponse {
val adapter = NfcAdapter.getDefaultAdapter(activity)
val newIntentListener = Consumer<Intent> {
if (it?.action != NfcAdapter.ACTION_TECH_DISCOVERED) return@Consumer
val tag = it.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG) ?: return@Consumer
deferred.complete(tag)
}
try {
(activity as OnNewIntentProvider).addOnNewIntentListener(newIntentListener)
while (true) {
val tag = waitForNewNfcTag(adapter)
try {
return handle(options, callerPackage, tag)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, e)
}
}
} finally {
(activity as OnNewIntentProvider).removeOnNewIntentListener(newIntentListener)
}
}
companion object {
const val TAG = "FidoNfcHandler"
}
}

View file

@ -0,0 +1,84 @@
/*
* SPDX-FileCopyrightText: 2022 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.fido.core.transport.usb
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.os.Build.VERSION.SDK_INT
import androidx.core.app.PendingIntentCompat
import kotlinx.coroutines.CompletableDeferred
private val Context.usbPermissionCallbackAction
get() = "$packageName.USB_PERMISSION_CALLBACK"
private object UsbDevicePermissionReceiver : BroadcastReceiver() {
private var registered = false
private val pendingRequests = hashMapOf<UsbDevice, MutableList<CompletableDeferred<Boolean>>>()
fun register(context: Context) = synchronized(this) {
if (!registered) {
context.registerReceiver(this, IntentFilter(context.usbPermissionCallbackAction))
registered = true
}
}
fun addDeferred(device: UsbDevice, deferred: CompletableDeferred<Boolean>) = synchronized(this) {
if (pendingRequests.containsKey(device)) {
pendingRequests[device]!!.add(deferred)
false
} else {
pendingRequests[device] = arrayListOf(deferred)
true
}
}
fun unregister(context: Context) = synchronized(this) {
if (registered) {
context.unregisterReceiver(this)
registered = false
}
}
override fun onReceive(context: Context?, intent: Intent?) {
if (intent == null || context == null) return
if (intent.action == context.usbPermissionCallbackAction) {
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
if (device != null) {
synchronized(this) {
if (pendingRequests.containsKey(device)) {
val hasPermission = context.usbManager?.hasPermission(device) == true
for (deferred in pendingRequests[device].orEmpty()) {
deferred.complete(hasPermission)
}
pendingRequests.remove(device)
if (pendingRequests.isEmpty()) {
unregister(context)
}
}
}
}
}
}
}
class UsbDevicePermissionManager(private val context: Context) {
suspend fun awaitPermission(device: UsbDevice): Boolean {
if (context.usbManager?.hasPermission(device) == true) return true
val res = CompletableDeferred<Boolean>()
if (UsbDevicePermissionReceiver.addDeferred(device, res)) {
UsbDevicePermissionReceiver.register(context)
val intent = PendingIntentCompat.getBroadcast(context, 0, Intent(context.usbPermissionCallbackAction).apply { `package` = context.packageName }, 0, true)
context.usbManager?.requestPermission(device, intent)
}
return res.await()
}
}

View file

@ -0,0 +1,57 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
dependencies {
api project(':play-services-location')
compileOnly project(':play-services-location-core-system-api')
implementation project(':play-services-base-core')
implementation project(':play-services-location-core-base')
implementation "androidx.core:core-ktx:$coreVersion"
implementation "androidx.lifecycle:lifecycle-service:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVersion"
implementation "com.android.volley:volley:$volleyVersion"
implementation 'org.microg:address-formatter:0.3.1'
}
android {
namespace "org.microg.gms.location.provider"
compileSdkVersion androidCompileSdk
buildToolsVersion "$androidBuildVersionTools"
buildFeatures {
buildConfig = true
}
defaultConfig {
versionName version
minSdkVersion androidMinSdk
targetSdkVersion androidTargetSdk
buildConfigField "String", "ICHNAEA_KEY", "\"${localProperties.get("ichnaea.key", "")}\""
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
kotlinOptions {
jvmTarget = 1.8
}
}

View file

@ -0,0 +1,124 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.provider
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import android.location.Criteria
import android.location.Location
import android.os.Build.VERSION.SDK_INT
import android.os.WorkSource
import androidx.annotation.RequiresApi
import androidx.core.app.PendingIntentCompat
import com.android.location.provider.ProviderPropertiesUnbundled
import com.android.location.provider.ProviderRequestUnbundled
import org.microg.gms.location.*
import org.microg.gms.location.network.LOCATION_EXTRA_PRECISION
import org.microg.gms.location.network.NetworkLocationService
import org.microg.gms.location.provider.NetworkLocationProviderService.Companion.ACTION_REPORT_LOCATION
import java.io.PrintWriter
import kotlin.math.max
class NetworkLocationProviderPreTiramisu : AbstractLocationProviderPreTiramisu {
@Deprecated("Use only with SDK < 31")
constructor(context: Context, legacy: Unit) : super(properties) {
this.context = context
}
@RequiresApi(31)
constructor(context: Context) : super(context, properties) {
this.context = context
}
private val context: Context
private var enabled = false
private var currentRequest: ProviderRequestUnbundled? = null
private var pendingIntent: PendingIntent? = null
private var lastReportedLocation: Location? = null
private fun updateRequest() {
if (enabled) {
val forceNow: Boolean
val intervalMillis: Long
if (currentRequest?.reportLocation == true) {
forceNow = true
intervalMillis = max(currentRequest?.interval ?: Long.MAX_VALUE, MIN_INTERVAL_MILLIS)
} else {
forceNow = false
intervalMillis = Long.MAX_VALUE
}
val intent = Intent(context, NetworkLocationService::class.java)
intent.putExtra(EXTRA_PENDING_INTENT, pendingIntent)
intent.putExtra(EXTRA_ENABLE, true)
intent.putExtra(EXTRA_INTERVAL_MILLIS, intervalMillis)
intent.putExtra(EXTRA_FORCE_NOW, forceNow)
if (SDK_INT >= 31) {
intent.putExtra(EXTRA_LOW_POWER, currentRequest?.isLowPower ?: false)
intent.putExtra(EXTRA_WORK_SOURCE, currentRequest?.workSource)
}
if (SDK_INT >= 29) {
intent.putExtra(EXTRA_BYPASS, currentRequest?.isLocationSettingsIgnored ?: false)
}
context.startService(intent)
}
}
override fun dump(writer: PrintWriter) {
writer.println("Enabled: $enabled")
writer.println("Current request: $currentRequest")
writer.println("Last reported: $lastReportedLocation")
}
override fun onSetRequest(request: ProviderRequestUnbundled, source: WorkSource) {
synchronized(this) {
currentRequest = request
updateRequest()
}
}
override fun enable() {
synchronized(this) {
if (enabled) throw IllegalStateException()
val intent = Intent(context, NetworkLocationProviderService::class.java)
intent.action = ACTION_REPORT_LOCATION
pendingIntent = PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, true)
currentRequest = null
enabled = true
when {
SDK_INT >= 30 -> isAllowed = true
SDK_INT >= 29 -> isEnabled = true
}
}
}
override fun disable() {
synchronized(this) {
if (!enabled) throw IllegalStateException()
val intent = Intent(context, NetworkLocationService::class.java)
intent.putExtra(EXTRA_PENDING_INTENT, pendingIntent)
intent.putExtra(EXTRA_ENABLE, false)
context.startService(intent)
pendingIntent?.cancel()
pendingIntent = null
currentRequest = null
enabled = false
}
}
override fun reportLocationToSystem(location: Location) {
location.provider = "network"
location.extras?.remove(LOCATION_EXTRA_PRECISION)
lastReportedLocation = location
super.reportLocation(location)
}
companion object {
private const val MIN_INTERVAL_MILLIS = 20000L
private val properties = ProviderPropertiesUnbundled.create(false, false, false, false, true, true, true, Criteria.POWER_LOW, Criteria.ACCURACY_COARSE)
}
}

View file

@ -0,0 +1,268 @@
/*
* SPDX-FileCopyrightText: 2020, microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.nearby.exposurenotification
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.app.AlarmManager
import android.app.PendingIntent.FLAG_ONE_SHOT
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.bluetooth.BluetoothAdapter.*
import android.bluetooth.le.*
import android.bluetooth.le.AdvertiseSettings.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build.VERSION.SDK_INT
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import androidx.core.app.PendingIntentCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.microg.gms.common.ForegroundServiceContext
import org.microg.gms.common.ForegroundServiceInfo
import java.io.FileDescriptor
import java.io.PrintWriter
import java.nio.ByteBuffer
import java.util.*
@TargetApi(21)
@ForegroundServiceInfo("Exposure Notification")
class AdvertiserService : LifecycleService() {
private val version = VERSION_1_1
private var advertising = false
private var wantStartAdvertising = false
private val advertiser: BluetoothLeAdvertiser?
get() = getDefaultAdapter()?.bluetoothLeAdvertiser
private val alarmManager: AlarmManager
get() = getSystemService(Context.ALARM_SERVICE) as AlarmManager
private val callback: AdvertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
Log.d(TAG, "Advertising active for ${settingsInEffect?.timeout}ms")
}
override fun onStartFailure(errorCode: Int) {
Log.w(TAG, "Advertising failed: $errorCode")
stopOrRestartAdvertising()
}
}
@TargetApi(23)
private var setCallback: Any? = null
private val trigger = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == ACTION_STATE_CHANGED) {
when (intent.getIntExtra(EXTRA_STATE, -1)) {
STATE_TURNING_OFF, STATE_OFF -> stopOrRestartAdvertising()
STATE_ON -> startAdvertisingIfNeeded()
}
}
}
}
private val handler = Handler(Looper.getMainLooper())
private val startLaterRunnable = Runnable { startAdvertisingIfNeeded() }
override fun onCreate() {
super.onCreate()
registerReceiver(trigger, IntentFilter().apply { addAction(ACTION_STATE_CHANGED) })
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
ForegroundServiceContext.completeForegroundService(this, intent, TAG)
Log.d(TAG, "AdvertisingService.start: $intent")
super.onStartCommand(intent, flags, startId)
if (intent?.action == ACTION_RESTART_ADVERTISING && advertising) {
stopOrRestartAdvertising()
} else {
startAdvertisingIfNeeded()
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(trigger)
stopOrRestartAdvertising()
handler.removeCallbacks(startLaterRunnable)
}
private fun startAdvertisingIfNeeded() {
if (ExposurePreferences(this).enabled) {
lifecycleScope.launchWhenStarted {
withContext(Dispatchers.IO) {
startAdvertising()
}
}
} else {
stopSelf()
}
}
private var lastStartTime = System.currentTimeMillis()
private var sendingBytes = ByteArray(0)
private var starting = false
@SuppressLint("MissingPermission")
private suspend fun startAdvertising() {
val advertiser = synchronized(this) {
if (advertising || starting) return
val advertiser = advertiser ?: return
wantStartAdvertising = false
starting = true
advertiser
}
try {
val aemBytes = when (version) {
VERSION_1_0 -> byteArrayOf(
version, // Version and flags
currentDeviceInfo.txPowerCorrection, // TX power
0x00, // Reserved
0x00 // Reserved
)
VERSION_1_1 -> byteArrayOf(
(version + currentDeviceInfo.confidence.toByte() * 4).toByte(), // Version and flags
currentDeviceInfo.txPowerCorrection, // 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()
Log.i(TAG, "Starting advertiser")
if (SDK_INT >= 26) {
setCallback = SetCallback()
val params = AdvertisingSetParameters.Builder()
.setInterval(AdvertisingSetParameters.INTERVAL_MEDIUM)
.setLegacyMode(true)
.setTxPowerLevel(AdvertisingSetParameters.TX_POWER_LOW)
.setConnectable(false)
.build()
try {
advertiser.startAdvertisingSet(params, data, null, null, null, setCallback as AdvertisingSetCallback)
} catch (e: SecurityException) {
Log.e(TAG, "Couldn't start advertising: Need android.permission.BLUETOOTH_ADVERTISE permission.", )
}
} else {
nextSend = nextSend.coerceAtMost(180000)
val settings = Builder()
.setTimeout(nextSend.toInt())
.setAdvertiseMode(ADVERTISE_MODE_BALANCED)
.setTxPowerLevel(ADVERTISE_TX_POWER_LOW)
.setConnectable(false)
.build()
try {
advertiser.startAdvertising(settings, data, callback)
} catch (e: SecurityException) {
Log.e(TAG, "Couldn't start advertising.", )
}
}
synchronized(this) { advertising = true }
sendingBytes = payload
lastStartTime = System.currentTimeMillis()
scheduleRestartAdvertising(nextSend)
} finally {
synchronized(this) { starting = false }
}
}
override fun dump(fd: FileDescriptor?, writer: PrintWriter?, args: Array<out String>?) {
writer?.println("Advertising: $advertising")
try {
val startTime = lastStartTime
val bytes = sendingBytes
val (uuid, aem) = ByteBuffer.wrap(bytes).let { UUID(it.long, it.long) to it.int }
writer?.println("""
Last advertising:
Since: ${Date(startTime)}
RPI: $uuid
Version: 0x${version.toString(16)}
TX Power: ${currentDeviceInfo.txPowerCorrection}
AEM: 0x${aem.toLong().let { if (it < 0) 0x100000000L + it else it }.toString(16)}
""".trimIndent())
} catch (e: Exception) {
writer?.println("Last advertising: ${e.message ?: e.toString()}")
}
}
private fun scheduleRestartAdvertising(nextSend: Long) {
val intent = Intent(this, AdvertiserService::class.java).apply { action = ACTION_RESTART_ADVERTISING }
val pendingIntent = PendingIntentCompat.getService(this, ACTION_RESTART_ADVERTISING.hashCode(), intent, FLAG_ONE_SHOT or FLAG_UPDATE_CURRENT, false)!!
when {
SDK_INT >= 23 ->
alarmManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + nextSend, pendingIntent)
else ->
alarmManager.setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + nextSend, pendingIntent)
}
}
@Synchronized
@SuppressLint("MissingPermission")
private fun stopOrRestartAdvertising() {
if (!advertising) return
Log.i(TAG, "Stopping advertiser")
advertising = false
if (SDK_INT >= 26) {
wantStartAdvertising = true
try {
advertiser?.stopAdvertisingSet(setCallback as AdvertisingSetCallback)
} catch (e: SecurityException) {
Log.i(TAG, "Tried calling stopAdvertisingSet without android.permission.BLUETOOTH_ADVERTISE permission.", )
}
} else {
try {
advertiser?.stopAdvertising(callback)
} catch (e: SecurityException) {
Log.i(TAG, "stopAdvertising() failed with a SecurityException. Maybe some permissions are missing?", )
}
}
handler.postDelayed(startLaterRunnable, 1000)
}
@TargetApi(26)
inner class SetCallback : AdvertisingSetCallback() {
override fun onAdvertisingSetStarted(advertisingSet: AdvertisingSet?, txPower: Int, status: Int) {
Log.d(TAG, "Advertising active, status=$status")
}
override fun onAdvertisingSetStopped(advertisingSet: AdvertisingSet?) {
Log.d(TAG, "Advertising stopped")
if (wantStartAdvertising) {
startAdvertisingIfNeeded()
} else {
stopOrRestartAdvertising()
}
}
}
companion object {
private const val ACTION_RESTART_ADVERTISING = "org.microg.gms.nearby.exposurenotification.RESTART_ADVERTISING"
fun isNeeded(context: Context): Boolean {
return ExposurePreferences(context).enabled
}
fun isSupported(context: Context): Boolean? {
val adapter = getDefaultAdapter()
return when {
adapter == null -> false
SDK_INT >= 26 && (adapter.isLeExtendedAdvertisingSupported || adapter.isLePeriodicAdvertisingSupported) -> true
adapter.state != STATE_ON -> null
adapter.bluetoothLeAdvertiser != null -> true
else -> false
}
}
}
}

View file

@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: 2020, microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.nearby.exposurenotification
import android.app.AlarmManager
import android.app.PendingIntent.FLAG_ONE_SHOT
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.PendingIntentCompat
import androidx.lifecycle.LifecycleService
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
import org.microg.gms.common.ForegroundServiceInfo
@ForegroundServiceInfo("Exposure Notification")
class CleanupService : LifecycleService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
ForegroundServiceContext.completeForegroundService(this, intent, TAG)
Log.d(TAG, "CleanupService.start: $intent")
super.onStartCommand(intent, flags, startId)
if (isNeeded(this, true)) {
lifecycleScope.launchWhenStarted {
withContext(Dispatchers.IO) {
var workPending = true
while (workPending) {
ExposureDatabase.with(this@CleanupService) {
workPending = !it.dailyCleanup()
}
if (workPending) delay(5000L)
}
ExposurePreferences(this@CleanupService).lastCleanup = System.currentTimeMillis()
}
stop()
}
} else {
stop()
}
return START_NOT_STICKY
}
fun stop() {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val pendingIntent = PendingIntentCompat.getService(applicationContext, CleanupService::class.java.name.hashCode(), Intent(applicationContext, CleanupService::class.java), FLAG_ONE_SHOT or FLAG_UPDATE_CURRENT, false)!!
alarmManager.set(AlarmManager.RTC, ExposurePreferences(this).lastCleanup + CLEANUP_INTERVAL, pendingIntent)
stopSelf()
}
companion object {
fun isNeeded(context: Context, now: Boolean = false): Boolean {
return ExposurePreferences(context).let {
(it.enabled && !now) || it.lastCleanup < System.currentTimeMillis() - CLEANUP_INTERVAL
}
}
}
}

View file

@ -16,6 +16,7 @@ import android.location.LocationManager
import android.os.*
import android.util.Base64
import android.util.Log
import androidx.core.app.PendingIntentCompat
import androidx.core.location.LocationManagerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleCoroutineScope
@ -113,7 +114,7 @@ class ExposureNotificationServiceImpl(private val context: Context, override val
Log.w(TAG, e)
}
Log.d(TAG, "Pending: $intent")
val pi = PendingIntent.getActivity(context, permission.hashCode(), intent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE)
val pi = PendingIntentCompat.getActivity(context, permission.hashCode(), intent, PendingIntent.FLAG_ONE_SHOT, false)!!
Log.d(TAG, "Pending: $pi")
return pi
}

View file

@ -0,0 +1,140 @@
/*
* SPDX-FileCopyrightText: 2020, microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.nearby.exposurenotification
import android.annotation.TargetApi
import android.app.*
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.bluetooth.BluetoothAdapter
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.Color
import android.location.LocationManager
import android.os.Build.VERSION.SDK_INT
import android.util.Log
import android.util.TypedValue
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import androidx.core.location.LocationManagerCompat
import androidx.lifecycle.LifecycleService
import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
import org.microg.gms.common.ForegroundServiceContext
import org.microg.gms.common.ForegroundServiceInfo
import org.microg.gms.nearby.core.R
@ForegroundServiceInfo("Exposure Notification")
class NotifyService : LifecycleService() {
private val notificationId = NotifyService::class.java.name.hashCode()
private val trigger = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
updateNotification()
}
}
@TargetApi(26)
private fun createNotificationChannel(): String {
val channel = NotificationChannel("exposure-notifications", "Exposure Notifications", NotificationManager.IMPORTANCE_HIGH)
channel.setSound(null, null)
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
channel.setShowBadge(true)
if (SDK_INT >= 29) {
channel.setAllowBubbles(false)
}
channel.vibrationPattern = longArrayOf(0)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
return channel.id
}
@TargetApi(21)
private fun updateNotification() {
val location = !LocationManagerCompat.isLocationEnabled(getSystemService(Context.LOCATION_SERVICE) as LocationManager)
val bluetooth = BluetoothAdapter.getDefaultAdapter()?.state.let { it != BluetoothAdapter.STATE_ON && it != BluetoothAdapter.STATE_TURNING_ON }
val nearbyPermissions = arrayOf("android.permission.BLUETOOTH_ADVERTISE", "android.permission.BLUETOOTH_SCAN")
val permissionNeedsHandling = SDK_INT >= 31 && nearbyPermissions.any {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
Log.d( TAG,"notify: location: $location, bluetooth: $bluetooth, permissionNeedsHandling: $permissionNeedsHandling")
val text: String = when {
permissionNeedsHandling -> getString(R.string.exposure_notify_off_nearby)
location && bluetooth -> getString(R.string.exposure_notify_off_bluetooth_location)
location -> getString(R.string.exposure_notify_off_location)
bluetooth -> getString(R.string.exposure_notify_off_bluetooth)
else -> {
NotificationManagerCompat.from(this).cancel(notificationId)
return
}
}
if (SDK_INT >= 26) {
NotificationCompat.Builder(this, createNotificationChannel())
} else {
NotificationCompat.Builder(this)
}.apply {
val typedValue = TypedValue()
try {
var resolved = theme.resolveAttribute(androidx.appcompat.R.attr.colorError, typedValue, true)
if (!resolved && SDK_INT >= 26) resolved = theme.resolveAttribute(android.R.attr.colorError, typedValue, true)
color = if (resolved) {
ContextCompat.getColor(this@NotifyService, typedValue.resourceId)
} else {
Color.RED
}
if (SDK_INT >= 26) setColorized(true)
} catch (e: Exception) {
// Ignore
}
setSmallIcon(R.drawable.ic_virus_outline)
setContentTitle(getString(R.string.exposure_notify_off_title))
setContentText(text)
setStyle(NotificationCompat.BigTextStyle())
try {
val intent = Intent(ExposureNotificationClient.ACTION_EXPOSURE_NOTIFICATION_SETTINGS).apply { `package` = packageName }
intent.resolveActivity(packageManager)
setContentIntent(PendingIntentCompat.getActivity(this@NotifyService, notificationId, Intent(ExposureNotificationClient.ACTION_EXPOSURE_NOTIFICATION_SETTINGS).apply { `package` = packageName }, FLAG_UPDATE_CURRENT, false))
} catch (e: Exception) {
// Ignore
}
}.let {
NotificationManagerCompat.from(this).notify(notificationId, it.build())
}
}
override fun onCreate() {
super.onCreate()
registerReceiver(trigger, IntentFilter().apply {
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
if (SDK_INT >= 19) addAction(LocationManager.MODE_CHANGED_ACTION)
addAction(LocationManager.PROVIDERS_CHANGED_ACTION)
addAction(NOTIFICATION_UPDATE_ACTION)
})
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
ForegroundServiceContext.completeForegroundService(this, intent, TAG)
Log.d(TAG, "NotifyService.start: $intent")
super.onStartCommand(intent, flags, startId)
updateNotification()
return Service.START_STICKY
}
override fun onDestroy() {
super.onDestroy()
NotificationManagerCompat.from(this).cancel(notificationId)
unregisterReceiver(trigger)
}
companion object {
fun isNeeded(context: Context): Boolean {
return ExposurePreferences(context).let { it.enabled }
}
}
}

View file

@ -0,0 +1,197 @@
/*
* SPDX-FileCopyrightText: 2020, microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.nearby.exposurenotification
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.app.AlarmManager
import android.app.PendingIntent.FLAG_ONE_SHOT
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.bluetooth.BluetoothAdapter.*
import android.bluetooth.le.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.*
import android.util.Log
import androidx.core.app.PendingIntentCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import org.microg.gms.common.ForegroundServiceContext
import org.microg.gms.common.ForegroundServiceInfo
import java.io.FileDescriptor
import java.io.PrintWriter
import java.util.*
@TargetApi(21)
@ForegroundServiceInfo("Exposure Notification")
class ScannerService : LifecycleService() {
private var scanning = false
private var lastStartTime = 0L
private var seenAdvertisements = 0L
private var lastAdvertisement = 0L
private val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult?) {
result?.let { onScanResult(it) }
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
for (result in results) {
onScanResult(result)
}
}
override fun onScanFailed(errorCode: Int) {
Log.w(TAG, "onScanFailed: $errorCode")
stopScan()
}
}
private val trigger = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == ACTION_STATE_CHANGED) {
when (intent.getIntExtra(EXTRA_STATE, -1)) {
STATE_TURNING_OFF, STATE_OFF -> stopScan()
STATE_ON -> startScanIfNeeded()
}
}
}
}
private val handler = Handler(Looper.getMainLooper())
private val stopLaterRunnable = Runnable { stopScan() }
// Wake lock for the duration of scan. Otherwise we might fall asleep while scanning
// resulting in potentially very long scan times
private val wakeLock: PowerManager.WakeLock by lazy {
powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, ScannerService::class.java.canonicalName).apply { setReferenceCounted(false) }
}
private val scanner: BluetoothLeScanner?
get() = getDefaultAdapter()?.bluetoothLeScanner
private val alarmManager: AlarmManager
get() = getSystemService(Context.ALARM_SERVICE) as AlarmManager
private val powerManager: PowerManager
get() = getSystemService(Context.POWER_SERVICE) as PowerManager
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
ForegroundServiceContext.completeForegroundService(this, intent, TAG)
Log.d(TAG, "ScannerService.start: $intent")
super.onStartCommand(intent, flags, startId)
startScanIfNeeded()
return START_STICKY
}
fun onScanResult(result: ScanResult) {
val data = result.scanRecord?.serviceData?.get(SERVICE_UUID) ?: return
if (data.size < 16) return // Ignore invalid advertisements
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() {
if (ExposurePreferences(this).enabled) {
startScan()
} else {
stopSelf()
}
}
override fun onCreate() {
super.onCreate()
registerReceiver(trigger, IntentFilter().apply { addAction(ACTION_STATE_CHANGED) })
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(trigger)
stopScan()
}
@SuppressLint("WakelockTimeout", "MissingPermission")
@Synchronized
private fun startScan() {
if (scanning) return
val scanner = scanner ?: return
Log.i(TAG, "Starting scanner for service $SERVICE_UUID for ${SCANNING_TIME_MS}ms")
seenAdvertisements = 0
wakeLock.acquire()
try {
scanner.startScan(
listOf(ScanFilter.Builder()
.setServiceUuid(SERVICE_UUID)
.setServiceData(SERVICE_UUID, byteArrayOf(0), byteArrayOf(0))
.build()),
ScanSettings.Builder().build(),
callback
)
} catch (e: SecurityException) {
Log.e(TAG, "Couldn't start ScannerService, need android.permission.BLUETOOTH_SCAN permission.")
}
scanning = true
lastStartTime = System.currentTimeMillis()
handler.postDelayed(stopLaterRunnable, SCANNING_TIME_MS)
}
@SuppressLint("MissingPermission")
@Synchronized
private fun stopScan() {
if (!scanning) return
Log.i(TAG, "Stopping scanner for service $SERVICE_UUID, had seen $seenAdvertisements advertisements")
handler.removeCallbacks(stopLaterRunnable)
scanning = false
try {
scanner?.stopScan(callback)
} catch (e: Exception) {
// Ignored
}
if (ExposurePreferences(this).enabled) {
scheduleStartScan(((lastStartTime + SCANNING_INTERVAL_MS) - System.currentTimeMillis()).coerceIn(0, SCANNING_INTERVAL_MS))
}
wakeLock.release()
}
private fun scheduleStartScan(nextScan: Long) {
val intent = Intent(this, ScannerService::class.java)
val pendingIntent = PendingIntentCompat.getService(this, ScannerService::class.java.hashCode(), intent, FLAG_ONE_SHOT or FLAG_UPDATE_CURRENT, false)!!
if (Build.VERSION.SDK_INT >= 23) {
// Note: there is no setWindowAndAllowWhileIdle()
alarmManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + nextScan, pendingIntent)
} else {
alarmManager.setWindow(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + nextScan - SCANNING_TIME_MS / 2, SCANNING_TIME_MS, pendingIntent)
}
}
override fun dump(fd: FileDescriptor?, writer: PrintWriter?, args: Array<out String>?) {
writer?.println("Scanning now: $scanning")
writer?.println("Last scan start: ${Date(lastStartTime)}")
if (Build.VERSION.SDK_INT >= 29) {
writer?.println("Scan stop pending: ${handler.hasCallbacks(stopLaterRunnable)}")
}
writer?.println("Seen advertisements since last scan start: $seenAdvertisements")
writer?.println("Last advertisement seen: ${Date(lastAdvertisement)}")
}
companion object {
fun isNeeded(context: Context): Boolean {
return ExposurePreferences(context).enabled
}
fun isSupported(context: Context): Boolean? {
val adapter = getDefaultAdapter()
return when {
adapter == null -> false
adapter.state != STATE_ON -> null
adapter.bluetoothLeScanner != null -> true
else -> false
}
}
}
}

View file

@ -114,7 +114,7 @@ public abstract class Task<TResult> {
public abstract Task<TResult> addOnFailureListener(Activity activity, OnFailureListener listener);
/**
* Adds an Activity-scoped listener that is called if the Task fails.
* Adds a listener that is called if the Task fails.
* <p/>
* The listener will be called on main application thread. If the Task has already failed, a
* call to the listener will be immediately scheduled. If multiple listeners are added, they