Skip to content

Commit f21fd62

Browse files
adalpariclaude
andauthored
CMM-1998: Add application password prompt and creation analytics events (#22750)
* Add application password analytics events for migration, creation, and reauth flows Add three new analytics events to match iOS parity: - application_password_migration_prompted: tracked when the migration prompt is shown, with source property - application_password_created: tracked on credential storage success/failure, with source, success, and error properties - application_password_reauth_prompted: tracked when the re-authentication prompt is shown Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Extract creation source tracking into ApplicationPasswordCreationTracker singleton Move the pending creation source state from ApplicationPasswordLoginActivity companion object into a dedicated singleton. This bridges the source value across the Chrome Custom Tab flow and supports distinct sources: login, auto_migration, migration, and reauth. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix false application_password_storing_failed on new site login Replace Boolean return from storeApplicationPasswordCredentialsFrom() with a sealed class (Success, SiteNotFound, BadData) so callers can distinguish the expected "site not found locally" case from genuine failures. SiteNotFound no longer fires storing_failed tracking or Sentry reports since it is the normal path for new sites that need to be fetched first. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix double application_password_created tracking on reauth The updateApplicationPassword dispatch triggers onSiteChanged via EventBus, causing a second tracking event. Pass creationSource to the helper so it tracks on the Success path, and add a credentialsAlreadyStored guard so onSiteChanged is ignored when credentials were already stored directly (reauth flow). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix double tracking of prompted events on activity recreation Guard analytics tracking in onCreate with savedInstanceState == null so events only fire on the initial creation, not on configuration changes or activity recreation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix race condition causing double application_password_created on reauth Replace credentialsAlreadyStored flag (set after coroutine returns, too late) with a volatile waitingForFetchedSite flag that is only set in the SiteNotFound path. This ensures onSiteChanged events from updateApplicationPassword are ignored — they only matter when we dispatched fetchSites for a new site. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Emit navigation event on successful credential storage for existing sites The Success branch was missing a navigation emit after blocking onSiteChanged, causing the reauth flow to hang indefinitely. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use migration source when auto-creation falls back to manual webview When auto-creation fails and the user completes the flow via the webview, the source should be "migration" (manual) not "auto_migration", since the password was created manually. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add tests for existing site credential storage and onSiteChanged guard - Test that Success emits navigation without site selector - Test that onSiteChanged is ignored after direct credential storage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add @volatile to CreationTracker and propagate creationSource in exception handler - Mark pendingCreationSource as @volatile for thread safety - Pass creationSource to trackStoringFailed in the storeCredentials exception handler so the created-failure event fires on that path Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix review issues: feature_name key, atomic tracker, and source constants - Fix migration_prompted event property key from "source" to "feature_name" to match iOS schema - Replace @volatile with AtomicReference in CreationTracker for atomic consume operation - Extract creation source magic strings into constants on ApplicationPasswordCreationTracker Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Extract observeNavigationEvents to fix LongMethod detekt warning Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove empty line before closing brace in ApplicationPasswordLoginActivity --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 32f7759 commit f21fd62

12 files changed

Lines changed: 390 additions & 133 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package org.wordpress.android.ui.accounts.applicationpassword
2+
3+
import java.util.concurrent.atomic.AtomicReference
4+
5+
/**
6+
* Holds the creation source for the application password flow.
7+
* Since the web authorization goes through Chrome Custom Tabs,
8+
* we cannot pass extras through the intent. This singleton
9+
* bridges the source from the caller to the ViewModel that
10+
* handles the callback.
11+
*/
12+
object ApplicationPasswordCreationTracker {
13+
const val SOURCE_LOGIN = "login"
14+
const val SOURCE_AUTO_MIGRATION = "auto_migration"
15+
const val SOURCE_MIGRATION = "migration"
16+
const val SOURCE_REAUTH = "reauth"
17+
18+
private const val DEFAULT_SOURCE = SOURCE_LOGIN
19+
20+
private val pendingCreationSource =
21+
AtomicReference(DEFAULT_SOURCE)
22+
23+
fun setPendingCreationSource(source: String) {
24+
pendingCreationSource.set(source)
25+
}
26+
27+
fun consumePendingCreationSource(): String =
28+
pendingCreationSource.getAndSet(DEFAULT_SOURCE)
29+
}

WordPress/src/main/java/org/wordpress/android/ui/accounts/applicationpassword/ApplicationPasswordLoginViewModel.kt

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import kotlinx.coroutines.launch
99
import kotlinx.coroutines.withContext
1010
import org.greenrobot.eventbus.Subscribe
1111
import org.greenrobot.eventbus.ThreadMode
12+
import org.wordpress.android.analytics.AnalyticsTracker
13+
import org.wordpress.android.analytics.AnalyticsTracker.Stat
1214
import org.wordpress.android.fluxc.Dispatcher
1315
import org.wordpress.android.fluxc.generated.SiteActionBuilder
1416
import org.wordpress.android.fluxc.model.SiteModel
@@ -19,6 +21,7 @@ import org.wordpress.android.fluxc.utils.AppLogWrapper
1921
import org.wordpress.android.util.SiteUtils
2022
import org.wordpress.android.modules.IO_THREAD
2123
import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper
24+
import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper.StoreCredentialsResult
2225
import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper.UriLogin
2326
import org.wordpress.android.util.AppLog
2427
import org.wordpress.android.util.UrlUtils
@@ -44,8 +47,12 @@ class ApplicationPasswordLoginViewModel @Inject constructor(
4447
*/
4548
val onFinishedEvent = _onFinishedEvent.asSharedFlow()
4649

50+
private val creationSource =
51+
ApplicationPasswordCreationTracker.consumePendingCreationSource()
4752
private var currentUrlLogin: UriLogin? = null
4853
private var oldSitesIDs: ArrayList<Int>? = null
54+
@Volatile
55+
private var waitingForFetchedSite = false
4956

5057
fun onStart() {
5158
dispatcher.register(this)
@@ -68,40 +75,68 @@ class ApplicationPasswordLoginViewModel @Inject constructor(
6875
AppLog.T.MAIN,
6976
"A_P: Cannot store credentials: rawData is empty"
7077
)
71-
applicationPasswordLoginHelper.trackStoringFailed("", "empty_raw_data")
78+
applicationPasswordLoginHelper.trackStoringFailed(
79+
"", "empty_raw_data", creationSource
80+
)
7281
emitError(siteUrl = "", errorMessage = "empty_raw_data")
7382
return@launch
7483
}
7584
val urlLogin = applicationPasswordLoginHelper.getSiteUrlLoginFromRawData(rawData)
7685
currentUrlLogin = urlLogin
7786
// Store credentials if the site already exists
78-
val credentialsStored = storeCredentials(urlLogin)
79-
// If the site already exists, we can skip fetching it again
80-
if (!credentialsStored) {
81-
fetchSites(
82-
urlLogin.user.orEmpty(),
83-
urlLogin.password.orEmpty(),
84-
urlLogin.siteUrl.orEmpty(),
85-
urlLogin.apiRootUrl.orEmpty()
86-
)
87+
when (storeCredentials(urlLogin)) {
88+
is StoreCredentialsResult.Success -> {
89+
_onFinishedEvent.emit(
90+
NavigationActionData(
91+
showSiteSelector = false,
92+
siteUrl = urlLogin.siteUrl,
93+
oldSitesIDs = oldSitesIDs,
94+
isError = false,
95+
)
96+
)
97+
}
98+
is StoreCredentialsResult.SiteNotFound -> {
99+
waitingForFetchedSite = true
100+
fetchSites(
101+
urlLogin.user.orEmpty(),
102+
urlLogin.password.orEmpty(),
103+
urlLogin.siteUrl.orEmpty(),
104+
urlLogin.apiRootUrl.orEmpty()
105+
)
106+
}
107+
is StoreCredentialsResult.BadData -> {
108+
// Already tracked inside the helper
109+
emitError(
110+
siteUrl = urlLogin.siteUrl.orEmpty(),
111+
errorMessage = "bad_data"
112+
)
113+
}
87114
}
88115
}
89116
}
90117

91118
@Suppress("TooGenericExceptionCaught")
92-
private suspend fun storeCredentials(urlLogin: UriLogin): Boolean = withContext(ioDispatcher) {
119+
private suspend fun storeCredentials(
120+
urlLogin: UriLogin
121+
): StoreCredentialsResult = withContext(ioDispatcher) {
93122
try {
94-
applicationPasswordLoginHelper.storeApplicationPasswordCredentialsFrom(urlLogin)
123+
applicationPasswordLoginHelper
124+
.storeApplicationPasswordCredentialsFrom(
125+
urlLogin, creationSource
126+
)
95127
} catch (e: Exception) {
96128
appLogWrapper.e(
97129
AppLog.T.DB,
98-
"A_P: Error storing credentials: ${e.stackTraceToString()}"
130+
"A_P: Error storing credentials:" +
131+
" ${e.stackTraceToString()}"
99132
)
100133
applicationPasswordLoginHelper.trackStoringFailed(
101-
urlLogin.siteUrl, "store_credentials_exception"
134+
urlLogin.siteUrl,
135+
"store_credentials_exception",
136+
creationSource
102137
)
103138
crashLogging.sendReportWithTag(e, AppLog.T.DB)
104-
false
139+
StoreCredentialsResult.BadData
105140
}
106141
}
107142

@@ -123,7 +158,7 @@ class ApplicationPasswordLoginViewModel @Inject constructor(
123158
", apiRootUrl isEmpty=${apiRootUrl.isEmpty()}"
124159
)
125160
applicationPasswordLoginHelper.trackStoringFailed(
126-
siteUrl, "empty_fetch_params"
161+
siteUrl, "empty_fetch_params", creationSource
127162
)
128163
emitError(
129164
siteUrl = siteUrl,
@@ -149,7 +184,7 @@ class ApplicationPasswordLoginViewModel @Inject constructor(
149184
"A_P: Error fetching sites: ${e.stackTraceToString()}"
150185
)
151186
applicationPasswordLoginHelper.trackStoringFailed(
152-
siteUrl, "fetch_sites_exception"
187+
siteUrl, "fetch_sites_exception", creationSource
153188
)
154189
emitError(siteUrl = siteUrl, errorMessage = e.message, cause = e)
155190
}
@@ -177,6 +212,7 @@ class ApplicationPasswordLoginViewModel @Inject constructor(
177212
@SuppressWarnings("unused")
178213
@Subscribe(threadMode = ThreadMode.BACKGROUND)
179214
fun onSiteChanged(event: OnSiteChanged) {
215+
if (!waitingForFetchedSite) return
180216
viewModelScope.launch {
181217
if (event.isError) {
182218
handleSiteChangedError(event)
@@ -195,7 +231,8 @@ class ApplicationPasswordLoginViewModel @Inject constructor(
195231
)
196232
applicationPasswordLoginHelper.trackStoringFailed(
197233
currentUrlLogin?.siteUrl,
198-
"site_changed_failed"
234+
"site_changed_failed",
235+
creationSource
199236
)
200237
emitError(
201238
siteUrl = currentUrlLogin?.siteUrl.orEmpty(),
@@ -230,6 +267,10 @@ class ApplicationPasswordLoginViewModel @Inject constructor(
230267
)
231268
} else {
232269
val resolvedSite = site ?: return
270+
AnalyticsTracker.track(
271+
Stat.APPLICATION_PASSWORD_CREATED,
272+
mapOf("source" to creationSource, "success" to "true")
273+
)
233274
_onFinishedEvent.emit(
234275
NavigationActionData(
235276
showSiteSelector = siteStore.hasSite() &&
@@ -280,7 +321,8 @@ class ApplicationPasswordLoginViewModel @Inject constructor(
280321
)
281322
applicationPasswordLoginHelper.trackStoringFailed(
282323
currentUrlLogin?.siteUrl,
283-
"site_changed_failed"
324+
"site_changed_failed",
325+
creationSource
284326
)
285327
emitError(
286328
siteUrl = currentUrlLogin?.siteUrl.orEmpty(),

WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import javax.inject.Named
3131
private const val URL_TAG = "url"
3232
private const val SUCCESS_TAG = "success"
3333
private const val REASON_TAG = "reason"
34+
private const val SOURCE_TAG = "source"
35+
private const val ERROR_TAG = "error"
3436

3537
class ApplicationPasswordLoginHelper @Inject constructor(
3638
@param:Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher,
@@ -92,18 +94,25 @@ class ApplicationPasswordLoginHelper @Inject constructor(
9294
return ""
9395
}
9496

97+
sealed class StoreCredentialsResult {
98+
object Success : StoreCredentialsResult()
99+
object SiteNotFound : StoreCredentialsResult()
100+
object BadData : StoreCredentialsResult()
101+
}
102+
95103
@Suppress("ComplexCondition")
96104
suspend fun storeApplicationPasswordCredentialsFrom(
97-
urlLogin: UriLogin
98-
): Boolean {
105+
urlLogin: UriLogin,
106+
creationSource: String = ""
107+
): StoreCredentialsResult {
99108
if (urlLogin.apiRootUrl == null ||
100109
urlLogin.user.isNullOrEmpty() ||
101110
urlLogin.password.isNullOrEmpty() ||
102111
urlLogin.siteUrl == null ||
103112
urlLogin.siteUrl == processedAppPasswordData
104113
) {
105-
logAndReportBadData(urlLogin)
106-
return false
114+
logAndReportBadData(urlLogin, creationSource)
115+
return StoreCredentialsResult.BadData
107116
}
108117

109118
return withContext(bgDispatcher) {
@@ -123,25 +132,48 @@ class ApplicationPasswordLoginHelper @Inject constructor(
123132
wpApiClientProvider.clearSelfHostedClient(site.id)
124133
dispatcherWrapper.updateApplicationPassword(site)
125134
trackSuccessful(urlLogin.siteUrl)
126-
processedAppPasswordData = urlLogin.siteUrl // Save locally to avoid duplicated calls
127-
true
135+
trackCreated(creationSource, success = true)
136+
processedAppPasswordData = urlLogin.siteUrl
137+
StoreCredentialsResult.Success
128138
} else {
129-
logAndReportSiteNotFound(
130-
urlLogin.siteUrl, normalizedUrl, sites
131-
)
132-
false
139+
logSiteNotFound(urlLogin.siteUrl, normalizedUrl, sites)
140+
StoreCredentialsResult.SiteNotFound
133141
}
134142
}
135143
}
136144

137-
fun trackStoringFailed(siteUrl: String?, reason: String) {
145+
fun trackStoringFailed(
146+
siteUrl: String?,
147+
reason: String,
148+
creationSource: String = ""
149+
) {
138150
val properties: MutableMap<String, String?> = HashMap()
139151
properties[URL_TAG] = maskUrl(siteUrl.orEmpty())
140152
properties[REASON_TAG] = reason
141153
AnalyticsTracker.track(
142154
Stat.APPLICATION_PASSWORD_STORING_FAILED,
143155
properties
144156
)
157+
trackCreated(creationSource, success = false, error = reason)
158+
}
159+
160+
private fun trackCreated(
161+
creationSource: String,
162+
success: Boolean,
163+
error: String? = null
164+
) {
165+
if (creationSource.isEmpty()) return
166+
val properties = mutableMapOf<String, String>(
167+
SOURCE_TAG to creationSource,
168+
SUCCESS_TAG to success.toString()
169+
)
170+
if (!success && !error.isNullOrEmpty()) {
171+
properties[ERROR_TAG] = error
172+
}
173+
AnalyticsTracker.track(
174+
Stat.APPLICATION_PASSWORD_CREATED,
175+
properties
176+
)
145177
}
146178

147179
private fun reportStoringFailedToSentry(
@@ -154,7 +186,10 @@ class ApplicationPasswordLoginHelper @Inject constructor(
154186
)
155187
}
156188

157-
private fun logAndReportBadData(urlLogin: UriLogin) {
189+
private fun logAndReportBadData(
190+
urlLogin: UriLogin,
191+
creationSource: String
192+
) {
158193
val detail =
159194
"apiRootUrl isNull=${urlLogin.apiRootUrl == null}" +
160195
", user isEmpty=${urlLogin.user.isNullOrEmpty()}" +
@@ -168,29 +203,23 @@ class ApplicationPasswordLoginHelper @Inject constructor(
168203
"A_P: Cannot save credentials" +
169204
" for: ${urlLogin.siteUrl} - $detail"
170205
)
171-
trackStoringFailed(urlLogin.siteUrl, "bad_data")
206+
trackStoringFailed(urlLogin.siteUrl, "bad_data", creationSource)
172207
reportStoringFailedToSentry("bad_data", detail)
173208
}
174209

175-
private fun logAndReportSiteNotFound(
210+
private fun logSiteNotFound(
176211
siteUrl: String?,
177212
normalizedUrl: String?,
178213
sites: List<SiteModel>
179214
) {
180215
val availableSiteUrls = sites.joinToString { it.url }
181216
val logDetail = "$siteUrl (normalized: $normalizedUrl)" +
182217
"${sites.size} sites available: [$availableSiteUrls]"
183-
appLogWrapper.e(
218+
appLogWrapper.d(
184219
AppLog.T.DB,
185-
"A_P: Cannot save credentials" +
186-
" - site not found: $logDetail"
220+
"A_P: Site not found locally, will fetch:" +
221+
" $logDetail"
187222
)
188-
trackStoringFailed(siteUrl, "site_not_found")
189-
val maskedSiteUrls = sites.joinToString { maskUrl(it.url) }
190-
val sentryDetail =
191-
"${maskUrl(siteUrl.orEmpty())} (normalized: ${maskUrl(normalizedUrl.orEmpty())})" +
192-
"${sites.size} sites available: [$maskedSiteUrls]"
193-
reportStoringFailedToSentry("site_not_found", sentryDetail)
194223
}
195224

196225
private fun trackSuccessful(siteUrl: String) {

WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordAutoAuthDialogActivity.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import dagger.hilt.android.AndroidEntryPoint
3838
import kotlinx.coroutines.launch
3939
import org.wordpress.android.R
4040
import org.wordpress.android.fluxc.model.SiteModel
41+
import org.wordpress.android.ui.accounts.applicationpassword.ApplicationPasswordCreationTracker
4142
import org.wordpress.android.ui.ActivityNavigator
4243
import org.wordpress.android.ui.compose.theme.AppThemeM3
4344
import org.wordpress.android.ui.compose.unit.Margin
@@ -76,6 +77,9 @@ class ApplicationPasswordAutoAuthDialogActivity : ComponentActivity() {
7677
finish()
7778
}
7879
is ApplicationPasswordAutoAuthDialogViewModel.NavigationEvent.FallbackToManualLogin -> {
80+
ApplicationPasswordCreationTracker.setPendingCreationSource(
81+
ApplicationPasswordCreationTracker.SOURCE_MIGRATION
82+
)
7983
activityNavigator.openApplicationPasswordLogin(
8084
this@ApplicationPasswordAutoAuthDialogActivity,
8185
event.authUrl
@@ -103,7 +107,12 @@ class ApplicationPasswordAutoAuthDialogActivity : ComponentActivity() {
103107
setResult(RESULT_CANCELED)
104108
finish()
105109
},
106-
onConfirm = { viewModel.createApplicationPassword(site) }
110+
onConfirm = {
111+
viewModel.createApplicationPassword(
112+
site,
113+
ApplicationPasswordCreationTracker.SOURCE_AUTO_MIGRATION
114+
)
115+
}
107116
)
108117
}
109118
}

0 commit comments

Comments
 (0)