Skip to content

Commit 972ca7a

Browse files
authored
Fire pixels for shared preferences retrieval failed (#7603)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1212227266948491/task/1213011287945042 ### Description ### Steps to test this PR _Feature 1_ - [ ] - [ ] ### UI changes | Before | After | | ------ | ----- | !(Upload before screenshot)|(Upload after screenshot)| <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches shared-preferences migration and encrypted key storage paths and changes error handling/return semantics, which could affect data access during migration. Also introduces an opt-in path to send sanitized stack traces in pixels, which has privacy and telemetry correctness implications if misconfigured. > > **Overview** > Adds **daily pixels** for failures when reading/writing encrypted secure storage in `RealSecureStorageKeyStore` and when migrating shared preferences (encrypted + unencrypted) to Harmony in `SharedPreferencesProviderImpl`, including per-step failure reasons. > > Introduces an opt-in `sendSanitizedStackTraces` toggle (for `autofill` and `databaseProvider`) and a new `Throwable.sanitizeStackTrace()` helper to redact common PII patterns before sending error details; these new pixels are registered with `PixelParamRemovalInterceptor` to strip `ATB`. Constructor/test wiring is updated to inject `Pixel`/feature dependencies and `data-store-impl` now depends on `:statistics-api` for pixel support. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 74af1df. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent ff41acc commit 972ca7a

File tree

12 files changed

+387
-79
lines changed

12 files changed

+387
-79
lines changed

app/src/androidTest/java/com/duckduckgo/app/data/store/impl/SharedPreferencesProviderImplTest.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import org.junit.Assert.assertEquals
3333
import org.junit.Before
3434
import org.junit.Rule
3535
import org.junit.Test
36+
import org.mockito.Mockito.mock
3637
import java.util.UUID
3738

3839
class SharedPreferencesProviderImplTest {
@@ -67,7 +68,10 @@ class SharedPreferencesProviderImplTest {
6768
preferencesProvider = SharedPreferencesProviderImpl(
6869
context,
6970
coroutineRule.testDispatcherProvider,
70-
) { crashLogger }
71+
{ mock() },
72+
{ mock() },
73+
{ crashLogger },
74+
)
7175
}
7276

7377
@After

app/src/androidTest/java/com/duckduckgo/app/statistics/user_segments/DuckAiRetentionIntegrationTest.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@ class DuckAiRetentionIntegrationTest {
105105
val sharedPreferencesProvider = SharedPreferencesProviderImpl(
106106
context,
107107
coroutineRule.testDispatcherProvider,
108-
) { crashLogger }
108+
{ mock() },
109+
{ mock() },
110+
{ crashLogger },
111+
)
109112

110113
usageHistory = SegmentStoreModule().provideSegmentStore(
111114
sharedPreferencesProvider,

app/src/main/java/com/duckduckgo/app/global/api/PixelParamRemovalInterceptor.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin
3131
import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin.PixelParameter
3232
import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin.PixelParameter.APP_VERSION
3333
import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin.PixelParameter.ATB
34+
import com.duckduckgo.data.store.impl.DataStorePixelNames
3435
import com.duckduckgo.di.scopes.AppScope
3536
import com.duckduckgo.newtabpage.impl.pixels.NewTabPixelNames
3637
import com.duckduckgo.remote.messaging.impl.pixels.RemoteMessagingPixelName
@@ -160,8 +161,23 @@ object PixelInterceptorPixelsRequiringDataCleaning : PixelParamRemovalPlugin {
160161
RemoteMessagingPixelName.REMOTE_MESSAGE_IMAGE_LOAD_SUCCESS.pixelName to PixelParameter.removeAtb(),
161162
RemoteMessagingPixelName.REMOTE_MESSAGE_IMAGE_LOAD_FAILED.pixelName to PixelParameter.removeAtb(),
162163
AutofillPixelNames.AUTOFILL_HARMONY_PREFERENCES_RETRIEVAL_FAILED.pixelName to PixelParameter.removeAtb(),
164+
AutofillPixelNames.AUTOFILL_PREFERENCES_RETRIEVAL_FAILED.pixelName to PixelParameter.removeAtb(),
165+
AutofillPixelNames.AUTOFILL_PREFERENCES_GET_KEY_FAILED.pixelName to PixelParameter.removeAtb(),
166+
AutofillPixelNames.AUTOFILL_HARMONY_PREFERENCES_GET_KEY_FAILED.pixelName to PixelParameter.removeAtb(),
167+
AutofillPixelNames.AUTOFILL_PREFERENCES_UPDATE_KEY_FAILED.pixelName to PixelParameter.removeAtb(),
168+
AutofillPixelNames.AUTOFILL_HARMONY_PREFERENCES_UPDATE_KEY_FAILED.pixelName to PixelParameter.removeAtb(),
163169
AppPixelName.APP_INSTALL_VERIFIED_INSTALL.pixelName to PixelParameter.removeAtb(),
164170
AppPixelName.APP_UPDATE_VERIFIED_INSTALL.pixelName to PixelParameter.removeAtb(),
171+
DataStorePixelNames.DATA_STORE_MIGRATE_ENCRYPTED_GET_PREFERENCES_ORIGIN_FAILED.pixelName to PixelParameter.removeAtb(),
172+
DataStorePixelNames.DATA_STORE_MIGRATE_UNENCRYPTED_GET_PREFERENCES_ORIGIN_FAILED.pixelName to PixelParameter.removeAtb(),
173+
DataStorePixelNames.DATA_STORE_MIGRATE_ENCRYPTED_GET_PREFERENCES_DESTINATION_FAILED.pixelName to PixelParameter.removeAtb(),
174+
DataStorePixelNames.DATA_STORE_MIGRATE_UNENCRYPTED_GET_PREFERENCES_DESTINATION_FAILED.pixelName to PixelParameter.removeAtb(),
175+
DataStorePixelNames.DATA_STORE_MIGRATE_ENCRYPTED_QUERY_PREFERENCES_DESTINATION_FAILED.pixelName to PixelParameter.removeAtb(),
176+
DataStorePixelNames.DATA_STORE_MIGRATE_UNENCRYPTED_QUERY_PREFERENCES_DESTINATION_FAILED.pixelName to PixelParameter.removeAtb(),
177+
DataStorePixelNames.DATA_STORE_MIGRATE_ENCRYPTED_QUERY_ALL_PREFERENCES_ORIGIN_FAILED.pixelName to PixelParameter.removeAtb(),
178+
DataStorePixelNames.DATA_STORE_MIGRATE_UNENCRYPTED_QUERY_ALL_PREFERENCES_ORIGIN_FAILED.pixelName to PixelParameter.removeAtb(),
179+
DataStorePixelNames.DATA_STORE_MIGRATE_ENCRYPTED_UPDATE_PREFERENCES_DESTINATION_FAILED.pixelName to PixelParameter.removeAtb(),
180+
DataStorePixelNames.DATA_STORE_MIGRATE_UNENCRYPTED_UPDATE_PREFERENCES_DESTINATION_FAILED.pixelName to PixelParameter.removeAtb(),
165181
)
166182
}
167183
}

autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,7 @@ interface AutofillFeature {
179179

180180
@Toggle.DefaultValue(defaultValue = DefaultFeatureValue.FALSE)
181181
fun useHarmony(): Toggle
182+
183+
@Toggle.DefaultValue(defaultValue = DefaultFeatureValue.FALSE)
184+
fun sendSanitizedStackTraces(): Toggle
182185
}

autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/pixel/AutofillPixelNames.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,11 @@ enum class AutofillPixelNames(override val pixelName: String) : Pixel.PixelName
227227
PRODUCT_TELEMETRY_SURFACE_PASSWORDS_OPENED("m_product_telemetry_surface_usage_passwords_page"),
228228
PRODUCT_TELEMETRY_SURFACE_PASSWORDS_OPENED_DAILY("m_product_telemetry_surface_usage_passwords_page_daily"),
229229
AUTOFILL_HARMONY_PREFERENCES_RETRIEVAL_FAILED("autofill_harmony_preferences_retrieval_failed"),
230+
AUTOFILL_PREFERENCES_RETRIEVAL_FAILED("autofill_preferences_retrieval_failed"),
231+
AUTOFILL_PREFERENCES_GET_KEY_FAILED("autofill_preferences_get_key_failed"),
232+
AUTOFILL_HARMONY_PREFERENCES_GET_KEY_FAILED("autofill_harmony_preferences_get_key_failed"),
233+
AUTOFILL_PREFERENCES_UPDATE_KEY_FAILED("autofill_preferences_update_key_failed"),
234+
AUTOFILL_HARMONY_PREFERENCES_UPDATE_KEY_FAILED("autofill_harmony_preferences_update_key_failed"),
230235
}
231236

