From a35c85e21d33ef33e56c738cfd73c6c4a45376fe Mon Sep 17 00:00:00 2001 From: sherwinski Date: Tue, 14 Apr 2026 13:06:24 -0700 Subject: [PATCH 1/9] feat: add PIIHasher utility for deterministic SHA-256 hashing Standalone utility for hashing PII fields (email, phone number) before persisting to SharedPreferences. Includes hash detection so already-hashed values are not double-hashed. --- .../java/com/onesignal/common/PIIHasher.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/PIIHasher.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/PIIHasher.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/PIIHasher.kt new file mode 100644 index 000000000..35e90595b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/PIIHasher.kt @@ -0,0 +1,23 @@ +package com.onesignal.common + +import java.security.MessageDigest + +/** + * Deterministic SHA-256 hashing for PII fields (email, phone number) so that + * sensitive data stored in SharedPreferences is not readable in plain text on + * rooted devices or via ADB backup. + * + * The hash is hex-encoded and always 64 characters long. + */ +object PIIHasher { + private const val SHA256_HEX_LENGTH = 64 + private val SHA256_HEX_REGEX = Regex("^[a-f0-9]{$SHA256_HEX_LENGTH}$") + + fun hash(value: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val bytes = digest.digest(value.toByteArray(Charsets.UTF_8)) + return bytes.joinToString("") { "%02x".format(it) } + } + + fun isHashed(value: String): Boolean = SHA256_HEX_REGEX.matches(value) +} From d1b89d130b75cb0e6a5209988cc7bff47fba7f3d Mon Sep 17 00:00:00 2001 From: sherwinski Date: Tue, 14 Apr 2026 13:06:35 -0700 Subject: [PATCH 2/9] refactor: add transformJsonForPersistence hook to ModelStore Adds a protected open method that subclasses can override to transform a model's JSON before it is written to SharedPreferences. The default implementation returns the JSON unchanged, so this is a no-op for all existing model stores. --- .../java/com/onesignal/common/modeling/ModelStore.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/modeling/ModelStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/modeling/ModelStore.kt index f7c41a410..fc4b6566b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/modeling/ModelStore.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/modeling/ModelStore.kt @@ -7,6 +7,7 @@ import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.internal.logging.Logging import org.json.JSONArray +import org.json.JSONObject /** * The abstract implementation of a model store. Implements all but the [create] method, @@ -212,13 +213,22 @@ abstract class ModelStore( val jsonArray = JSONArray() synchronized(models) { for (model in models) { - jsonArray.put(model.toJSON()) + jsonArray.put(transformJsonForPersistence(model, model.toJSON())) } } _prefs.saveString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.MODEL_STORE_PREFIX + name, jsonArray.toString()) } + /** + * Hook for subclasses to transform a model's JSON representation before it is + * written to SharedPreferences. The default implementation returns the JSON unchanged. + */ + protected open fun transformJsonForPersistence( + model: TModel, + json: JSONObject, + ): JSONObject = json + override fun subscribe(handler: IModelStoreChangeHandler) = changeSubscription.subscribe(handler) override fun unsubscribe(handler: IModelStoreChangeHandler) = changeSubscription.unsubscribe(handler) From 347fef8f24cdc4865f4f92fc537b01c40102b2c2 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Tue, 14 Apr 2026 13:06:46 -0700 Subject: [PATCH 3/9] feat: hash email/SMS addresses at the persistence boundary Override transformJsonForPersistence in SubscriptionModelStore to SHA-256 hash the address field for EMAIL and SMS subscriptions before writing to SharedPreferences. Push tokens are left unchanged. The in-memory model always retains the raw value. --- .../subscriptions/SubscriptionModelStore.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStore.kt index 2f8d0e235..56673f719 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStore.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStore.kt @@ -1,8 +1,10 @@ package com.onesignal.user.internal.subscriptions +import com.onesignal.common.PIIHasher import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.common.modeling.SimpleModelStore import com.onesignal.core.internal.preferences.IPreferencesService +import org.json.JSONObject open class SubscriptionModelStore(prefs: IPreferencesService) : SimpleModelStore({ SubscriptionModel() @@ -32,4 +34,17 @@ open class SubscriptionModelStore(prefs: IPreferencesService) : SimpleModelStore super.replaceAll(models, tag) } } + + override fun transformJsonForPersistence( + model: SubscriptionModel, + json: JSONObject, + ): JSONObject { + if (model.type == SubscriptionType.PUSH) return json + + val address = json.optString("address", "") + if (address.isNotEmpty() && !PIIHasher.isHashed(address)) { + json.put("address", PIIHasher.hash(address)) + } + return json + } } From 938f14dd1e773c0a0e64a7964b0339ef1da88185 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Tue, 14 Apr 2026 13:06:59 -0700 Subject: [PATCH 4/9] fix: hash-aware subscription removal/lookup and redact PII from logs SubscriptionManager.removeEmail/removeSms and SubscriptionList.getByEmail/ getBySMS now compare against both the raw address and its SHA-256 hash so lookups work whether the model is hydrated (raw) or loaded from disk (hashed). Debug log in addSubscriptionToModels now logs the hash instead of the raw address. --- .../internal/subscriptions/SubscriptionList.kt | 18 ++++++++++++++++-- .../subscriptions/impl/SubscriptionManager.kt | 15 ++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionList.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionList.kt index 7bfd08610..ab0d5488e 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionList.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionList.kt @@ -1,5 +1,7 @@ package com.onesignal.user.internal.subscriptions +import com.onesignal.common.PIIHasher +import com.onesignal.user.internal.Subscription import com.onesignal.user.subscriptions.IEmailSubscription import com.onesignal.user.subscriptions.IPushSubscription import com.onesignal.user.subscriptions.ISmsSubscription @@ -30,15 +32,27 @@ class SubscriptionList(val collection: List, private val _fallbac /** * Retrieve the Email subscription with the matching email, if there is one. + * Compares against the underlying model address (raw or hashed) so lookups + * work both before and after server hydration. */ fun getByEmail(email: String): IEmailSubscription? { - return emails.firstOrNull { it.email == email } + val hashed = PIIHasher.hash(email) + return emails.firstOrNull { + val address = (it as Subscription).model.address + address == email || address == hashed + } } /** * Retrieve the SMS subscription with the matching SMS number, if there is one. + * Compares against the underlying model address (raw or hashed) so lookups + * work both before and after server hydration. */ fun getBySMS(sms: String): ISmsSubscription? { - return smss.firstOrNull { it.number == sms } + val hashed = PIIHasher.hash(sms) + return smss.firstOrNull { + val address = (it as Subscription).model.address + address == sms || address == hashed + } } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/impl/SubscriptionManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/impl/SubscriptionManager.kt index 07834d9ba..a2384743b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/impl/SubscriptionManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/impl/SubscriptionManager.kt @@ -5,6 +5,7 @@ import com.onesignal.common.AndroidUtils import com.onesignal.common.DeviceUtils import com.onesignal.common.IDManager import com.onesignal.common.OneSignalUtils +import com.onesignal.common.PIIHasher import com.onesignal.common.events.EventProducer import com.onesignal.common.modeling.IModelStoreChangeHandler import com.onesignal.common.modeling.ModelChangedArgs @@ -93,7 +94,11 @@ internal class SubscriptionManager( } override fun removeEmailSubscription(email: String) { - val subscriptionToRem = subscriptions.emails.firstOrNull { it is EmailSubscription && it.email == email } + val hashedEmail = PIIHasher.hash(email) + val subscriptionToRem = + subscriptions.emails.firstOrNull { + it is EmailSubscription && (it.model.address == email || it.model.address == hashedEmail) + } if (subscriptionToRem != null) { removeSubscriptionFromModels(subscriptionToRem) @@ -101,7 +106,11 @@ internal class SubscriptionManager( } override fun removeSmsSubscription(sms: String) { - val subscriptionToRem = subscriptions.smss.firstOrNull { it is SmsSubscription && it.number == sms } + val hashedSms = PIIHasher.hash(sms) + val subscriptionToRem = + subscriptions.smss.firstOrNull { + it is SmsSubscription && (it.model.address == sms || it.model.address == hashedSms) + } if (subscriptionToRem != null) { removeSubscriptionFromModels(subscriptionToRem) @@ -113,7 +122,7 @@ internal class SubscriptionManager( address: String, status: SubscriptionStatus? = null, ) { - Logging.log(LogLevel.DEBUG, "SubscriptionManager.addSubscription(type: $type, address: $address)") + Logging.log(LogLevel.DEBUG, "SubscriptionManager.addSubscription(type: $type, address: ${PIIHasher.hash(address)})") val subscriptionModel = SubscriptionModel() subscriptionModel.id = IDManager.createLocalId() From e780b74694df92875e877ba19b08f6909e46effd Mon Sep 17 00:00:00 2001 From: sherwinski Date: Tue, 14 Apr 2026 13:07:11 -0700 Subject: [PATCH 5/9] fix: return empty string from email/sms getters when address is hashed Between cold start and server hydration, model.address contains a SHA-256 hash loaded from SharedPreferences. EmailSubscription.email and SmsSubscription.number now return "" in this window instead of exposing the opaque hash to app developers. Once RefreshUserOperationExecutor hydrates the model, the real value is restored. --- .../java/com/onesignal/user/internal/EmailSubscription.kt | 6 +++++- .../java/com/onesignal/user/internal/SmsSubscription.kt | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/EmailSubscription.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/EmailSubscription.kt index 1160db501..9cad21041 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/EmailSubscription.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/EmailSubscription.kt @@ -1,5 +1,6 @@ package com.onesignal.user.internal +import com.onesignal.common.PIIHasher import com.onesignal.user.internal.subscriptions.SubscriptionModel import com.onesignal.user.subscriptions.IEmailSubscription @@ -7,5 +8,8 @@ internal class EmailSubscription( model: SubscriptionModel, ) : Subscription(model), IEmailSubscription { override val email: String - get() = model.address + get() { + val address = model.address + return if (PIIHasher.isHashed(address)) "" else address + } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/SmsSubscription.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/SmsSubscription.kt index e40c9bc2a..29fe96290 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/SmsSubscription.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/SmsSubscription.kt @@ -1,5 +1,6 @@ package com.onesignal.user.internal +import com.onesignal.common.PIIHasher import com.onesignal.user.internal.subscriptions.SubscriptionModel import com.onesignal.user.subscriptions.ISmsSubscription @@ -7,5 +8,8 @@ internal class SmsSubscription( model: SubscriptionModel, ) : Subscription(model), ISmsSubscription { override val number: String - get() = model.address + get() { + val address = model.address + return if (PIIHasher.isHashed(address)) "" else address + } } From a91c17ee64bb14103dc21f7c9bf43993932e2c11 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Wed, 15 Apr 2026 10:07:01 -0700 Subject: [PATCH 6/9] fix: read subscription type from JSON to avoid NPE in transformJsonForPersistence The existing code accessed model.type directly, which throws a NullPointerException when the model's type property hasn't been set (e.g. in the ModelingTests deadlock test). Reading from the JSON object via optString is null-safe and consistent with the rest of the method. --- .../user/internal/subscriptions/SubscriptionModelStore.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStore.kt index 56673f719..089d2bc88 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStore.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStore.kt @@ -39,7 +39,8 @@ open class SubscriptionModelStore(prefs: IPreferencesService) : SimpleModelStore model: SubscriptionModel, json: JSONObject, ): JSONObject { - if (model.type == SubscriptionType.PUSH) return json + val type = json.optString("type", "") + if (type.isEmpty() || type == SubscriptionType.PUSH.toString()) return json val address = json.optString("address", "") if (address.isNotEmpty() && !PIIHasher.isHashed(address)) { From 6297d81170f454d003885444e406728949f4ff66 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Wed, 15 Apr 2026 14:30:33 -0700 Subject: [PATCH 7/9] chore(detekt): add KDoc to PIIHasher public functions --- .../core/src/main/java/com/onesignal/common/PIIHasher.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/PIIHasher.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/PIIHasher.kt index 35e90595b..31a6028fc 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/PIIHasher.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/PIIHasher.kt @@ -13,11 +13,13 @@ object PIIHasher { private const val SHA256_HEX_LENGTH = 64 private val SHA256_HEX_REGEX = Regex("^[a-f0-9]{$SHA256_HEX_LENGTH}$") + /** Returns the lowercase hex-encoded SHA-256 hash of [value]. */ fun hash(value: String): String { val digest = MessageDigest.getInstance("SHA-256") val bytes = digest.digest(value.toByteArray(Charsets.UTF_8)) return bytes.joinToString("") { "%02x".format(it) } } + /** Returns `true` if [value] looks like a 64-char lowercase hex SHA-256 digest. */ fun isHashed(value: String): Boolean = SHA256_HEX_REGEX.matches(value) } From 3dc48829d00f423220d815720377fd539f2f00a9 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Thu, 16 Apr 2026 15:00:36 -0700 Subject: [PATCH 8/9] fix: only hash email/SMS addresses in debug log, not push tokens --- .../user/internal/subscriptions/impl/SubscriptionManager.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/impl/SubscriptionManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/impl/SubscriptionManager.kt index a2384743b..73618a12f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/impl/SubscriptionManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/impl/SubscriptionManager.kt @@ -122,7 +122,8 @@ internal class SubscriptionManager( address: String, status: SubscriptionStatus? = null, ) { - Logging.log(LogLevel.DEBUG, "SubscriptionManager.addSubscription(type: $type, address: ${PIIHasher.hash(address)})") + val logAddress = if (type != SubscriptionType.PUSH) PIIHasher.hash(address) else address + Logging.log(LogLevel.DEBUG, "SubscriptionManager.addSubscription(type: $type, address: $logAddress)") val subscriptionModel = SubscriptionModel() subscriptionModel.id = IDManager.createLocalId() From 1e0a91383fdc43fe8ec55c2731ee917e3674c716 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Thu, 16 Apr 2026 16:11:26 -0700 Subject: [PATCH 9/9] test: add unit tests for PII hashing feature - PIIHasherTests: hash output format, determinism, known digest, isHashed detection for valid/invalid inputs - SubscriptionModelStoreTests: persist hashes email/SMS but not push, idempotent on already-hashed values, in-memory model stays raw - SubscriptionManagerTests: hash-aware removal for email/SMS when model.address is hashed (pre-hydration), public getter returns empty string for hashed addresses, getByEmail/getBySMS find subscriptions with hashed addresses --- .../com/onesignal/common/PIIHasherTests.kt | 60 ++++++ .../subscriptions/SubscriptionManagerTests.kt | 201 ++++++++++++++++++ .../SubscriptionModelStoreTests.kt | 124 +++++++++++ 3 files changed, 385 insertions(+) create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/PIIHasherTests.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStoreTests.kt diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/PIIHasherTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/PIIHasherTests.kt new file mode 100644 index 000000000..874c051d0 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/PIIHasherTests.kt @@ -0,0 +1,60 @@ +package com.onesignal.common + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldHaveLength +import io.kotest.matchers.string.shouldMatch + +class PIIHasherTests : FunSpec({ + + test("hash produces 64-char lowercase hex string") { + val result = PIIHasher.hash("test@example.com") + result shouldHaveLength 64 + result shouldMatch Regex("^[a-f0-9]{64}$") + } + + test("hash is deterministic") { + PIIHasher.hash("test@example.com") shouldBe PIIHasher.hash("test@example.com") + } + + test("hash produces different output for different input") { + val hash1 = PIIHasher.hash("user1@example.com") + val hash2 = PIIHasher.hash("user2@example.com") + (hash1 != hash2) shouldBe true + } + + test("hash matches known SHA-256 digest") { + // SHA-256 of "hello" is well-known + PIIHasher.hash("hello") shouldBe "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + } + + test("isHashed returns true for valid 64-char hex string") { + val hashed = PIIHasher.hash("test@example.com") + PIIHasher.isHashed(hashed) shouldBe true + } + + test("isHashed returns false for plain email") { + PIIHasher.isHashed("test@example.com") shouldBe false + } + + test("isHashed returns false for phone number") { + PIIHasher.isHashed("+15558675309") shouldBe false + } + + test("isHashed returns false for empty string") { + PIIHasher.isHashed("") shouldBe false + } + + test("isHashed returns false for uppercase hex") { + val upper = PIIHasher.hash("test").uppercase() + PIIHasher.isHashed(upper) shouldBe false + } + + test("isHashed returns false for 63-char hex string") { + PIIHasher.isHashed("a".repeat(63)) shouldBe false + } + + test("isHashed returns false for 65-char hex string") { + PIIHasher.isHashed("a".repeat(65)) shouldBe false + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionManagerTests.kt index f753d01db..68a272db6 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionManagerTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionManagerTests.kt @@ -1,6 +1,7 @@ package com.onesignal.user.internal.subscriptions import com.onesignal.common.IDManager.LOCAL_PREFIX +import com.onesignal.common.PIIHasher import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.common.modeling.ModelChangedArgs import com.onesignal.core.internal.application.IApplicationService @@ -416,4 +417,204 @@ class SubscriptionManagerTests : FunSpec({ ) } } + + test("remove email subscription matches hashed address (pre-hydration)") { + // Given + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + val emailSubscription = SubscriptionModel() + emailSubscription.id = "subscription1" + emailSubscription.type = SubscriptionType.EMAIL + emailSubscription.status = SubscriptionStatus.SUBSCRIBED + emailSubscription.optedIn = true + emailSubscription.address = PIIHasher.hash("name@company.com") + + val listOfSubscriptions = listOf(emailSubscription) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.add(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOfSubscriptions + every { mockSubscriptionModelStore.remove("subscription1") } just runs + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When — raw email is passed but model has hashed address + subscriptionManager.removeEmailSubscription("name@company.com") + + // Then + verify(exactly = 1) { mockSubscriptionModelStore.remove("subscription1") } + } + + test("remove sms subscription matches hashed address (pre-hydration)") { + // Given + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + val smsSubscription = SubscriptionModel() + smsSubscription.id = "subscription1" + smsSubscription.type = SubscriptionType.SMS + smsSubscription.status = SubscriptionStatus.SUBSCRIBED + smsSubscription.optedIn = true + smsSubscription.address = PIIHasher.hash("+18458675309") + + val listOfSubscriptions = listOf(smsSubscription) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.add(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOfSubscriptions + every { mockSubscriptionModelStore.remove("subscription1") } just runs + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When — raw phone is passed but model has hashed address + subscriptionManager.removeSmsSubscription("+18458675309") + + // Then + verify(exactly = 1) { mockSubscriptionModelStore.remove("subscription1") } + } + + test("email getter returns empty string when address is hashed") { + // Given + val emailSubscription = SubscriptionModel() + emailSubscription.id = "subscription1" + emailSubscription.type = SubscriptionType.EMAIL + emailSubscription.address = PIIHasher.hash("user@example.com") + + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOf(emailSubscription) + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When + val subscriptions = subscriptionManager.subscriptions + + // Then — public getter returns "" for hashed address + subscriptions.emails.count() shouldBe 1 + subscriptions.emails[0].email shouldBe "" + } + + test("email getter returns raw value when address is not hashed") { + // Given + val emailSubscription = SubscriptionModel() + emailSubscription.id = "subscription1" + emailSubscription.type = SubscriptionType.EMAIL + emailSubscription.address = "user@example.com" + + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOf(emailSubscription) + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When + val subscriptions = subscriptionManager.subscriptions + + // Then + subscriptions.emails[0].email shouldBe "user@example.com" + } + + test("sms getter returns empty string when address is hashed") { + // Given + val smsSubscription = SubscriptionModel() + smsSubscription.id = "subscription1" + smsSubscription.type = SubscriptionType.SMS + smsSubscription.address = PIIHasher.hash("+15558675309") + + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOf(smsSubscription) + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When + val subscriptions = subscriptionManager.subscriptions + + // Then — public getter returns "" for hashed address + subscriptions.smss.count() shouldBe 1 + subscriptions.smss[0].number shouldBe "" + } + + test("sms getter returns raw value when address is not hashed") { + // Given + val smsSubscription = SubscriptionModel() + smsSubscription.id = "subscription1" + smsSubscription.type = SubscriptionType.SMS + smsSubscription.address = "+15558675309" + + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOf(smsSubscription) + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When + val subscriptions = subscriptionManager.subscriptions + + // Then + subscriptions.smss[0].number shouldBe "+15558675309" + } + + test("getByEmail finds subscription with hashed address") { + // Given + val emailSubscription = SubscriptionModel() + emailSubscription.id = "subscription1" + emailSubscription.type = SubscriptionType.EMAIL + emailSubscription.address = PIIHasher.hash("user@example.com") + + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOf(emailSubscription) + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When + val result = subscriptionManager.subscriptions.getByEmail("user@example.com") + + // Then + result shouldNotBe null + result!!.id shouldBe "subscription1" + } + + test("getBySMS finds subscription with hashed address") { + // Given + val smsSubscription = SubscriptionModel() + smsSubscription.id = "subscription1" + smsSubscription.type = SubscriptionType.SMS + smsSubscription.address = PIIHasher.hash("+15558675309") + + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOf(smsSubscription) + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When + val result = subscriptionManager.subscriptions.getBySMS("+15558675309") + + // Then + result shouldNotBe null + result!!.id shouldBe "subscription1" + } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStoreTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStoreTests.kt new file mode 100644 index 000000000..2efb330ce --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStoreTests.kt @@ -0,0 +1,124 @@ +package com.onesignal.user.internal.subscriptions + +import com.onesignal.common.PIIHasher +import com.onesignal.core.internal.preferences.IPreferencesService +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.mocks.MockPreferencesService +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldMatch +import org.json.JSONArray + +class SubscriptionModelStoreTests : FunSpec({ + + fun getPersistedJson(prefs: IPreferencesService): JSONArray { + val raw = prefs.getString( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.MODEL_STORE_PREFIX + "subscriptions", + null, + ) + return JSONArray(raw!!) + } + + test("persist hashes email address in SharedPreferences") { + val prefs = MockPreferencesService() + val store = SubscriptionModelStore(prefs) + + val model = SubscriptionModel() + model.id = "email1" + model.type = SubscriptionType.EMAIL + model.address = "user@example.com" + store.add(model) + + val json = getPersistedJson(prefs) + val persisted = json.getJSONObject(0) + persisted.getString("address") shouldBe PIIHasher.hash("user@example.com") + } + + test("persist hashes SMS address in SharedPreferences") { + val prefs = MockPreferencesService() + val store = SubscriptionModelStore(prefs) + + val model = SubscriptionModel() + model.id = "sms1" + model.type = SubscriptionType.SMS + model.address = "+15558675309" + store.add(model) + + val json = getPersistedJson(prefs) + val persisted = json.getJSONObject(0) + persisted.getString("address") shouldBe PIIHasher.hash("+15558675309") + } + + test("persist does not hash push token in SharedPreferences") { + val prefs = MockPreferencesService() + val store = SubscriptionModelStore(prefs) + + val pushToken = "dz1A0qydQGCYM9dDgo6rB_:APA91bEqFakeToken" + val model = SubscriptionModel() + model.id = "push1" + model.type = SubscriptionType.PUSH + model.address = pushToken + store.add(model) + + val json = getPersistedJson(prefs) + val persisted = json.getJSONObject(0) + persisted.getString("address") shouldBe pushToken + } + + test("persist does not double-hash already-hashed email") { + val prefs = MockPreferencesService() + val store = SubscriptionModelStore(prefs) + + val alreadyHashed = PIIHasher.hash("user@example.com") + val model = SubscriptionModel() + model.id = "email1" + model.type = SubscriptionType.EMAIL + model.address = alreadyHashed + store.add(model) + + val json = getPersistedJson(prefs) + val persisted = json.getJSONObject(0) + persisted.getString("address") shouldBe alreadyHashed + } + + test("persist keeps in-memory model address as raw value") { + val prefs = MockPreferencesService() + val store = SubscriptionModelStore(prefs) + + val model = SubscriptionModel() + model.id = "email1" + model.type = SubscriptionType.EMAIL + model.address = "user@example.com" + store.add(model) + + model.address shouldBe "user@example.com" + } + + test("persist hashes email but not push when both are present") { + val prefs = MockPreferencesService() + val store = SubscriptionModelStore(prefs) + + val pushModel = SubscriptionModel() + pushModel.id = "push1" + pushModel.type = SubscriptionType.PUSH + pushModel.address = "fcm-token-abc123" + store.add(pushModel) + + val emailModel = SubscriptionModel() + emailModel.id = "email1" + emailModel.type = SubscriptionType.EMAIL + emailModel.address = "user@example.com" + store.add(emailModel) + + val json = getPersistedJson(prefs) + val models = (0 until json.length()).map { json.getJSONObject(it) } + val pushJson = models.first { it.getString("type") == SubscriptionType.PUSH.toString() } + val emailJson = models.first { it.getString("type") == SubscriptionType.EMAIL.toString() } + + pushJson.getString("address") shouldBe "fcm-token-abc123" + emailJson.getString("address") shouldBe PIIHasher.hash("user@example.com") + emailJson.getString("address") shouldMatch Regex("^[a-f0-9]{64}$") + } +})