diff --git a/play-services-auth-api-phone/core/src/main/kotlin/org/microg/gms/auth/phone/SmsRetrieverCore.kt b/play-services-auth-api-phone/core/src/main/kotlin/org/microg/gms/auth/phone/SmsRetrieverCore.kt index c0c70b49..14096e4e 100644 --- a/play-services-auth-api-phone/core/src/main/kotlin/org/microg/gms/auth/phone/SmsRetrieverCore.kt +++ b/play-services-auth-api-phone/core/src/main/kotlin/org/microg/gms/auth/phone/SmsRetrieverCore.kt @@ -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 { diff --git a/play-services-base/core/build.gradle b/play-services-base/core/build.gradle new file mode 100644 index 00000000..96ace60a --- /dev/null +++ b/play-services-base/core/build.gradle @@ -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' diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/utils/IntentCacheManager.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/utils/IntentCacheManager.kt new file mode 100644 index 00000000..4a5e55be --- /dev/null +++ b/play-services-base/core/src/main/kotlin/org/microg/gms/utils/IntentCacheManager.kt @@ -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(private val context: Context, private val clazz: Class, private val type: Int) { + private val lock = Any() + private lateinit var content: ArrayList + 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() + 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 = 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 create(context: Context, type: Int) = IntentCacheManager(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 + } + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/GamesConnectService.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/GamesConnectService.kt index e5f7486a..34810ef7 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/games/GamesConnectService.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/GamesConnectService.kt @@ -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) diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/GamesService.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/GamesService.kt index d6b3e96e..13e37a41 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/games/GamesService.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/GamesService.kt @@ -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 ) ) }) diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/privileged/Fido2PrivilegedService.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/privileged/Fido2PrivilegedService.kt index 00745089..0ac84f8f 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/privileged/Fido2PrivilegedService.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/privileged/Fido2PrivilegedService.kt @@ -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) } } diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/nfc/NfcTransportHandler.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/nfc/NfcTransportHandler.kt new file mode 100644 index 00000000..3d49cf89 --- /dev/null +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/nfc/NfcTransportHandler.kt @@ -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() + + 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 { + if (it?.action != NfcAdapter.ACTION_TECH_DISCOVERED) return@Consumer + val tag = it.getParcelableExtra(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" + } +} diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/usb/UsbDevicePermissionManager.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/usb/UsbDevicePermissionManager.kt new file mode 100644 index 00000000..ef3c4098 --- /dev/null +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/usb/UsbDevicePermissionManager.kt @@ -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>>() + + fun register(context: Context) = synchronized(this) { + if (!registered) { + context.registerReceiver(this, IntentFilter(context.usbPermissionCallbackAction)) + registered = true + } + } + + fun addDeferred(device: UsbDevice, deferred: CompletableDeferred) = 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(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() + 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() + } +} diff --git a/play-services-location/core/provider/build.gradle b/play-services-location/core/provider/build.gradle new file mode 100644 index 00000000..5eac5823 --- /dev/null +++ b/play-services-location/core/provider/build.gradle @@ -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 + } +} diff --git a/play-services-location/core/provider/src/main/kotlin/org/microg/gms/location/provider/NetworkLocationProviderPreTiramisu.kt b/play-services-location/core/provider/src/main/kotlin/org/microg/gms/location/provider/NetworkLocationProviderPreTiramisu.kt new file mode 100644 index 00000000..f2a4001c --- /dev/null +++ b/play-services-location/core/provider/src/main/kotlin/org/microg/gms/location/provider/NetworkLocationProviderPreTiramisu.kt @@ -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) + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..09a0af4e --- /dev/null +++ b/play-services-nearby/core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/AdvertiserService.kt @@ -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?) { + 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 + } + } + } +} 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 new file mode 100644 index 00000000..ba842d3a --- /dev/null +++ b/play-services-nearby/core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/CleanupService.kt @@ -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 + } + } + } +} 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 12241493..e31eeb73 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 @@ -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 } diff --git a/play-services-nearby/core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/NotifyService.kt b/play-services-nearby/core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/NotifyService.kt new file mode 100644 index 00000000..8adec591 --- /dev/null +++ b/play-services-nearby/core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/NotifyService.kt @@ -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 } + } + } +} 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 new file mode 100644 index 00000000..f363737c --- /dev/null +++ b/play-services-nearby/core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ScannerService.kt @@ -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) { + 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?) { + 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 + } + } + } +} diff --git a/play-services-tasks/src/main/java/com/google/android/gms/tasks/Task.java b/play-services-tasks/src/main/java/com/google/android/gms/tasks/Task.java index 7cf39477..9d27a773 100644 --- a/play-services-tasks/src/main/java/com/google/android/gms/tasks/Task.java +++ b/play-services-tasks/src/main/java/com/google/android/gms/tasks/Task.java @@ -114,7 +114,7 @@ public abstract class Task { public abstract Task 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. *

* 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