From d1ccc40dadc80df313898f58f75fd638a5101bf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rover=20Release=20Bot=20=F0=9F=A4=96?= Date: Thu, 16 Nov 2023 22:52:44 +0000 Subject: [PATCH] Releasing 4.4.0 --- .../sdk/advertising/AdvertisingAssembler.kt | 2 + .../AdvertisingIdContentProvider.kt | 30 ++- build.gradle.kts | 2 +- .../kotlin/io/rover/sdk/core/CoreAssembler.kt | 58 +++-- .../sdk/core/data/domain/DeviceContext.kt | 3 +- .../graphql/operations/data/DeviceContext.kt | 2 + .../LocationServicesContextProvider.kt | 10 +- .../TelephonyContextProvider.kt | 8 +- .../rover/sdk/core/privacy/PrivacyService.kt | 83 ++++++ .../io/rover/sdk/core/streams/Publishers.kt | 19 +- .../io/rover/sdk/core/streams/README.md | 20 +- debug/build.gradle.kts | 16 ++ .../io/rover/sdk/debug/DebugAssembler.kt | 12 +- .../io/rover/sdk/debug/DebugPreferences.kt | 63 +---- .../io/rover/sdk/debug/RoverDebugActivity.kt | 53 +--- .../io/rover/sdk/debug/SettingsComposable.kt | 237 ++++++++++++++++++ .../sdk/debug/TestDeviceContextProvider.kt | 4 +- .../rich/compose/ui/layers/CarouselLayer.kt | 37 ++- location/build.gradle.kts | 1 + .../GoogleBackgroundLocationService.kt | 48 +++- .../location/GoogleBeaconTrackerService.kt | 86 +++++-- .../sdk/location/GoogleGeofenceService.kt | 67 ++++- .../io/rover/sdk/location/Interfaces.kt | 3 +- .../rover/sdk/location/LocationAssembler.kt | 11 +- .../LocationContextProvider.kt | 14 +- .../location/sync/BeaconsSyncParticipant.kt | 19 +- .../location/sync/GeofencesSyncParticipant.kt | 23 +- .../rover/sdk/seatgeek/SeatGeekAssembler.kt | 12 +- .../io/rover/sdk/seatgeek/SeatGeekManager.kt | 15 +- .../TicketMasterAnalyticsBroadcastReceiver.kt | 3 + .../sdk/ticketmaster/TicketmasterAssembler.kt | 8 +- .../sdk/ticketmaster/TicketmasterManager.kt | 14 +- 32 files changed, 770 insertions(+), 213 deletions(-) create mode 100644 core/src/main/kotlin/io/rover/sdk/core/privacy/PrivacyService.kt create mode 100644 debug/src/main/kotlin/io/rover/sdk/debug/SettingsComposable.kt diff --git a/advertising/src/main/kotlin/io/rover/sdk/advertising/AdvertisingAssembler.kt b/advertising/src/main/kotlin/io/rover/sdk/advertising/AdvertisingAssembler.kt index 0b5028696..383e3d796 100644 --- a/advertising/src/main/kotlin/io/rover/sdk/advertising/AdvertisingAssembler.kt +++ b/advertising/src/main/kotlin/io/rover/sdk/advertising/AdvertisingAssembler.kt @@ -25,6 +25,7 @@ import io.rover.sdk.core.container.Scope import io.rover.sdk.core.events.ContextProvider import io.rover.sdk.core.events.EventQueueServiceInterface import io.rover.sdk.core.platform.LocalStorage +import io.rover.sdk.core.privacy.PrivacyService import io.rover.sdk.core.streams.Scheduler /** @@ -40,6 +41,7 @@ class AdvertisingAssembler : Assembler { ) { resolver -> AdvertisingIdContentProvider( resolver.resolveSingletonOrFail(Context::class.java), + resolver.resolveSingletonOrFail(PrivacyService::class.java), resolver.resolveSingletonOrFail(Scheduler::class.java, "io"), resolver.resolveSingletonOrFail(LocalStorage::class.java) ) diff --git a/advertising/src/main/kotlin/io/rover/sdk/advertising/AdvertisingIdContentProvider.kt b/advertising/src/main/kotlin/io/rover/sdk/advertising/AdvertisingIdContentProvider.kt index d4c31089b..5a3ab2d26 100644 --- a/advertising/src/main/kotlin/io/rover/sdk/advertising/AdvertisingIdContentProvider.kt +++ b/advertising/src/main/kotlin/io/rover/sdk/advertising/AdvertisingIdContentProvider.kt @@ -23,14 +23,19 @@ import io.rover.sdk.core.data.domain.DeviceContext import io.rover.sdk.core.events.ContextProvider import io.rover.sdk.core.logging.log import io.rover.sdk.core.platform.LocalStorage +import io.rover.sdk.core.privacy.PrivacyService import io.rover.sdk.core.streams.Publishers import io.rover.sdk.core.streams.Scheduler import io.rover.sdk.core.streams.subscribe import io.rover.sdk.core.streams.subscribeOn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class AdvertisingIdContentProvider( - applicationContext: Context, - ioScheduler: Scheduler, + private val applicationContext: Context, + private val privacyService: PrivacyService, + private val ioScheduler: Scheduler, localStorage: LocalStorage ) : ContextProvider { private val keyValueStorage = localStorage.getKeyValueStorageFor(STORAGE_CONTEXT_IDENTIFIER) @@ -41,7 +46,7 @@ class AdvertisingIdContentProvider( field = token } - init { + private fun acquireAdvertisingId() { Publishers.defer { advertisingId = try { AdvertisingIdClient.getAdvertisingIdInfo(applicationContext).id @@ -55,9 +60,26 @@ class AdvertisingIdContentProvider( }.subscribeOn(ioScheduler).subscribe { } } + init { + CoroutineScope(Dispatchers.Main).launch { + privacyService.trackingModeFlow.collect { trackingEnabled -> + if (trackingEnabled != PrivacyService.TrackingMode.Default) { + advertisingId = null + log.i("Tracking disabled, advertising id cleared.") + } else { + log.i("Tracking enabled, acquiring advertising id.") + acquireAdvertisingId() + } + } + } + } + override fun captureContext(deviceContext: DeviceContext): DeviceContext { + if (privacyService.trackingMode != PrivacyService.TrackingMode.Default) { + return deviceContext + } return deviceContext.copy( - advertisingIdentifier = this.advertisingId + advertisingIdentifier = this.advertisingId, ) } diff --git a/build.gradle.kts b/build.gradle.kts index 997a6a057..69504fc9a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,7 +18,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. // The version number for the build SDK modules and testbench app. -val roverSdkVersion by extra("4.3.0") +val roverSdkVersion by extra("4.4.0") // Definitions of several core shared dependencies: val kotlinVersion by extra("1.8.20") // NB: when changing this one check the two duplicates of this number below diff --git a/core/src/main/kotlin/io/rover/sdk/core/CoreAssembler.kt b/core/src/main/kotlin/io/rover/sdk/core/CoreAssembler.kt index e43c8a8ca..2ec298d9a 100644 --- a/core/src/main/kotlin/io/rover/sdk/core/CoreAssembler.kt +++ b/core/src/main/kotlin/io/rover/sdk/core/CoreAssembler.kt @@ -76,6 +76,7 @@ import io.rover.sdk.core.platform.IoMultiplexingExecutor import io.rover.sdk.core.platform.LocalStorage import io.rover.sdk.core.platform.SharedPreferencesLocalStorage import io.rover.sdk.core.platform.whenNotNull +import io.rover.sdk.core.privacy.PrivacyService import io.rover.sdk.core.routing.LinkOpenInterface import io.rover.sdk.core.routing.Router import io.rover.sdk.core.routing.RouterService @@ -179,6 +180,12 @@ class CoreAssembler @JvmOverloads constructor( application } + container.register(Scope.Singleton, PrivacyService::class.java) { resolver -> + PrivacyService( + resolver.resolveSingletonOrFail(LocalStorage::class.java) + ) + } + if (openAppIntent != null || application.packageManager.getLaunchIntentForPackage(application.packageName) != null) { container.register(Scope.Singleton, Intent::class.java, "openApp") { _ -> openAppIntent ?: application.packageManager.getLaunchIntentForPackage(application.packageName) ?: Intent() @@ -303,8 +310,8 @@ class CoreAssembler @JvmOverloads constructor( ScreenContextProvider(application.resources) } - container.register(Scope.Singleton, ContextProvider::class.java, "telephony") { _ -> - TelephonyContextProvider(application) + container.register(Scope.Singleton, ContextProvider::class.java, "telephony") { resolver -> + TelephonyContextProvider(application, resolver.resolveSingletonOrFail(PrivacyService::class.java)) } container.register(Scope.Singleton, ContextProvider::class.java, "device") { _ -> @@ -315,8 +322,11 @@ class CoreAssembler @JvmOverloads constructor( TimeZoneContextProvider() } - container.register(Scope.Singleton, ContextProvider::class.java, "locationAuthorization") { _ -> - LocationServicesContextProvider(application) + container.register(Scope.Singleton, ContextProvider::class.java, "locationAuthorization") { resolver -> + LocationServicesContextProvider( + application, + resolver.resolveSingletonOrFail(PrivacyService::class.java) + ) } container.register(Scope.Singleton, ContextProvider::class.java, "attributes") { resolver -> @@ -485,20 +495,21 @@ class CoreAssembler @JvmOverloads constructor( val eventQueue = resolver.resolveSingletonOrFail(EventQueueServiceInterface::class.java) listOf( - resolver.resolveSingletonOrFail(ContextProvider::class.java, "device"), - resolver.resolveSingletonOrFail(ContextProvider::class.java, "locale"), - resolver.resolveSingletonOrFail(ContextProvider::class.java, "darkMode"), - resolver.resolveSingletonOrFail(ContextProvider::class.java, "reachability"), - resolver.resolveSingletonOrFail(ContextProvider::class.java, "screen"), - resolver.resolveSingletonOrFail(ContextProvider::class.java, "telephony"), - resolver.resolveSingletonOrFail(ContextProvider::class.java, "timeZone"), - resolver.resolveSingletonOrFail(ContextProvider::class.java, "attributes"), - resolver.resolveSingletonOrFail(ContextProvider::class.java, "application"), - resolver.resolveSingletonOrFail(ContextProvider::class.java, "deviceIdentifier"), - resolver.resolveSingletonOrFail(ContextProvider::class.java, "sdkVersion"), - resolver.resolveSingletonOrFail(ContextProvider::class.java, "locationAuthorization"), - resolver.resolveSingletonOrFail(ContextProvider::class.java, "conversions"), - resolver.resolveSingletonOrFail(ContextProvider::class.java, "lastSeen") + resolver.resolveSingletonOrFail(PrivacyService::class.java), + resolver.resolveSingletonOrFail(ContextProvider::class.java, "device"), + resolver.resolveSingletonOrFail(ContextProvider::class.java, "locale"), + resolver.resolveSingletonOrFail(ContextProvider::class.java, "darkMode"), + resolver.resolveSingletonOrFail(ContextProvider::class.java, "reachability"), + resolver.resolveSingletonOrFail(ContextProvider::class.java, "screen"), + resolver.resolveSingletonOrFail(ContextProvider::class.java, "telephony"), + resolver.resolveSingletonOrFail(ContextProvider::class.java, "timeZone"), + resolver.resolveSingletonOrFail(ContextProvider::class.java, "attributes"), + resolver.resolveSingletonOrFail(ContextProvider::class.java, "application"), + resolver.resolveSingletonOrFail(ContextProvider::class.java, "deviceIdentifier"), + resolver.resolveSingletonOrFail(ContextProvider::class.java, "sdkVersion"), + resolver.resolveSingletonOrFail(ContextProvider::class.java, "locationAuthorization"), + resolver.resolveSingletonOrFail(ContextProvider::class.java, "conversions"), + resolver.resolveSingletonOrFail(ContextProvider::class.java, "lastSeen"), ).forEach { eventQueue.addContextProvider(it) } resolver.resolveSingletonOrFail(VersionTrackerInterface::class.java).trackAppVersion() @@ -528,6 +539,8 @@ class CoreAssembler @JvmOverloads constructor( resolver.resolveSingletonOrFail(ConversionsManager::class.java).apply { this.migrateLegacyTags() } + + resolver.resolveSingletonOrFail(PrivacyService::class.java).refreshAllListeners() } } @@ -568,3 +581,12 @@ val Rover.userInfoManager: UserInfoInterface private fun missingDependencyError(name: String): Throwable { throw RuntimeException("Dependency not registered: $name. Did you include CoreAssembler() in the assembler list?") } + +val Rover.privacyService: PrivacyService + get() = this.resolve(PrivacyService::class.java) ?: throw missingDependencyError("PrivacyService") + +var Rover.trackingMode: PrivacyService.TrackingMode + get() = privacyService.trackingMode + set(value) { + privacyService.trackingMode = value + } diff --git a/core/src/main/kotlin/io/rover/sdk/core/data/domain/DeviceContext.kt b/core/src/main/kotlin/io/rover/sdk/core/data/domain/DeviceContext.kt index 3501a9035..1a87b9ae3 100644 --- a/core/src/main/kotlin/io/rover/sdk/core/data/domain/DeviceContext.kt +++ b/core/src/main/kotlin/io/rover/sdk/core/data/domain/DeviceContext.kt @@ -23,6 +23,7 @@ import java.util.Date * A Rover context: describes the device and general situation when an [Event] is generated. */ data class DeviceContext( + val trackingMode: String?, val appBuild: String?, val appIdentifier: String?, val appVersion: String?, @@ -118,7 +119,7 @@ data class DeviceContext( null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, false, null, hashMapOf(), null, listOf(), null + null, null, null, null, null, null, false, null, null, hashMapOf(), null, listOf(), null ) } } diff --git a/core/src/main/kotlin/io/rover/sdk/core/data/graphql/operations/data/DeviceContext.kt b/core/src/main/kotlin/io/rover/sdk/core/data/graphql/operations/data/DeviceContext.kt index 6503d328f..f8f9f3d03 100644 --- a/core/src/main/kotlin/io/rover/sdk/core/data/graphql/operations/data/DeviceContext.kt +++ b/core/src/main/kotlin/io/rover/sdk/core/data/graphql/operations/data/DeviceContext.kt @@ -38,6 +38,7 @@ import org.json.JSONObject internal fun DeviceContext.asJson(dateFormatting: DateFormattingInterface): JSONObject { return JSONObject().apply { val props = listOf( + DeviceContext::trackingMode, DeviceContext::appBuild, DeviceContext::appIdentifier, DeviceContext::deviceIdentifier, @@ -87,6 +88,7 @@ internal fun DeviceContext.asJson(dateFormatting: DateFormattingInterface): JSON */ internal fun DeviceContext.Companion.decodeJson(json: JSONObject, dateFormatting: DateFormattingInterface): DeviceContext { return DeviceContext( + trackingMode = json.safeOptString("trackingMode"), appBuild = json.safeOptString("appBuild"), appIdentifier = json.safeOptString("appIdentifier"), appVersion = json.safeOptString("appVersion"), diff --git a/core/src/main/kotlin/io/rover/sdk/core/events/contextproviders/LocationServicesContextProvider.kt b/core/src/main/kotlin/io/rover/sdk/core/events/contextproviders/LocationServicesContextProvider.kt index 2d20c0a33..36e6c2514 100644 --- a/core/src/main/kotlin/io/rover/sdk/core/events/contextproviders/LocationServicesContextProvider.kt +++ b/core/src/main/kotlin/io/rover/sdk/core/events/contextproviders/LocationServicesContextProvider.kt @@ -26,8 +26,12 @@ import android.provider.Settings.Secure.LOCATION_MODE_OFF import androidx.core.content.ContextCompat import io.rover.sdk.core.data.domain.DeviceContext import io.rover.sdk.core.events.ContextProvider +import io.rover.sdk.core.privacy.PrivacyService -class LocationServicesContextProvider(val applicationContext: android.content.Context) : ContextProvider { +class LocationServicesContextProvider( + private val applicationContext: android.content.Context, + private val privacyService: PrivacyService +) : ContextProvider { companion object { private const val BACKGROUND_LOCATION_PERMISSION_CODE = "android.permission.ACCESS_BACKGROUND_LOCATION" private const val Q_VERSION_CODE = 29 @@ -38,6 +42,10 @@ class LocationServicesContextProvider(val applicationContext: android.content.Co } override fun captureContext(deviceContext: DeviceContext): DeviceContext { + if (privacyService.trackingMode != PrivacyService.TrackingMode.Default) { + return deviceContext.copy(locationAuthorization = DENIED, isLocationServicesEnabled = false) + } + val mode = Settings.Secure.getInt(applicationContext.contentResolver, LOCATION_MODE, LOCATION_MODE_OFF) val locationServicesEnabled = mode != LOCATION_MODE_OFF diff --git a/core/src/main/kotlin/io/rover/sdk/core/events/contextproviders/TelephonyContextProvider.kt b/core/src/main/kotlin/io/rover/sdk/core/events/contextproviders/TelephonyContextProvider.kt index f991d9572..632fe218d 100644 --- a/core/src/main/kotlin/io/rover/sdk/core/events/contextproviders/TelephonyContextProvider.kt +++ b/core/src/main/kotlin/io/rover/sdk/core/events/contextproviders/TelephonyContextProvider.kt @@ -24,12 +24,14 @@ import android.telephony.TelephonyManager import androidx.core.app.ActivityCompat import io.rover.sdk.core.data.domain.DeviceContext import io.rover.sdk.core.events.ContextProvider +import io.rover.sdk.core.privacy.PrivacyService /** * Captures and adds the mobile carrier and data connection details to a [DeviceContext]. */ class TelephonyContextProvider( - private val applicationContext: android.content.Context + private val applicationContext: android.content.Context, + private val privacyService: PrivacyService ) : ContextProvider { private val telephonyManager = applicationContext.applicationContext.getSystemService(android.content.Context.TELEPHONY_SERVICE) as TelephonyManager @@ -62,6 +64,10 @@ class TelephonyContextProvider( @SuppressLint("MissingPermission") override fun captureContext(deviceContext: DeviceContext): DeviceContext { + if (privacyService.trackingMode != PrivacyService.TrackingMode.Default) { + return deviceContext + } + val targetSdkVersion = applicationContext.applicationInfo.targetSdkVersion val networkTypeName = if (ActivityCompat.checkSelfPermission(applicationContext, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED || targetSdkVersion < 30) { diff --git a/core/src/main/kotlin/io/rover/sdk/core/privacy/PrivacyService.kt b/core/src/main/kotlin/io/rover/sdk/core/privacy/PrivacyService.kt new file mode 100644 index 000000000..cbe77c4f6 --- /dev/null +++ b/core/src/main/kotlin/io/rover/sdk/core/privacy/PrivacyService.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2023, Rover Labs, Inc. All rights reserved. + * You are hereby granted a non-exclusive, worldwide, royalty-free license to use, + * copy, modify, and distribute this software in source code or binary form for use + * in connection with the web services and APIs provided by Rover. + * + * This copyright notice shall be included in all copies or substantial portions of + * the software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package io.rover.sdk.core.privacy + +import io.rover.sdk.core.data.domain.DeviceContext +import io.rover.sdk.core.events.ContextProvider +import io.rover.sdk.core.logging.log +import io.rover.sdk.core.platform.LocalStorage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * This object is responsible for the global privacy settings of the Rover SDK. + */ +class PrivacyService( + private val localStorage: LocalStorage, + +) : ContextProvider { + + private val _trackingEnabledFlow: MutableStateFlow = MutableStateFlow( + localStorage.getKeyValueStorageFor("PrivacyService")["trackingMode"]?.let { stringValue -> + TrackingMode.values().singleOrNull() { it.wireFormat == stringValue } + } ?: TrackingMode.Default, + ) + + var trackingModeFlow: SharedFlow = _trackingEnabledFlow.asSharedFlow() + + enum class TrackingMode( + val wireFormat: String, + ) { + Default("default"), + Anonymized("anonymized"), + } + + var trackingMode: TrackingMode + get() { + return _trackingEnabledFlow.value + } + set(value) { + localStorage.getKeyValueStorageFor("PrivacyService")["trackingMode"] = + value.wireFormat + _trackingEnabledFlow.value = value + log.i("Tracking set to ${value.wireFormat}.") + listeners.forEach { it.onTrackingModeChanged(value) } + } + + private var listeners = mutableListOf() + + fun registerTrackingEnabledChangedListener(listener: TrackingEnabledChangedListener) { + listeners.add(listener) + listener.onTrackingModeChanged(trackingMode) + } + + interface TrackingEnabledChangedListener { + fun onTrackingModeChanged(trackingMode: TrackingMode) + } + + fun refreshAllListeners() { + listeners.forEach { it.onTrackingModeChanged(trackingMode) } + } + + override fun captureContext(deviceContext: DeviceContext): DeviceContext { + return deviceContext.copy( + trackingMode = trackingMode.wireFormat, + ) + } +} diff --git a/core/src/main/kotlin/io/rover/sdk/core/streams/Publishers.kt b/core/src/main/kotlin/io/rover/sdk/core/streams/Publishers.kt index cb5ed8fcf..beaedf6cc 100644 --- a/core/src/main/kotlin/io/rover/sdk/core/streams/Publishers.kt +++ b/core/src/main/kotlin/io/rover/sdk/core/streams/Publishers.kt @@ -306,7 +306,7 @@ object Publishers { fun combineLatest( source1: Publisher, source2: Publisher, - combiner: (T1, T2) -> R + combiner: (T1, T2) -> R, ): Publisher { @Suppress("UNCHECKED_CAST") // Suppression due to erasure/variance issues. return combineLatest(listOf(source1, source2) as List>) { list: List -> @@ -320,7 +320,7 @@ object Publishers { source1: Publisher, source2: Publisher, source3: Publisher, - combiner: (T1, T2, T3) -> R + combiner: (T1, T2, T3) -> R, ): Publisher { @Suppress("UNCHECKED_CAST") // Suppression due to erasure/variance issues. return combineLatest(listOf(source1, source2, source3) as List>) { list: List -> @@ -329,4 +329,19 @@ object Publishers { combiner(list[0] as T1, list[1] as T2, list[2] as T3) } } + + fun combineLatest( + source1: Publisher, + source2: Publisher, + source3: Publisher, + source4: Publisher, + combiner: (T1, T2, T3, T4) -> R, + ): Publisher { + @Suppress("UNCHECKED_CAST") // Suppression due to erasure/variance issues. + return combineLatest(listOf(source1, source2, source3, source4) as List>) { list: List -> + // Suppression due to erasure. + @Suppress("UNCHECKED_CAST") + combiner(list[0] as T1, list[1] as T2, list[2] as T3, list[3] as T4) + } + } } diff --git a/core/src/main/kotlin/io/rover/sdk/core/streams/README.md b/core/src/main/kotlin/io/rover/sdk/core/streams/README.md index 938d3de3b..fae36939e 100644 --- a/core/src/main/kotlin/io/rover/sdk/core/streams/README.md +++ b/core/src/main/kotlin/io/rover/sdk/core/streams/README.md @@ -1,8 +1,16 @@ -# Rover μReactive Streams +# Rover μReactive Extensions (Deprecated) -A small library within our SDK for handling asynchronous streams in the -style of Reactive Streams, in order to avoid a big, complicated -dependency like RxJava. +A simple implementation of Rx in Kotlin that conforms to +the Reactive Streams standard Publisher interface +(https://www.reactive-streams.org/) in Android/Java-land. -In future, we may be able further simplify the pattern by using Kotlin -continuations/coroutines to allow for imperative style stream code. +This was done in order to avoid a big, complicated transitive dependency like RxJava. + +## Migration Path + +This approach has been made redundant by the emergence of Kotlin +Coroutines and Flow. + +Migrating to Flow can be done incrementally thanks to the +`kotlinx-coroutines-reactive` library, which can bridge Publishers +into Flows and vice versa. diff --git a/debug/build.gradle.kts b/debug/build.gradle.kts index da726ae52..061738ac2 100644 --- a/debug/build.gradle.kts +++ b/debug/build.gradle.kts @@ -23,6 +23,8 @@ plugins { val roverSdkVersion: String by rootProject.extra val kotlinVersion: String by rootProject.extra +val composeBomVersion: String by rootProject.extra +val composeKotlinCompilerExtensionVersion: String by rootProject.extra android { compileSdk = 33 @@ -51,6 +53,14 @@ android { jvmTarget = "11" } + buildFeatures { // Enables Jetpack Compose for this module + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = composeKotlinCompilerExtensionVersion + } + namespace = "io.rover.debug" } @@ -61,6 +71,12 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") implementation(project(":core")) + + implementation(platform("androidx.compose:compose-bom:$composeBomVersion")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material:material") + implementation("androidx.activity:activity-compose:1.5.1") } afterEvaluate { diff --git a/debug/src/main/kotlin/io/rover/sdk/debug/DebugAssembler.kt b/debug/src/main/kotlin/io/rover/sdk/debug/DebugAssembler.kt index ac66ff0a6..b1bb6e8fd 100644 --- a/debug/src/main/kotlin/io/rover/sdk/debug/DebugAssembler.kt +++ b/debug/src/main/kotlin/io/rover/sdk/debug/DebugAssembler.kt @@ -26,7 +26,6 @@ import io.rover.sdk.core.container.Container import io.rover.sdk.core.container.Resolver import io.rover.sdk.core.container.Scope import io.rover.sdk.core.events.EventQueueServiceInterface -import io.rover.sdk.core.platform.DeviceIdentificationInterface import io.rover.sdk.core.routing.Router import io.rover.sdk.debug.routes.DebugRoute @@ -43,11 +42,10 @@ class DebugAssembler : Assembler { override fun assemble(container: Container) { container.register( Scope.Singleton, - DebugPreferences::class.java + DebugPreferences::class.java, ) { resolver -> DebugPreferences( resolver.resolveSingletonOrFail(Context::class.java), - resolver.resolveSingletonOrFail(DeviceIdentificationInterface::class.java) ) } } @@ -56,16 +54,16 @@ class DebugAssembler : Assembler { resolver.resolveSingletonOrFail(Router::class.java).apply { registerRoute( DebugRoute( - resolver.resolveSingletonOrFail(Context::class.java) - ) + resolver.resolveSingletonOrFail(Context::class.java), + ), ) } resolver.resolveSingletonOrFail(EventQueueServiceInterface::class.java) .addContextProvider( TestDeviceContextProvider( - resolver.resolveSingletonOrFail(DebugPreferences::class.java) - ) + resolver.resolveSingletonOrFail(DebugPreferences::class.java), + ), ) } } diff --git a/debug/src/main/kotlin/io/rover/sdk/debug/DebugPreferences.kt b/debug/src/main/kotlin/io/rover/sdk/debug/DebugPreferences.kt index e9c08742e..18e1de607 100644 --- a/debug/src/main/kotlin/io/rover/sdk/debug/DebugPreferences.kt +++ b/debug/src/main/kotlin/io/rover/sdk/debug/DebugPreferences.kt @@ -19,69 +19,24 @@ package io.rover.sdk.debug import android.content.Context import android.content.Context.MODE_PRIVATE -import androidx.preference.EditTextPreference -import androidx.preference.Preference -import androidx.preference.PreferenceManager -import androidx.preference.PreferenceScreen -import androidx.preference.SwitchPreferenceCompat -import io.rover.debug.R -import io.rover.sdk.core.platform.DeviceIdentificationInterface class DebugPreferences( context: Context, - private val deviceIdentification: DeviceIdentificationInterface ) { - val sharedPreferencesName = "io.rover.debug.settings" + private val sharedPreferencesName = "io.rover.debug.settings" private val sharedPreferences = context.getSharedPreferences(sharedPreferencesName, MODE_PRIVATE) - /** - * Constructs a [Preferences] object that describes the Debug screen preferences. - */ - fun constructPreferencesDefinition(preferenceManager: PreferenceManager): PreferenceScreen { - return preferenceManager.createPreferenceScreen(preferenceManager.context).apply { - title = context.getText(R.string.debug_settings_title) - addPreference( - SwitchPreferenceCompat( - preferenceManager.context - ).apply { - title = context.getText(R.string.debug_settings_test_device) - key = testDevicePreferencesKey - } - ) - addPreference( - EditTextPreference( - preferenceManager.context - ).apply { - isSelectable = true - isPersistent = false - title = context.getText(R.string.debug_settings_device_name) - summary = deviceIdentification.deviceName - key = "edit_device_name" - dialogTitle = context.getText(R.string.debug_settings_set_device_name) - dialogMessage = context.getText(R.string.debug_settings_device_name_description) - onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - deviceIdentification.deviceName = newValue as String - summary = newValue - true - } - } - ) - addPreference( - EditTextPreference( - preferenceManager.context - ).apply { - isSelectable = false - isPersistent = false - title = context.getText(R.string.debug_settings_device_id) - summary = deviceIdentification.installationIdentifier - } - ) - } - } - fun currentTestDeviceState(): Boolean { return sharedPreferences.getBoolean(testDevicePreferencesKey, false) } + var isTestDevice: Boolean + get() { + return sharedPreferences.getBoolean(testDevicePreferencesKey, false) + } + set(value) { + sharedPreferences.edit().putBoolean(testDevicePreferencesKey, value).apply() + } + private val testDevicePreferencesKey = "test-device" } diff --git a/debug/src/main/kotlin/io/rover/sdk/debug/RoverDebugActivity.kt b/debug/src/main/kotlin/io/rover/sdk/debug/RoverDebugActivity.kt index bbd0f9777..42ee6176c 100644 --- a/debug/src/main/kotlin/io/rover/sdk/debug/RoverDebugActivity.kt +++ b/debug/src/main/kotlin/io/rover/sdk/debug/RoverDebugActivity.kt @@ -19,62 +19,19 @@ package io.rover.sdk.debug import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import androidx.preference.PreferenceFragmentCompat -import io.rover.debug.R -import io.rover.sdk.core.Rover -import io.rover.sdk.core.logging.log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent /** * This activity displays a list of hidden debug settings for the Rover SDK. */ -class RoverDebugActivity : AppCompatActivity() { +class RoverDebugActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - title = getString(R.string.debug_settings_title) - - supportFragmentManager.beginTransaction() - .replace( - android.R.id.content, - RoverDebugPreferenceFragment() - ) - .commit() - } - - class RoverDebugPreferenceFragment : PreferenceFragmentCompat() { - - override fun onDestroy() { - super.onDestroy() - preferenceManager.sharedPreferences.unregisterOnSharedPreferenceChangeListener( - this::sharedPreferenceChangeListener - ) - } - - @Suppress("UNUSED_PARAMETER") - private fun sharedPreferenceChangeListener(sharedPreferences: SharedPreferences, key: String) { - // unused parameter suppressed because this method needs to match a signature. - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - val rover = Rover.failableShared - if (rover == null) { - log.e("RoverDebugActivity cannot work if Rover is not initialized. Ignoring.") - return - } - val debugPreferences = rover.resolve(DebugPreferences::class.java) - if (debugPreferences == null) { - log.e("RoverDebugActivity cannot work if Rover is not initialized, but DebugPreferences is not registered in the Rover container. Ensure DebugAssembler() is in Rover.initialize(). Ignoring.") - return - } - - preferenceManager.sharedPreferencesName = debugPreferences.sharedPreferencesName - preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener( - this::sharedPreferenceChangeListener - ) - preferenceScreen = debugPreferences.constructPreferencesDefinition(this.preferenceManager) + setContent { + RoverSettingsView(dismiss = { finish() }) } } diff --git a/debug/src/main/kotlin/io/rover/sdk/debug/SettingsComposable.kt b/debug/src/main/kotlin/io/rover/sdk/debug/SettingsComposable.kt new file mode 100644 index 000000000..7e8376d97 --- /dev/null +++ b/debug/src/main/kotlin/io/rover/sdk/debug/SettingsComposable.kt @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2023, Rover Labs, Inc. All rights reserved. + * You are hereby granted a non-exclusive, worldwide, royalty-free license to use, + * copy, modify, and distribute this software in source code or binary form for use + * in connection with the web services and APIs provided by Rover. + * + * This copyright notice shall be included in all copies or substantial portions of + * the software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package io.rover.sdk.debug + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ExposedDropdownMenuBox +import androidx.compose.material.ExposedDropdownMenuDefaults +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TopAppBar +import androidx.compose.material.darkColors +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.capitalize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.PopupProperties +import io.rover.sdk.core.Rover +import io.rover.sdk.core.deviceIdentification +import io.rover.sdk.core.platform.getDeviceName +import io.rover.sdk.core.privacy.PrivacyService +import io.rover.sdk.core.trackingMode + +@Composable +fun RoverSettingsView(dismiss: () -> Unit) { + val deviceName = remember { + mutableStateOf(Rover.shared.deviceIdentification.deviceName ?: getDeviceName()) + } + + val debugPreferences = Rover.shared.debugPreferences + + val isTestDevice = remember { + mutableStateOf( + debugPreferences.isTestDevice, + ) + } + + val trackingMode = remember { mutableStateOf(Rover.shared.trackingMode) } + + val deviceIdentifier = remember { mutableStateOf(Rover.shared.deviceIdentification.installationIdentifier) } + + MaterialTheme( + colors = if (isSystemInDarkTheme()) DarkColorPalette else LightColorPalette, + ) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = "Rover Settings") }, + actions = { + IconButton(onClick = { dismiss() }) { + Icon(Icons.Default.Close, contentDescription = null) + } + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .padding(paddingValues), + ) { + BooleanRow(label = "Test Device", value = isTestDevice.value, updateValue = { + isTestDevice.value = it + debugPreferences.isTestDevice = it + }) + TrackingModeRow(value = trackingMode.value, updateValue = { + trackingMode.value = it + Rover.shared.trackingMode = it + }) + StringRow(label = "Device Name", value = deviceName.value, updateValue = { + deviceName.value = it + Rover.shared.deviceIdentification.deviceName = it + }) + StringRow(label = "Device Identifier", value = deviceIdentifier.value, readOnly = true, updateValue = { + deviceIdentifier.value = it + }) + } + } + } +} + +@Composable +private fun BooleanRow(label: String, value: Boolean, updateValue: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = label, fontSize = 19.sp) + Switch(checked = value, onCheckedChange = { updateValue(it) }) + } +} + +@Composable +private fun StringRow(label: String, value: String, updateValue: (String) -> Unit, readOnly: Boolean = false) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + Text(text = label, fontSize = 15.sp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextField( + value = value, + enabled = !readOnly, + onValueChange = { if (!readOnly) updateValue(it) }, + textStyle = TextStyle(fontSize = 19.sp), + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(4.dp), + singleLine = true, + ) + if (readOnly) { + val clipboardManager = LocalContext.current.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + IconButton(onClick = { + val clip = ClipData.newPlainText("Device Identifier", value) + clipboardManager.setPrimaryClip(clip) + }) { + Icon(Icons.Default.Share, contentDescription = null) + } + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun TrackingModeRow(value: PrivacyService.TrackingMode, updateValue: (PrivacyService.TrackingMode) -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + Text(text = "Tracking Mode", fontSize = 15.sp) + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + modifier = Modifier.fillMaxWidth(), + onExpandedChange = { newValue -> + expanded = newValue + }, + ) { + TextField( + value = value.wireFormat.capitalize(), + modifier = Modifier.fillMaxWidth(), + onValueChange = {}, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + placeholder = { + Text(text = "Please select your gender") + }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + properties = PopupProperties(focusable = true), + ) { + DropdownMenuItem(onClick = { + updateValue(PrivacyService.TrackingMode.Default) + expanded = false + }) { + Text(text = "Default") + } + + DropdownMenuItem(onClick = { + updateValue(PrivacyService.TrackingMode.Anonymized) + expanded = false + }) { + Text(text = "Anonymized") + } + } + } + } +} + +internal val DarkColorPalette = darkColors( + primary = Color(0xFF80DEEA), // Cyan200 + secondary = Color(0xFF90CAF9), // Blue200 +) + +internal val LightColorPalette = lightColors( + primary = Color(0xff00BCD4), // Cyan500 + secondary = Color(0xFF90CAF9), // Blue200 +) diff --git a/debug/src/main/kotlin/io/rover/sdk/debug/TestDeviceContextProvider.kt b/debug/src/main/kotlin/io/rover/sdk/debug/TestDeviceContextProvider.kt index fdf5f9703..0d2a2af05 100644 --- a/debug/src/main/kotlin/io/rover/sdk/debug/TestDeviceContextProvider.kt +++ b/debug/src/main/kotlin/io/rover/sdk/debug/TestDeviceContextProvider.kt @@ -21,12 +21,12 @@ import io.rover.sdk.core.data.domain.DeviceContext import io.rover.sdk.core.events.ContextProvider class TestDeviceContextProvider( - private val debugPreferences: DebugPreferences + private val debugPreferences: DebugPreferences, ) : ContextProvider { override fun captureContext(deviceContext: DeviceContext): DeviceContext { // have to re-interrogate the shared preferences every time, but it should be quite fast. return deviceContext.copy( - isTestDevice = debugPreferences.currentTestDeviceState() + isTestDevice = debugPreferences.currentTestDeviceState(), ) } } diff --git a/experiences/src/main/kotlin/io/rover/sdk/experiences/rich/compose/ui/layers/CarouselLayer.kt b/experiences/src/main/kotlin/io/rover/sdk/experiences/rich/compose/ui/layers/CarouselLayer.kt index 028ca8c6d..3026845ec 100644 --- a/experiences/src/main/kotlin/io/rover/sdk/experiences/rich/compose/ui/layers/CarouselLayer.kt +++ b/experiences/src/main/kotlin/io/rover/sdk/experiences/rich/compose/ui/layers/CarouselLayer.kt @@ -29,13 +29,18 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalLifecycleOwner import io.rover.sdk.experiences.rich.compose.model.nodes.Carousel import io.rover.sdk.experiences.rich.compose.model.nodes.Collection +import io.rover.sdk.experiences.rich.compose.model.nodes.Conditional import io.rover.sdk.experiences.rich.compose.model.nodes.Node import io.rover.sdk.experiences.rich.compose.model.nodes.getItems +import io.rover.sdk.experiences.rich.compose.model.values.isSatisfied import io.rover.sdk.experiences.rich.compose.ui.CarouselState import io.rover.sdk.experiences.rich.compose.ui.Environment import io.rover.sdk.experiences.rich.compose.ui.ViewID import io.rover.sdk.experiences.rich.compose.ui.data.DataContext +import io.rover.sdk.experiences.rich.compose.ui.data.data import io.rover.sdk.experiences.rich.compose.ui.data.makeDataContext +import io.rover.sdk.experiences.rich.compose.ui.data.urlParameters +import io.rover.sdk.experiences.rich.compose.ui.data.userInfo import io.rover.sdk.experiences.rich.compose.ui.modifiers.LayerModifiers import io.rover.sdk.experiences.rich.compose.ui.utils.ExpandMeasurePolicy import io.rover.sdk.experiences.rich.compose.ui.utils.floorMod @@ -107,20 +112,40 @@ private data class CarouselItem( ) private fun carouselPages(carousel: Carousel, dataContext: DataContext): List { - val nodes = carousel.children.flatMap { node -> - when (node) { + fun generatePages(node: Node, dataContext: DataContext): List { + return when (node) { is Collection -> { node.getItems(dataContext).flatMap { item -> - node.children.map { childNode -> - CarouselItem(childNode, item) + node.children.flatMap { childNode -> + val childDataContext = makeDataContext( + userInfo = dataContext.userInfo, + urlParameters = dataContext.urlParameters, + data = item + ) + + generatePages(childNode, childDataContext) } } } + is Conditional -> { + if (!node.conditions.all { it.isSatisfied(dataContext) }) { + return emptyList() + } + + node.children.flatMap { childNode -> + generatePages(childNode, dataContext) + } + } + is Carousel -> { + node.children.flatMap { childNode -> + generatePages(childNode, dataContext) + } + } else -> { - listOf(CarouselItem(node)) + listOf(CarouselItem(node, dataContext.data)) } } } - return nodes.toList() + return generatePages(carousel, dataContext) } diff --git a/location/build.gradle.kts b/location/build.gradle.kts index 48da5843c..4ee347cab 100644 --- a/location/build.gradle.kts +++ b/location/build.gradle.kts @@ -57,6 +57,7 @@ android { dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactive:1.6.1") testImplementation("junit:junit:4.12") diff --git a/location/src/main/kotlin/io/rover/sdk/location/GoogleBackgroundLocationService.kt b/location/src/main/kotlin/io/rover/sdk/location/GoogleBackgroundLocationService.kt index 2b6e42b29..053c378d0 100644 --- a/location/src/main/kotlin/io/rover/sdk/location/GoogleBackgroundLocationService.kt +++ b/location/src/main/kotlin/io/rover/sdk/location/GoogleBackgroundLocationService.kt @@ -41,6 +41,7 @@ import io.rover.sdk.core.permissions.PermissionsNotifierInterface import io.rover.sdk.core.platform.DateFormattingInterface import io.rover.sdk.core.platform.LocalStorage import io.rover.sdk.core.platform.whenNotNull +import io.rover.sdk.core.privacy.PrivacyService import io.rover.sdk.core.streams.PublishSubject import io.rover.sdk.core.streams.Publishers import io.rover.sdk.core.streams.Scheduler @@ -57,12 +58,17 @@ import java.util.Date /** * Subscribes to Location Updates from FusedLocationManager and emits location reporting events. * + * Despite the name, this object is responsible for both background location monitoring + * and foreground location monitoring. Moreover, background location monitoring is no longer + * supported as of Android 12. + * * This will allow you to see up to date location data for your users in the Rover Audience app if * [trackLocation] is enabled. * * Google documentation: https://developer.android.com/training/location/receive-location-updates.html */ class GoogleBackgroundLocationService( + private val privacyService: PrivacyService, private val fusedLocationProviderClient: FusedLocationProviderClient, private val applicationContext: Context, private val permissionsNotifier: PermissionsNotifierInterface, @@ -72,12 +78,13 @@ class GoogleBackgroundLocationService( mainScheduler: Scheduler, private val trackLocation: Boolean = false, localStorage: LocalStorage, - private val dateFormatting: DateFormattingInterface -) : GoogleBackgroundLocationServiceInterface { + private val dateFormatting: DateFormattingInterface, +) : GoogleBackgroundLocationServiceInterface, PrivacyService.TrackingEnabledChangedListener { private val keyValueStorage = localStorage.getKeyValueStorageFor(STORAGE_CONTEXT_IDENTIFIER) override fun newGoogleLocationResult(locationResult: LocationResult) { + if (privacyService.trackingMode != PrivacyService.TrackingMode.Default) return log.v("Received location result: $locationResult") subject.onNext(locationResult) } @@ -157,8 +164,16 @@ class GoogleBackgroundLocationService( override val locationUpdates = Publishers.concat(Publishers.just(currentLocation).filterNulls(), locationChanges).shareAndReplay(1) + private val googleForegroundCallback = object : LocationCallback() { + override fun onLocationResult(locationResult: LocationResult) { + newGoogleLocationResult(locationResult) + } + } + init { - startMonitoring() + if (privacyService.trackingMode == PrivacyService.TrackingMode.Default) { + startMonitoring() + } locationChanges .subscribe { location -> @@ -174,6 +189,7 @@ class GoogleBackgroundLocationService( @SuppressLint("MissingPermission") private fun startMonitoring() { + log.i("Starting location monitoring.") permissionsNotifier.notifyForAnyOfPermission( setOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) ).subscribe { @@ -189,11 +205,7 @@ class GoogleBackgroundLocationService( fusedLocationProviderClient .requestLocationUpdates( locationRequest, - object : LocationCallback() { - override fun onLocationResult(locationResult: LocationResult) { - newGoogleLocationResult(locationResult) - } - }, + googleForegroundCallback, Looper.getMainLooper() ) @@ -223,6 +235,20 @@ class GoogleBackgroundLocationService( } } } + + private fun stopMonitoring() { + log.i("Stopping location monitoring.") + fusedLocationProviderClient.removeLocationUpdates(googleForegroundCallback) + } + + override fun onTrackingModeChanged(trackingMode: PrivacyService.TrackingMode) { + if (trackingMode == PrivacyService.TrackingMode.Default) { + startMonitoring() + } else { + stopMonitoring() + currentLocation = null + } + } } class LocationBroadcastReceiver : BroadcastReceiver() { @@ -238,6 +264,12 @@ class LocationBroadcastReceiver : BroadcastReceiver() { log.e("Received a location result from Google, but Rover is not initialized. Ignoring.") return } + val privacyService = rover.resolve(PrivacyService::class.java) + if (privacyService == null) { + log.e("Received a location result from Google, but the Rover PrivacyService is missing. Ensure that LocationAssembler is added to Rover.initialize(). Ignoring.") + return + } + if (privacyService.trackingMode != PrivacyService.TrackingMode.Default) return val backgroundLocationService = rover.resolve(GoogleBackgroundLocationServiceInterface::class.java) if (backgroundLocationService == null) { log.e("Received a location result from Google, but the Rover GoogleBackgroundLocationServiceInterface is missing. Ensure that LocationAssembler is added to Rover.initialize(). Ignoring.") diff --git a/location/src/main/kotlin/io/rover/sdk/location/GoogleBeaconTrackerService.kt b/location/src/main/kotlin/io/rover/sdk/location/GoogleBeaconTrackerService.kt index adbc10baa..2add01fc3 100644 --- a/location/src/main/kotlin/io/rover/sdk/location/GoogleBeaconTrackerService.kt +++ b/location/src/main/kotlin/io/rover/sdk/location/GoogleBeaconTrackerService.kt @@ -42,6 +42,7 @@ import io.rover.sdk.core.Rover import io.rover.sdk.core.logging.log import io.rover.sdk.core.permissions.PermissionsNotifierInterface import io.rover.sdk.core.platform.whenNotNull +import io.rover.sdk.core.privacy.PrivacyService import io.rover.sdk.core.streams.Publishers import io.rover.sdk.core.streams.Scheduler import io.rover.sdk.core.streams.doOnNext @@ -51,6 +52,7 @@ import io.rover.sdk.core.streams.observeOn import io.rover.sdk.core.streams.subscribe import io.rover.sdk.location.domain.Beacon import io.rover.sdk.location.sync.BeaconsRepository +import kotlinx.coroutines.reactive.asPublisher import java.util.UUID /** @@ -61,12 +63,13 @@ import java.util.UUID */ class GoogleBeaconTrackerService( private val applicationContext: Context, + private val privacyService: PrivacyService, private val nearbyMessagesClient: MessagesClient, private val beaconsRepository: BeaconsRepository, mainScheduler: Scheduler, ioScheduler: Scheduler, private val locationReportingService: LocationReportingServiceInterface, - permissionsNotifier: PermissionsNotifierInterface + permissionsNotifier: PermissionsNotifierInterface, ) : GoogleBeaconTrackerServiceInterface { override fun newGoogleBeaconMessage(intent: Intent) { nearbyMessagesClient.handleIntent( @@ -82,7 +85,7 @@ class GoogleBeaconTrackerService( log.v("A beacon lost: $message") emitEventForPossibleBeacon(message, false) } - } + }, ) } @@ -90,13 +93,17 @@ class GoogleBeaconTrackerService( * If the message matches a given [Beacon] in the database, and emits events as needed. */ private fun emitEventForPossibleBeacon(message: Message, enter: Boolean) { + if (privacyService.trackingMode != PrivacyService.TrackingMode.Default) { + return + } + return when (message.type) { Message.MESSAGE_TYPE_I_BEACON_ID -> { val ibeacon = IBeaconId.from(message) beaconsRepository.beaconByUuidMajorAndMinor( ibeacon.proximityUuid, ibeacon.major, - ibeacon.minor + ibeacon.minor, ).subscribe { beacon -> beacon.whenNotNull { if (enter) { @@ -119,11 +126,12 @@ class GoogleBeaconTrackerService( @SuppressLint("MissingPermission") private fun startMonitoringBeacons(uuids: Set) { + log.i("Subscribing to beacon tracking updates.") val messagesClient = Nearby.getMessagesClient( applicationContext, MessagesOptions.Builder() .setPermissions(NearbyPermissions.BLE) - .build() + .build(), ) val messageFilters = uuids.map { uuid -> @@ -143,9 +151,9 @@ class GoogleBeaconTrackerService( applicationContext, 0, Intent(applicationContext, BeaconBroadcastReceiver::class.java), - PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { FLAG_IMMUTABLE } else { 0 } + PendingIntent.FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE, ), - subscribeOptions + subscribeOptions, ).addOnFailureListener { log.w("Unable to configure Rover beacon tracking because: $it") }.addOnCompleteListener { @@ -153,6 +161,22 @@ class GoogleBeaconTrackerService( } } + private fun stopMonitoringBeacons() { + log.i("Unsubscribing from beacon tracking updates.") + nearbyMessagesClient.unsubscribe( + PendingIntent.getBroadcast( + applicationContext, + 0, + Intent(applicationContext, BeaconBroadcastReceiver::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE, + ), + ).addOnFailureListener { + log.w("Unable to unsubscribe from beacon tracking updates because: $it") + }.addOnCompleteListener { + log.i("Successfully unsubscribed from beacon tracking updates.") + } + } + companion object { private const val BACKGROUND_LOCATION_PERMISSION_CODE = "android.permission.ACCESS_BACKGROUND_LOCATION" private const val Q_VERSION_CODE = 29 @@ -184,17 +208,39 @@ class GoogleBeaconTrackerService( Publishers.combineLatest( // observeOn(mainScheduler) used on each because combineLatest() is not thread safe. permissionGranted.doOnNext { log.v("Permission obtained. $it") }, - beaconsRepository.allBeacons().observeOn(mainScheduler).doOnNext { log.v("Full beacons list obtained from sync.") } - ) { permission, beacons -> - Pair(permission, beacons) - }.observeOn(ioScheduler).map { (_, beacons) -> - val fetchedBeacons = beacons.iterator().use { it.asSequence().toList() } - fetchedBeacons.aggregateToUniqueUuids().apply { - log.v("Starting up beacon tracking for ${fetchedBeacons.count()} beacon(s), aggregated to ${count()} filter(s).") + beaconsRepository.allBeacons().observeOn(mainScheduler).doOnNext { log.v("Full beacons list obtained from sync.") }, + privacyService.trackingModeFlow.asPublisher().doOnNext { log.v("Informed of tracking mode: ${it.wireFormat}") }, + ) { _, beacons, trackingEnabled -> + Pair(beacons, trackingEnabled) + }.observeOn(ioScheduler) + .map { (beacons, trackingMode) -> + if (trackingMode == PrivacyService.TrackingMode.Default) { + val fetchedBeacons = beacons.iterator().use { it.asSequence().toList() } + val beaconSet = fetchedBeacons.aggregateToUniqueUuids().apply { + log.v("Starting up beacon tracking for ${fetchedBeacons.count()} beacon(s), aggregated to ${count()} filter(s).") + } + Command.Enable(beaconSet) + } else { + Command.Disable + } + }.observeOn(mainScheduler).subscribe { command -> + when (command) { + is Command.Enable -> { + startMonitoringBeacons(command.uuids) + } + is Command.Disable -> { + stopMonitoringBeacons() + } + } } - }.observeOn(mainScheduler).subscribe { beaconUuids -> - startMonitoringBeacons(beaconUuids) - } + } + + private sealed class Command { + data class Enable( + val uuids: Set, + ) : Command() + + object Disable : Command() } } @@ -209,9 +255,11 @@ class BeaconBroadcastReceiver : BroadcastReceiver() { if (beaconTrackerService == null) { log.e("Received a beacon result from Google, but GoogleBeaconTrackerServiceInterface is not registered in the Rover container. Ensure LocationAssembler() is in Rover.initialize(). Ignoring.") return - } else beaconTrackerService.newGoogleBeaconMessage( - intent - ) + } else { + beaconTrackerService.newGoogleBeaconMessage( + intent, + ) + } } } diff --git a/location/src/main/kotlin/io/rover/sdk/location/GoogleGeofenceService.kt b/location/src/main/kotlin/io/rover/sdk/location/GoogleGeofenceService.kt index c040e4d9f..fabcd19c8 100644 --- a/location/src/main/kotlin/io/rover/sdk/location/GoogleGeofenceService.kt +++ b/location/src/main/kotlin/io/rover/sdk/location/GoogleGeofenceService.kt @@ -37,6 +37,7 @@ import io.rover.sdk.core.logging.log import io.rover.sdk.core.permissions.PermissionsNotifierInterface import io.rover.sdk.core.platform.LocalStorage import io.rover.sdk.core.platform.whenNotNull +import io.rover.sdk.core.privacy.PrivacyService import io.rover.sdk.core.streams.PublishSubject import io.rover.sdk.core.streams.Publishers import io.rover.sdk.core.streams.Scheduler @@ -47,7 +48,9 @@ import io.rover.sdk.core.streams.observeOn import io.rover.sdk.core.streams.share import io.rover.sdk.core.streams.subscribe import io.rover.sdk.location.domain.asLocation +import io.rover.sdk.location.sync.ClosableSequence import io.rover.sdk.location.sync.GeofencesRepository +import kotlinx.coroutines.reactive.asPublisher import org.json.JSONArray import org.reactivestreams.Publisher @@ -61,6 +64,7 @@ import org.reactivestreams.Publisher */ class GoogleGeofenceService( private val applicationContext: Context, + privacyService: PrivacyService, private val localStorage: LocalStorage, private val geofencingClient: GeofencingClient, mainScheduler: Scheduler, @@ -78,6 +82,7 @@ class GoogleGeofenceService( .observeOn(mainScheduler) .share() + @Deprecated("Use enclosingGeofences instead.") override val currentGeofences: List get() = enclosingGeofences @@ -176,6 +181,8 @@ class GoogleGeofenceService( activeFences!!.intersect(targetFenceIds) } + // TODO: if tracking disabled, instead just clear all these geofences + val toAdd = targetFenceIds - alreadyInGoogle val fencesByIdentifier = updatedFencesList.associateBy { it.identifier } @@ -215,6 +222,15 @@ class GoogleGeofenceService( activeFences = targetFenceIds } + @SuppressLint("MissingPermission") + private fun stopMonitoring() { + log.v("Stopping monitoring of geofences.") + geofencingClient.removeGeofences( + pendingIntentForReceiverService() + ) + activeFences = emptySet() + } + /** * A Pending Intent for activating the receiver service, [GeofenceBroadcastReceiver]. */ @@ -248,22 +264,57 @@ class GoogleGeofenceService( } init { + // wait for all pre-requisites to become available before starting to monitor for geofences. Publishers.combineLatest( // observeOn(mainScheduler) used on each because combineLatest() is not thread safe. + + // This publisher doesn't yield until permission is granted, so we don't need + // to actually check its value further down the chain. permissionsNotifier.notifyForPermission(Manifest.permission.ACCESS_FINE_LOCATION).observeOn(mainScheduler).doOnNext { log.v("Permission obtained.") }, geofencesRepository.allGeofences().observeOn(mainScheduler).doOnNext { log.v("Full geofences list obtained from sync.") }, - googleBackgroundLocationService.locationUpdates.observeOn(mainScheduler).doOnNext { log.v("Location update obtained so that distant geofences can be filtered out.") } - ) { permission, fences, location -> - Triple(permission, fences, location) - }.observeOn(ioScheduler).map { (_, fences, location) -> - log.v("Determining $geofenceMonitorLimit closest geofences for monitoring.") - fences.iterator().use { it.asSequence().sortedBy { it.center.asLocation().distanceTo(location) }.take(geofenceMonitorLimit).toList() } + googleBackgroundLocationService.locationUpdates.observeOn(mainScheduler).doOnNext { log.v("Location update obtained so that distant geofences can be filtered out.") }, + privacyService.trackingModeFlow.asPublisher().doOnNext() { log.v("Informed of tracking mode: $it") }, + + ) { _, fences, location, trackingMode -> + if (trackingMode == PrivacyService.TrackingMode.Default) { + Command.Enable(fences, location) + } else { + Command.Disable + } + }.observeOn(ioScheduler).map { command: Command -> + when (command) { + is Command.Enable -> { + val (fences, location) = command + log.v("Determining $geofenceMonitorLimit closest geofences for monitoring.") + fences.iterator().use { it.asSequence().sortedBy { it.center.asLocation().distanceTo(location) }.take(geofenceMonitorLimit).toList() } + } + is Command.Disable -> { + log.v("Geofences are now disabled, monitoring for 0 geofences.") + emptyList() + } + } }.observeOn(mainScheduler).subscribe { fences -> - log.v("Got location permission, geofences, and current location. Ready to start monitoring for ${fences.count()} geofence(s).") - startMonitoringGeofences(fences) + if (fences.isEmpty()) { + stopMonitoring() + } else { + log.v("Got location permission, privacy setting, geofences, and current location. Ready to start monitoring for ${fences.count()} geofence(s).") + startMonitoringGeofences(fences) + } } } + /** + * Describes how the the geofence service should configure itself. + */ + private sealed class Command { + data class Enable( + val fences: ClosableSequence, + val location: Location, + ) : Command() + + object Disable : Command() + } + companion object { private const val ACTIVE_FENCES_KEY = "active-fences" private const val STORAGE_CONTEXT_IDENTIFIER = "google-geofence-service" diff --git a/location/src/main/kotlin/io/rover/sdk/location/Interfaces.kt b/location/src/main/kotlin/io/rover/sdk/location/Interfaces.kt index dc2bdf5ca..500c3aee7 100644 --- a/location/src/main/kotlin/io/rover/sdk/location/Interfaces.kt +++ b/location/src/main/kotlin/io/rover/sdk/location/Interfaces.kt @@ -21,11 +21,12 @@ import android.content.Intent import com.google.android.gms.location.GeofencingEvent import com.google.android.gms.location.LocationResult import io.rover.sdk.core.data.domain.Location +import io.rover.sdk.core.privacy.PrivacyService import io.rover.sdk.location.domain.Beacon import io.rover.sdk.location.domain.Geofence import org.reactivestreams.Publisher -interface GoogleBackgroundLocationServiceInterface { +interface GoogleBackgroundLocationServiceInterface: PrivacyService.TrackingEnabledChangedListener { /** * The Google Location Services have yielded a new [LocationResult] to us. */ diff --git a/location/src/main/kotlin/io/rover/sdk/location/LocationAssembler.kt b/location/src/main/kotlin/io/rover/sdk/location/LocationAssembler.kt index d6bf7ee11..8dc427ba0 100644 --- a/location/src/main/kotlin/io/rover/sdk/location/LocationAssembler.kt +++ b/location/src/main/kotlin/io/rover/sdk/location/LocationAssembler.kt @@ -42,6 +42,7 @@ import io.rover.sdk.core.events.EventQueueServiceInterface import io.rover.sdk.core.permissions.PermissionsNotifierInterface import io.rover.sdk.core.platform.DateFormattingInterface import io.rover.sdk.core.platform.LocalStorage +import io.rover.sdk.core.privacy.PrivacyService import io.rover.sdk.core.streams.Scheduler import io.rover.sdk.location.events.contextproviders.LocationContextProvider import io.rover.sdk.location.sync.BeaconSyncDecoder @@ -223,7 +224,8 @@ class LocationAssembler( "location" ) { resolver -> LocationContextProvider( - resolver.resolveSingletonOrFail(GoogleBackgroundLocationServiceInterface::class.java) + resolver.resolveSingletonOrFail(GoogleBackgroundLocationServiceInterface::class.java), + resolver.resolveSingletonOrFail(PrivacyService::class.java), ) } @@ -243,6 +245,7 @@ class LocationAssembler( GoogleBackgroundLocationServiceInterface::class.java ) { resolver -> GoogleBackgroundLocationService( + resolver.resolveSingletonOrFail(PrivacyService::class.java), resolver.resolveSingletonOrFail( FusedLocationProviderClient::class.java ), @@ -275,6 +278,7 @@ class LocationAssembler( ) { resolver -> GoogleGeofenceService( resolver.resolveSingletonOrFail(Context::class.java), + resolver.resolveSingletonOrFail(PrivacyService::class.java), resolver.resolveSingletonOrFail(LocalStorage::class.java), resolver.resolveSingletonOrFail(GeofencingClient::class.java), resolver.resolveSingletonOrFail(Scheduler::class.java, "main"), @@ -308,6 +312,7 @@ class LocationAssembler( ) { resolver -> GoogleBeaconTrackerService( resolver.resolveSingletonOrFail(Context::class.java), + resolver.resolveSingletonOrFail(PrivacyService::class.java), resolver.resolveSingletonOrFail(MessagesClient::class.java), resolver.resolveSingletonOrFail(BeaconsRepository::class.java), resolver.resolveSingletonOrFail(Scheduler::class.java, "main"), @@ -359,6 +364,10 @@ class LocationAssembler( resolver.resolveSingletonOrFail(ContextProvider::class.java, "location") ) } + + resolver.resolveSingletonOrFail(PrivacyService::class.java).registerTrackingEnabledChangedListener( + resolver.resolveSingletonOrFail(GoogleBackgroundLocationServiceInterface::class.java) + ) } } diff --git a/location/src/main/kotlin/io/rover/sdk/location/events/contextproviders/LocationContextProvider.kt b/location/src/main/kotlin/io/rover/sdk/location/events/contextproviders/LocationContextProvider.kt index 9d3d775e4..f46689f8a 100644 --- a/location/src/main/kotlin/io/rover/sdk/location/events/contextproviders/LocationContextProvider.kt +++ b/location/src/main/kotlin/io/rover/sdk/location/events/contextproviders/LocationContextProvider.kt @@ -20,12 +20,14 @@ package io.rover.sdk.location.events.contextproviders import io.rover.sdk.core.data.domain.DeviceContext import io.rover.sdk.core.data.domain.Location import io.rover.sdk.core.events.ContextProvider +import io.rover.sdk.core.privacy.PrivacyService import io.rover.sdk.core.streams.subscribe import io.rover.sdk.location.GoogleBackgroundLocationServiceInterface class LocationContextProvider( - googleBackgroundLocationService: GoogleBackgroundLocationServiceInterface -) : ContextProvider { + googleBackgroundLocationService: GoogleBackgroundLocationServiceInterface, + privacyService: PrivacyService +) : ContextProvider, PrivacyService.TrackingEnabledChangedListener { override fun captureContext(deviceContext: DeviceContext): DeviceContext { return deviceContext.copy( location = currentLocation @@ -38,5 +40,13 @@ class LocationContextProvider( googleBackgroundLocationService.locationUpdates.subscribe { location -> currentLocation = location } + + privacyService.registerTrackingEnabledChangedListener(this) + } + + override fun onTrackingModeChanged(trackingMode: PrivacyService.TrackingMode) { + if (trackingMode == PrivacyService.TrackingMode.Anonymized) { + currentLocation = null + } } } diff --git a/location/src/main/kotlin/io/rover/sdk/location/sync/BeaconsSyncParticipant.kt b/location/src/main/kotlin/io/rover/sdk/location/sync/BeaconsSyncParticipant.kt index f250e75af..8aaef3faf 100644 --- a/location/src/main/kotlin/io/rover/sdk/location/sync/BeaconsSyncParticipant.kt +++ b/location/src/main/kotlin/io/rover/sdk/location/sync/BeaconsSyncParticipant.kt @@ -54,12 +54,19 @@ class BeaconsRepository( private val beaconsSqlStorage: BeaconsSqlStorage, private val ioScheduler: Scheduler ) { - fun allBeacons(): Publisher> = syncCoordinator - .updates - .observeOn(ioScheduler) - .map { - beaconsSqlStorage.queryAllBeacons() - } + fun allBeacons(): Publisher> = + Publishers.concat( + Publishers.defer { + Publishers.just(beaconsSqlStorage.queryAllBeacons()) + }, + syncCoordinator + .updates + .observeOn(ioScheduler) + .map { + beaconsSqlStorage.queryAllBeacons() + } + ) + /** * Look up a [Beacon] by its iBeacon UUID, major, and minor. Yields it if it exists. diff --git a/location/src/main/kotlin/io/rover/sdk/location/sync/GeofencesSyncParticipant.kt b/location/src/main/kotlin/io/rover/sdk/location/sync/GeofencesSyncParticipant.kt index a78c6d7b9..e34003c93 100644 --- a/location/src/main/kotlin/io/rover/sdk/location/sync/GeofencesSyncParticipant.kt +++ b/location/src/main/kotlin/io/rover/sdk/location/sync/GeofencesSyncParticipant.kt @@ -58,15 +58,20 @@ class GeofencesRepository( * * Be sure to close the [ClosableSequence] when you are finished iterating through it. */ - fun allGeofences(): Publisher> = syncCoordinator - .updates - .observeOn(ioScheduler) - .map { - // for now, we don't check the result because we just want an *attempt* to have completely - // occurred. In future we may keep state for tracking if at least one sync has happened - // successfully over the install lifetime of the app, but for now, this will do. - geofencesSqlStorage.queryAllGeofences() - } + fun allGeofences(): Publisher> = + Publishers.concat( + Publishers.defer { Publishers.just(geofencesSqlStorage.queryAllGeofences()) }, + syncCoordinator + .updates + .observeOn(ioScheduler) + .map { + // for now, we don't check the result because we just want an *attempt* to have completely + // occurred. In future we may keep state for tracking if at least one sync has happened + // successfully over the install lifetime of the app, but for now, this will do. + geofencesSqlStorage.queryAllGeofences() + } + ) + fun geofenceByIdentifier(identifier: String): Publisher { return Publishers.defer { diff --git a/seatgeek/src/main/kotlin/io/rover/sdk/seatgeek/SeatGeekAssembler.kt b/seatgeek/src/main/kotlin/io/rover/sdk/seatgeek/SeatGeekAssembler.kt index 7a0f3753e..a0ec8aa87 100644 --- a/seatgeek/src/main/kotlin/io/rover/sdk/seatgeek/SeatGeekAssembler.kt +++ b/seatgeek/src/main/kotlin/io/rover/sdk/seatgeek/SeatGeekAssembler.kt @@ -27,6 +27,7 @@ import io.rover.sdk.core.container.Resolver import io.rover.sdk.core.container.Scope import io.rover.sdk.core.events.UserInfoInterface import io.rover.sdk.core.platform.LocalStorage +import io.rover.sdk.core.privacy.PrivacyService class SeatGeekAssembler : Assembler { override fun assemble(container: Container) { @@ -39,14 +40,21 @@ class SeatGeekAssembler : Assembler { container.register( Scope.Singleton, - SeatGeekManager::class.java + SeatGeekManager::class.java ) { resolver -> SeatGeekManager( resolver.resolveSingletonOrFail(UserInfoInterface::class.java), - resolver.resolveSingletonOrFail(LocalStorage::class.java) + resolver.resolveSingletonOrFail(LocalStorage::class.java), + resolver.resolveSingletonOrFail(PrivacyService::class.java), ) } } + + override fun afterAssembly(resolver: Resolver) { + resolver.resolveSingletonOrFail(PrivacyService::class.java).registerTrackingEnabledChangedListener( + resolver.resolveSingletonOrFail(SeatGeekManager::class.java) + ) + } } val Rover.seatGeekAuthorizer: SeatGeekAuthorizer diff --git a/seatgeek/src/main/kotlin/io/rover/sdk/seatgeek/SeatGeekManager.kt b/seatgeek/src/main/kotlin/io/rover/sdk/seatgeek/SeatGeekManager.kt index 7ed3e452f..49f71a481 100644 --- a/seatgeek/src/main/kotlin/io/rover/sdk/seatgeek/SeatGeekManager.kt +++ b/seatgeek/src/main/kotlin/io/rover/sdk/seatgeek/SeatGeekManager.kt @@ -22,13 +22,15 @@ import io.rover.sdk.core.events.UserInfoInterface import io.rover.sdk.core.logging.log import io.rover.sdk.core.platform.LocalStorage import io.rover.sdk.core.platform.whenNotNull +import io.rover.sdk.core.privacy.PrivacyService import org.json.JSONException import org.json.JSONObject class SeatGeekManager( private val userInfo: UserInfoInterface, - localStorage: LocalStorage -) : SeatGeekAuthorizer { + localStorage: LocalStorage, + private val privacyService: PrivacyService, +) : SeatGeekAuthorizer, PrivacyService.TrackingEnabledChangedListener { private val storage = localStorage.getKeyValueStorageFor(STORAGE_CONTEXT_IDENTIFIER) companion object { @@ -38,6 +40,9 @@ class SeatGeekManager( } override fun setSeatGeekId(crmID: String) { + if (privacyService.trackingMode != PrivacyService.TrackingMode.Default) { + return + } seatGeekID = crmID updateUserInfoWithMemberAttributes() log.i("SeatGeek signed in with '$crmID'.") @@ -93,4 +98,10 @@ class SeatGeekManager( seatGeekID.whenNotNull { propertiesMap.put(SEATGEEK_ID_KEY, it)} return propertiesMap } + + override fun onTrackingModeChanged(trackingMode: PrivacyService.TrackingMode) { + if (trackingMode != PrivacyService.TrackingMode.Default) { + clearCredentials() + } + } } diff --git a/ticketmaster/src/main/kotlin/io/rover/sdk/ticketmaster/TicketMasterAnalyticsBroadcastReceiver.kt b/ticketmaster/src/main/kotlin/io/rover/sdk/ticketmaster/TicketMasterAnalyticsBroadcastReceiver.kt index 7d78d4e25..62bfaedec 100644 --- a/ticketmaster/src/main/kotlin/io/rover/sdk/ticketmaster/TicketMasterAnalyticsBroadcastReceiver.kt +++ b/ticketmaster/src/main/kotlin/io/rover/sdk/ticketmaster/TicketMasterAnalyticsBroadcastReceiver.kt @@ -23,9 +23,12 @@ import android.content.Intent import io.rover.sdk.core.Rover import io.rover.sdk.core.events.EventQueueServiceInterface import io.rover.sdk.core.events.domain.Event +import io.rover.sdk.core.privacy.PrivacyService class TicketMasterAnalyticsBroadcastReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { + val privacyService = Rover.shared.resolve(PrivacyService::class.java) ?: return + if(privacyService.trackingMode != PrivacyService.TrackingMode.Default) return intent?.action?.let { action -> TMScreenActionToRoverNames[action]?.let { screenName -> trackTicketMasterScreenViewedEvent(intent, screenName) diff --git a/ticketmaster/src/main/kotlin/io/rover/sdk/ticketmaster/TicketmasterAssembler.kt b/ticketmaster/src/main/kotlin/io/rover/sdk/ticketmaster/TicketmasterAssembler.kt index d7b0f6739..50ad31709 100644 --- a/ticketmaster/src/main/kotlin/io/rover/sdk/ticketmaster/TicketmasterAssembler.kt +++ b/ticketmaster/src/main/kotlin/io/rover/sdk/ticketmaster/TicketmasterAssembler.kt @@ -27,6 +27,7 @@ import io.rover.sdk.core.container.Resolver import io.rover.sdk.core.container.Scope import io.rover.sdk.core.events.UserInfoInterface import io.rover.sdk.core.platform.LocalStorage +import io.rover.sdk.core.privacy.PrivacyService class TicketmasterAssembler : Assembler { override fun assemble(container: Container) { @@ -43,7 +44,8 @@ class TicketmasterAssembler : Assembler { ) { resolver -> TicketmasterManager( resolver.resolveSingletonOrFail(UserInfoInterface::class.java), - resolver.resolveSingletonOrFail(LocalStorage::class.java) + resolver.resolveSingletonOrFail(LocalStorage::class.java), + resolver.resolveSingletonOrFail(PrivacyService::class.java) ) } } @@ -55,6 +57,10 @@ class TicketmasterAssembler : Assembler { LocalBroadcastManager.getInstance(resolver.resolveSingletonOrFail(Application::class.java).applicationContext) .registerReceiver(TicketMasterAnalyticsBroadcastReceiver(), analyticEventFilter) + + resolver.resolveSingletonOrFail(PrivacyService::class.java).registerTrackingEnabledChangedListener( + resolver.resolveSingletonOrFail(TicketmasterManager::class.java) + ) } } diff --git a/ticketmaster/src/main/kotlin/io/rover/sdk/ticketmaster/TicketmasterManager.kt b/ticketmaster/src/main/kotlin/io/rover/sdk/ticketmaster/TicketmasterManager.kt index 3b100d0c7..24b4300f2 100644 --- a/ticketmaster/src/main/kotlin/io/rover/sdk/ticketmaster/TicketmasterManager.kt +++ b/ticketmaster/src/main/kotlin/io/rover/sdk/ticketmaster/TicketmasterManager.kt @@ -17,20 +17,21 @@ package io.rover.sdk.ticketmaster -import android.content.Context import io.rover.sdk.core.data.graphql.putProp import io.rover.sdk.core.data.graphql.safeOptString import io.rover.sdk.core.events.UserInfoInterface import io.rover.sdk.core.logging.log import io.rover.sdk.core.platform.LocalStorage import io.rover.sdk.core.platform.whenNotNull +import io.rover.sdk.core.privacy.PrivacyService import org.json.JSONException import org.json.JSONObject class TicketmasterManager( private val userInfo: UserInfoInterface, - localStorage: LocalStorage -) : TicketmasterAuthorizer { + localStorage: LocalStorage, + private val privacyService: PrivacyService +) : TicketmasterAuthorizer, PrivacyService.TrackingEnabledChangedListener { private val storage = localStorage.getKeyValueStorageFor(STORAGE_CONTEXT_IDENTIFIER) companion object { @@ -39,6 +40,7 @@ class TicketmasterManager( } override fun setTicketmasterId(id: String) { + if (privacyService.trackingMode != PrivacyService.TrackingMode.Default) return member = Member( ticketmasterID = id, email = null, @@ -102,6 +104,12 @@ class TicketmasterManager( return propertiesMap } } + + override fun onTrackingModeChanged(trackingMode: PrivacyService.TrackingMode) { + if (trackingMode != PrivacyService.TrackingMode.Default) { + clearCredentials() + } + } } fun TicketmasterManager.Member.Companion.decodeJson(json: JSONObject): TicketmasterManager.Member {