232237
object AutofillPixelParameters {

autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/store/keys/SecureStorageKeyStore.kt

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,21 @@ import androidx.core.content.edit
2222
import androidx.security.crypto.EncryptedSharedPreferences
2323
import androidx.security.crypto.MasterKey
2424
import com.duckduckgo.app.statistics.pixels.Pixel
25+
import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily
2526
import com.duckduckgo.autofill.api.AutofillFeature
27+
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames
28+
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_HARMONY_PREFERENCES_GET_KEY_FAILED
2629
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_HARMONY_PREFERENCES_RETRIEVAL_FAILED
30+
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_HARMONY_PREFERENCES_UPDATE_KEY_FAILED
31+
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_PREFERENCES_GET_KEY_FAILED
32+
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_PREFERENCES_UPDATE_KEY_FAILED
2733
import com.duckduckgo.common.utils.DispatcherProvider
34+
import com.duckduckgo.common.utils.sanitizeStackTrace
2835
import com.duckduckgo.data.store.api.SharedPreferencesProvider
2936
import kotlinx.coroutines.CoroutineScope
3037
import kotlinx.coroutines.Deferred
3138
import kotlinx.coroutines.async
39+
import kotlinx.coroutines.ensureActive
3240
import kotlinx.coroutines.sync.Mutex
3341
import kotlinx.coroutines.sync.withLock
3442
import kotlinx.coroutines.withContext
@@ -82,6 +90,12 @@ class RealSecureStorageKeyStore constructor(
8290
)
8391
}
8492
} catch (e: Exception) {
93+
coroutineContext.ensureActive()
94+
pixel.fire(
95+
AutofillPixelNames.AUTOFILL_PREFERENCES_RETRIEVAL_FAILED,
96+
mapOf("error" to e.error()),
97+
type = Daily(),
98+
)
8599
null
86100
}
87101
}
@@ -94,7 +108,6 @@ class RealSecureStorageKeyStore constructor(
94108
if (autofillFeature.useHarmony().isEnabled()) {
95109
sharedPreferencesProvider.getMigratedEncryptedSharedPreferences(FILENAME).also {
96110
if (it == null) {
97-
pixel.fire(AUTOFILL_HARMONY_PREFERENCES_RETRIEVAL_FAILED)
98111
logcat { "autofill harmony preferences retrieval returned null" }
99112
}
100113
}
@@ -103,7 +116,12 @@ class RealSecureStorageKeyStore constructor(
103116
}
104117
}
105118
} catch (e: Exception) {
106-
pixel.fire(AUTOFILL_HARMONY_PREFERENCES_RETRIEVAL_FAILED)
119+
coroutineContext.ensureActive()
120+
pixel.fire(
121+
AUTOFILL_HARMONY_PREFERENCES_RETRIEVAL_FAILED,
122+
mapOf("error" to e.error()),
123+
type = Daily(),
124+
)
107125
logcat { "autofill harmony preferences retrieval failed: $e" }
108126
null
109127
}
@@ -123,41 +141,89 @@ class RealSecureStorageKeyStore constructor(
123141
keyValue: ByteArray?,
124142
) {
125143
withContext(dispatcherProvider.io()) {
126-
getEncryptedPreferences()?.edit(commit = true) {
127-
if (keyValue == null) {
128-
remove(keyName)
129-
} else {
130-
putString(keyName, keyValue.toByteString().base64())
131-
}
132-
}
133-
134-
if (autofillFeature.useHarmony().isEnabled()) {
135-
getHarmonyEncryptedPreferences()?.edit(commit = true) {
144+
runCatching {
145+
getEncryptedPreferences()?.edit(commit = true) {
136146
if (keyValue == null) {
137147
remove(keyName)
138148
} else {
139149
putString(keyName, keyValue.toByteString().base64())
140150
}
141151
}
152+
}.getOrElse {
153+
ensureActive()
154+
pixel.fire(
155+
AUTOFILL_PREFERENCES_UPDATE_KEY_FAILED,
156+
mapOf("error" to it.error()),
157+
type = Daily(),
158+
)
159+
throw it
160+
}
161+
162+
if (autofillFeature.useHarmony().isEnabled()) {
163+
runCatching {
164+
getHarmonyEncryptedPreferences()?.edit(commit = true) {
165+
if (keyValue == null) {
166+
remove(keyName)
167+
} else {
168+
putString(keyName, keyValue.toByteString().base64())
169+
}
170+
}
171+
}.getOrElse {
172+
ensureActive()
173+
pixel.fire(
174+
AUTOFILL_HARMONY_PREFERENCES_UPDATE_KEY_FAILED,
175+
mapOf("error" to it.error()),
176+
type = Daily(),
177+
)
178+
throw it
179+
}
142180
}
143181
}
144182
}
145183

146184
override suspend fun getKey(keyName: String): ByteArray? {
147185
return withContext(dispatcherProvider.io()) {
148-
val preferences = if (autofillFeature.useHarmony().isEnabled()) {
186+
val useHarmony = autofillFeature.useHarmony().isEnabled()
187+
val preferences = if (useHarmony) {
149188
getHarmonyEncryptedPreferences()
150189
} else {
151190
getEncryptedPreferences()
152191
}
153-
return@withContext preferences?.getString(keyName, null)?.run {
154-
this.decodeBase64()?.toByteArray()
192+
return@withContext runCatching {
193+
preferences?.getString(keyName, null)?.run {
194+
this.decodeBase64()?.toByteArray()
195+
}
196+
}.getOrElse {
197+
ensureActive()
198+
val pixelName = if (useHarmony) {
199+
AUTOFILL_HARMONY_PREFERENCES_GET_KEY_FAILED
200+
} else {
201+
AUTOFILL_PREFERENCES_GET_KEY_FAILED
202+
}
203+
pixel.fire(
204+
pixelName,
205+
mapOf("error" to it.error()),
206+
type = Daily(),
207+
)
208+
throw it
155209
}
156210
}
157211
}
158212

159213
override suspend fun canUseEncryption(): Boolean = withContext(dispatcherProvider.io()) {
160-
getEncryptedPreferences() != null
214+
if (autofillFeature.useHarmony().isEnabled()) {
215+
getHarmonyEncryptedPreferences() != null
216+
} else {
217+
getEncryptedPreferences() != null
218+
}
219+
}
220+
221+
private fun Throwable.error(): String {
222+
return if (autofillFeature.sendSanitizedStackTraces().isEnabled()) {
223+
sanitizeStackTrace()
224+
} else {
225+
javaClass.name
226+
}
161227
}
162228

163229
companion object {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.common.utils
18+
19+
import logcat.asLog
20+
21+
fun Throwable.sanitizeStackTrace(): String {
22+
// if we fail for whatever reason, we don't include the stack trace
23+
return runCatching {
24+
val emailRegex = Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}")
25+
val phoneRegex = Regex("\\b(?:\\d[\\s()-]?){6,14}\\b") // This regex matches common phone number formats
26+
val phoneRegex2 = Regex("\\b\\+?\\d[- (]*\\d{3}[- )]*\\d{3}[- ]*\\d{4}\\b") // enhanced to redact also other phone number formats
27+
val urlRegex = Regex("\\b(?:https?://|www\\.)\\S+\\b")
28+
val ipv4Regex = Regex("\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b")
29+
30+
var sanitizedStackTrace = this.asLog()
31+
sanitizedStackTrace = sanitizedStackTrace.replace(urlRegex, "[REDACTED_URL]")
32+
sanitizedStackTrace = sanitizedStackTrace.replace(emailRegex, "[REDACTED_EMAIL]")
33+
sanitizedStackTrace = sanitizedStackTrace.replace(phoneRegex2, "[REDACTED_PHONE]")
34+
sanitizedStackTrace = sanitizedStackTrace.replace(phoneRegex, "[REDACTED_PHONE]")
35+
sanitizedStackTrace = sanitizedStackTrace.replace(ipv4Regex, "[REDACTED_IPV4]")
36+
37+
sanitizedStackTrace
38+
}.getOrDefault(this.javaClass.name)
39+
}

data-store/data-store-impl/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ dependencies {
3232
implementation project(':common-utils')
3333
implementation project(':di')
3434
implementation project(':feature-toggles-api')
35+
implementation project(':statistics-api')
3536
ksp AndroidX.room.compiler
3637

3738
implementation KotlinX.coroutines.android
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.data.store.impl
18+
19+
import com.duckduckgo.app.statistics.pixels.Pixel
20+
21+
enum class DataStorePixelNames(override val pixelName: String) : Pixel.PixelName {
22+
DATA_STORE_MIGRATE_ENCRYPTED_GET_PREFERENCES_ORIGIN_FAILED("data-store_migrate_encrypted_get-preferences_origin_failed"),
23+
DATA_STORE_MIGRATE_UNENCRYPTED_GET_PREFERENCES_ORIGIN_FAILED("data-store_migrate_unencrypted_get-preferences_origin_failed"),
24+
DATA_STORE_MIGRATE_ENCRYPTED_GET_PREFERENCES_DESTINATION_FAILED("data-store_migrate_encrypted_get-preferences_destination_failed"),
25+
DATA_STORE_MIGRATE_UNENCRYPTED_GET_PREFERENCES_DESTINATION_FAILED("data-store_migrate_unencrypted_get-preferences_destination_failed"),
26+
DATA_STORE_MIGRATE_ENCRYPTED_QUERY_PREFERENCES_DESTINATION_FAILED("data-store_migrate_encrypted_query-preferences_destination_failed"),
27+
DATA_STORE_MIGRATE_UNENCRYPTED_QUERY_PREFERENCES_DESTINATION_FAILED("data-store_migrate_unencrypted_query-preferences_destination_failed"),
28+
DATA_STORE_MIGRATE_ENCRYPTED_QUERY_ALL_PREFERENCES_ORIGIN_FAILED("data-store_migrate_encrypted_query-all-preferences_origin_failed"),
29+
DATA_STORE_MIGRATE_UNENCRYPTED_QUERY_ALL_PREFERENCES_ORIGIN_FAILED("data-store_migrate_unencrypted_query-all-preferences_origin_failed"),
30+
DATA_STORE_MIGRATE_ENCRYPTED_UPDATE_PREFERENCES_DESTINATION_FAILED("data-store_migrate_encrypted_update-preferences_destination_failed"),
31+
DATA_STORE_MIGRATE_UNENCRYPTED_UPDATE_PREFERENCES_DESTINATION_FAILED("data-store_migrate_unencrypted_update-preferences_destination_failed"),
32+
}

data-store/data-store-impl/src/main/java/com/duckduckgo/data/store/impl/DatabaseProviderFeature.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,7 @@ interface DatabaseProviderFeature {
2929

3030
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
3131
fun self(): Toggle
32+
33+
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
34+
fun sendSanitizedStackTraces(): Toggle
3235
}

0 commit comments

Comments
 (0)