diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 5498f33ed3..6414f29a10 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -271,7 +271,7 @@ The user name space is accessible via `OneSignal.User` (in Kotlin) or `OneSignal | `fun addTags(tags: Map)` | `void addTags(Map tags)` | *Add multiple tags for the current user. Tags are key:value pairs used as building blocks for targeting specific users and/or personalizing messages. If the tag key already exists, it will be replaced with the value provided here.* | | `fun removeTag(key: String)` | `void removeTag(String key)` | *Remove the data tag with the provided key from the current user.* | | `fun removeTags(keys: Collection)` | `void removeTags(Collection keys)` | *Remove multiple tags from the current user.* | - +| `fun getTags()` | `Map getTags()` | *Return a copy of all local tags from the current user.* **Session Namespace** The session namespace is accessible via `OneSignal.Session` (in Kotlin) or `OneSignal.getSession()` (in Java) and provides access to session-scoped functionality. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/PreferenceStoreFix.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/PreferenceStoreFix.kt new file mode 100644 index 0000000000..773d1e4651 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/PreferenceStoreFix.kt @@ -0,0 +1,51 @@ +package com.onesignal.core.internal.preferences + +import android.content.Context +import android.os.Build +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import java.io.File + +object PreferenceStoreFix { + /** + * Ensure the OneSignal preference store is not using the v4 obfuscated version, if one + * exists. + */ + fun ensureNoObfuscatedPrefStore(context: Context) { + try { + // In the v4 version the OneSignal shared preference name was based on the OneSignal + // class name, which might be minimized/obfuscated if the app is using ProGuard or + // similar. In order for a device to successfully migrate from v4 to v5 picking + // up the subscription, we need to copy the shared preferences from the obfuscated + // version to the static "OneSignal" preference name. We only do this + // if there isn't already a "OneSignal" preference store. + val sharedPrefsDir = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + File(context.dataDir, "shared_prefs") + } else { + File(context.filesDir.parentFile, "shared_prefs") + } + + val osPrefsFile = File(sharedPrefsDir, "OneSignal.xml") + + if (!sharedPrefsDir.exists() || !sharedPrefsDir.isDirectory || osPrefsFile.exists()) { + return + } + + val prefsFileList = sharedPrefsDir.listFiles() ?: return + + // Go through every preference file, looking for the OneSignal preference store. + for (prefsFile in prefsFileList) { + val prefsStore = + context.getSharedPreferences(prefsFile.nameWithoutExtension, Context.MODE_PRIVATE) + + if (prefsStore.contains(PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID)) { + prefsFile.renameTo(osPrefsFile) + return + } + } + } catch (e: Throwable) { + Logging.log(LogLevel.ERROR, "error attempting to fix obfuscated preference store", e) + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index d60a2e4fd6..f5dc53b2e5 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -22,6 +22,7 @@ import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.IOperationRepo import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStoreFix import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.core.internal.startup.StartupService import com.onesignal.debug.IDebugManager @@ -133,6 +134,7 @@ internal class OneSignalImp : IOneSignal, IServiceProvider { private var _consentRequired: Boolean? = null private var _consentGiven: Boolean? = null private var _disableGMSMissingPrompt: Boolean? = null + private val initLock: Any = Any() private val loginLock: Any = Any() private val listOfModules = @@ -173,93 +175,140 @@ internal class OneSignalImp : IOneSignal, IServiceProvider { ): Boolean { Logging.log(LogLevel.DEBUG, "initWithContext(context: $context, appId: $appId)") - // do not do this again if already initialized - if (isInitialized) { - return true - } + synchronized(initLock) { + // do not do this again if already initialized + if (isInitialized) { + Logging.log(LogLevel.DEBUG, "initWithContext: SDK already initialized") + return true + } - // start the application service. This is called explicitly first because we want - // to make sure it has the context provided on input, for all other startable services - // to depend on if needed. - val applicationService = services.getService() - (applicationService as ApplicationService).start(context) - - // Give the logging singleton access to the application service to support visual logging. - Logging.applicationService = applicationService - - // get the current config model, if there is one - configModel = services.getService().model - sessionModel = services.getService().model - - // initWithContext is called by our internal services/receivers/activites but they do not provide - // an appId (they don't know it). If the app has never called the external initWithContext - // prior to our services/receivers/activities we will blow up, as no appId has been established. - if (appId == null && !configModel!!.hasProperty(ConfigModel::appId.name)) { - Logging.warn("initWithContext called without providing appId, and no appId has been established!") - return false - } + Logging.log(LogLevel.DEBUG, "initWithContext: SDK initializing") - var forceCreateUser = false - // if the app id was specified as input, update the config model with it - if (appId != null) { - if (!configModel!!.hasProperty(ConfigModel::appId.name) || configModel!!.appId != appId) { - forceCreateUser = true + PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) + + // start the application service. This is called explicitly first because we want + // to make sure it has the context provided on input, for all other startable services + // to depend on if needed. + val applicationService = services.getService() + (applicationService as ApplicationService).start(context) + + // Give the logging singleton access to the application service to support visual logging. + Logging.applicationService = applicationService + + // get the current config model, if there is one + configModel = services.getService().model + sessionModel = services.getService().model + + // initWithContext is called by our internal services/receivers/activites but they do not provide + // an appId (they don't know it). If the app has never called the external initWithContext + // prior to our services/receivers/activities we will blow up, as no appId has been established. + if (appId == null && !configModel!!.hasProperty(ConfigModel::appId.name)) { + Logging.warn("initWithContext called without providing appId, and no appId has been established!") + return false } - configModel!!.appId = appId - } - // if requires privacy consent was set prior to init, set it in the model now - if (_consentRequired != null) { - configModel!!.consentRequired = _consentRequired!! - } + var forceCreateUser = false + // if the app id was specified as input, update the config model with it + if (appId != null) { + if (!configModel!!.hasProperty(ConfigModel::appId.name) || configModel!!.appId != appId) { + forceCreateUser = true + } + configModel!!.appId = appId + } - // if privacy consent was set prior to init, set it in the model now - if (_consentGiven != null) { - configModel!!.consentGiven = _consentGiven!! - } + // if requires privacy consent was set prior to init, set it in the model now + if (_consentRequired != null) { + configModel!!.consentRequired = _consentRequired!! + } - if (_disableGMSMissingPrompt != null) { - configModel!!.disableGMSMissingPrompt = _disableGMSMissingPrompt!! - } + // if privacy consent was set prior to init, set it in the model now + if (_consentGiven != null) { + configModel!!.consentGiven = _consentGiven!! + } - // "Inject" the services required by this main class - _location = services.getService() - _user = services.getService() - _session = services.getService() - iam = services.getService() - _notifications = services.getService() - operationRepo = services.getService() - propertiesModelStore = services.getService() - identityModelStore = services.getService() - subscriptionModelStore = services.getService() - preferencesService = services.getService() - - // Instantiate and call the IStartableServices - startupService = services.getService() - startupService!!.bootstrap() - - if (forceCreateUser || !identityModelStore!!.model.hasProperty(IdentityConstants.ONESIGNAL_ID)) { - val legacyPlayerId = preferencesService!!.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID) - if (legacyPlayerId == null) { - Logging.debug("initWithContext: creating new device-scoped user") - createAndSwitchToNewUser() - operationRepo!!.enqueue( - LoginUserOperation( - configModel!!.appId, - identityModelStore!!.model.onesignalId, - identityModelStore!!.model.externalId, - ), - ) - } else { - Logging.debug("initWithContext: creating user linked to subscription $legacyPlayerId") + if (_disableGMSMissingPrompt != null) { + configModel!!.disableGMSMissingPrompt = _disableGMSMissingPrompt!! + } - // Converting a 4.x SDK to the 5.x SDK. We pull the legacy user sync values to create the subscription model, then enqueue - // a specialized `LoginUserFromSubscriptionOperation`, which will drive fetching/refreshing of the local user - // based on the subscription ID we do have. - val legacyUserSyncString = + // "Inject" the services required by this main class + _location = services.getService() + _user = services.getService() + _session = services.getService() + iam = services.getService() + _notifications = services.getService() + operationRepo = services.getService() + propertiesModelStore = services.getService() + identityModelStore = services.getService() + subscriptionModelStore = services.getService() + preferencesService = services.getService() + + // Instantiate and call the IStartableServices + startupService = services.getService() + startupService!!.bootstrap() + + if (forceCreateUser || !identityModelStore!!.model.hasProperty(IdentityConstants.ONESIGNAL_ID)) { + val legacyPlayerId = preferencesService!!.getString( PreferenceStores.ONESIGNAL, - PreferenceOneSignalKeys.PREFS_LEGACY_USER_SYNCVALUES, + PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID, + ) + if (legacyPlayerId == null) { + Logging.debug("initWithContext: creating new device-scoped user") + createAndSwitchToNewUser() + operationRepo!!.enqueue( + LoginUserOperation( + configModel!!.appId, + identityModelStore!!.model.onesignalId, + identityModelStore!!.model.externalId, + ), + ) + } else { + Logging.debug("initWithContext: creating user linked to subscription $legacyPlayerId") + + // Converting a 4.x SDK to the 5.x SDK. We pull the legacy user sync values to create the subscription model, then enqueue + // a specialized `LoginUserFromSubscriptionOperation`, which will drive fetching/refreshing of the local user + // based on the subscription ID we do have. + val legacyUserSyncString = + preferencesService!!.getString( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.PREFS_LEGACY_USER_SYNCVALUES, + ) + var suppressBackendOperation = false + + if (legacyUserSyncString != null) { + val legacyUserSyncJSON = JSONObject(legacyUserSyncString) + val notificationTypes = legacyUserSyncJSON.getInt("notification_types") + + val pushSubscriptionModel = SubscriptionModel() + pushSubscriptionModel.id = legacyPlayerId + pushSubscriptionModel.type = SubscriptionType.PUSH + pushSubscriptionModel.optedIn = + notificationTypes != SubscriptionStatus.NO_PERMISSION.value && notificationTypes != SubscriptionStatus.UNSUBSCRIBE.value + pushSubscriptionModel.address = + legacyUserSyncJSON.safeString("identifier") ?: "" + pushSubscriptionModel.status = SubscriptionStatus.fromInt(notificationTypes) + ?: SubscriptionStatus.NO_PERMISSION + configModel!!.pushSubscriptionId = legacyPlayerId + subscriptionModelStore!!.add( + pushSubscriptionModel, + ModelChangeTags.NO_PROPOGATE, + ) + suppressBackendOperation = true + } + + createAndSwitchToNewUser(suppressBackendOperation = suppressBackendOperation) + + operationRepo!!.enqueue( + LoginUserFromSubscriptionOperation( + configModel!!.appId, + identityModelStore!!.model.onesignalId, + legacyPlayerId, + ), + ) + preferencesService!!.saveString( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID, + null, ) var suppressBackendOperation = false @@ -282,24 +331,22 @@ internal class OneSignalImp : IOneSignal, IServiceProvider { subscriptionModelStore!!.add(pushSubscriptionModel, ModelChangeTags.NO_PROPOGATE) suppressBackendOperation = true } - - createAndSwitchToNewUser(suppressBackendOperation = suppressBackendOperation) - + } else { + Logging.debug("initWithContext: using cached user ${identityModelStore!!.model.onesignalId}") operationRepo!!.enqueue( - LoginUserFromSubscriptionOperation(configModel!!.appId, identityModelStore!!.model.onesignalId, legacyPlayerId), + RefreshUserOperation( + configModel!!.appId, + identityModelStore!!.model.onesignalId, + ), ) - preferencesService!!.saveString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID, null) } - } else { - Logging.debug("initWithContext: using cached user ${identityModelStore!!.model.onesignalId}") - operationRepo!!.enqueue(RefreshUserOperation(configModel!!.appId, identityModelStore!!.model.onesignalId)) - } - startupService!!.start() + startupService!!.start() - isInitialized = true + isInitialized = true - return true + return true + } } override fun login( @@ -309,7 +356,7 @@ internal class OneSignalImp : IOneSignal, IServiceProvider { Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") if (!isInitialized) { - Logging.log(LogLevel.ERROR, "Must call 'initWithContext' before using Login") + throw Exception("Must call 'initWithContext' before 'login'") } var currentIdentityExternalId: String? = null @@ -383,8 +430,7 @@ internal class OneSignalImp : IOneSignal, IServiceProvider { Logging.log(LogLevel.DEBUG, "logout()") if (!isInitialized) { - Logging.log(LogLevel.ERROR, "Must call 'initWithContext' before using Login") - return + throw Exception("Must call 'initWithContext' before 'logout'") } // only allow one login/logout at a time diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/IUserManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/IUserManager.kt index e9de656d0d..b3e2bc3864 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/IUserManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/IUserManager.kt @@ -133,4 +133,9 @@ interface IUserManager { * @param keys The collection of keys, all of which will be removed from the current user. */ fun removeTags(keys: Collection) + + /** + * Return a copy of all local tags from the current user. + */ + fun getTags(): Map } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt index dbb8add277..0effb3c9ed 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt @@ -23,9 +23,6 @@ internal open class UserManager( val externalId: String? get() = _identityModel.externalId - val tags: Map - get() = _propertiesModel.tags - val aliases: Map get() = _identityModel.filter { it.key != IdentityModel::id.name }.toMap() @@ -218,4 +215,8 @@ internal open class UserManager( _propertiesModel.tags.remove(it) } } + + override fun getTags(): Map { + return _propertiesModel.tags.toMap() + } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt index d32fac669e..92913742e1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt @@ -34,6 +34,8 @@ internal class UserBackendService( requestJSON.put("properties", JSONObject().putMap(properties)) } + requestJSON.put("refresh_device_metadata", true) + val response = _httpClient.post("apps/$appId/users", requestJSON) if (!response.isSuccess) { diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt new file mode 100644 index 0000000000..2f855a51d1 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt @@ -0,0 +1,44 @@ +package com.onesignal.internal + +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import io.kotest.assertions.throwables.shouldThrowUnit +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.runner.junit4.KotestTestRunner +import org.junit.runner.RunWith + +@RunWith(KotestTestRunner::class) +class OneSignalImpTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + test("attempting login before initWithContext throws exception") { + // Given + val os = OneSignalImp() + + // When + val exception = + shouldThrowUnit { + os.login("login-id") + } + + // Then + exception.message shouldBe "Must call 'initWithContext' before 'login'" + } + + test("attempting logout before initWithContext throws exception") { + // Given + val os = OneSignalImp() + + // When + val exception = + shouldThrowUnit { + os.logout() + } + + // Then + exception.message shouldBe "Must call 'initWithContext' before 'logout'" + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt index b0e0953805..504d397dea 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt @@ -6,6 +6,7 @@ import com.onesignal.user.internal.subscriptions.ISubscriptionManager import com.onesignal.user.internal.subscriptions.SubscriptionList import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe import io.kotest.runner.junit4.KotestTestRunner import io.mockk.every import io.mockk.just @@ -107,8 +108,8 @@ class UserManagerTests : FunSpec({ UserManager(mockSubscriptionManager, MockHelper.identityModelStore(), propertiesModelStore, MockHelper.languageContext()) // When - val tag1 = userManager.tags["my-tag-key1"] - val tag2 = userManager.tags["my-tag-key2"] + val tag1 = propertiesModelStore.model.tags["my-tag-key1"] + val tag2 = propertiesModelStore.model.tags["my-tag-key2"] // add userManager.addTag("my-tag-key5", "my-tag-value5") @@ -135,6 +136,31 @@ class UserManagerTests : FunSpec({ propertiesModelStore.model.tags["my-tag-key3"] shouldBe null } + test("getTags returns a copy of tags") { + // Given + val mockSubscriptionManager = mockk() + val propertiesModelStore = + MockHelper.propertiesModelStore { + it.tags["my-tag-key1"] = "my-tag-value1" + } + + val userManager = UserManager(mockSubscriptionManager, MockHelper.identityModelStore(), propertiesModelStore, MockHelper.languageContext()) + + // When + val tagSnapshot1 = userManager.getTags() + + // Then + tagSnapshot1.size shouldBe propertiesModelStore.model.tags.size + tagSnapshot1["my-tag-key1"] shouldBe propertiesModelStore.model.tags["my-tag-key1"] + + // Modify + userManager.addTag("my-tag-key2", "my-tag-value2") + userManager.getTags().size shouldBe 2 + + // Then + tagSnapshot1.size shouldNotBe userManager.getTags().size + } + test("subscriptions are backed by the subscriptions manager") { // Given val subscriptionList = SubscriptionList(listOf(), UninitializedPushSubscription())