From 93c06a6cce21dbf44e271bd5087e3bcaf80bedc1 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:46:44 +0300 Subject: [PATCH 1/6] Fix account switching during authentication Prioritize explicit account selections over in-flight authentication on the main and search screens. Add regression tests for multi-account switching and include the Gradle priority setting. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../anod/appwatcher/search/SearchViewModel.kt | 8 +- .../appwatcher/watchlist/MainViewModel.kt | 8 +- .../AccountSelectionViewModelTest.kt | 211 ++++++++++++++++++ gradle.properties | 1 + 4 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/anod/appwatcher/watchlist/AccountSelectionViewModelTest.kt diff --git a/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt b/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt index 33e85405..2bc734e8 100644 --- a/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt @@ -299,10 +299,16 @@ class SearchViewModel(initialState: SearchViewState) : BaseFlowViewModel() + preferences = Preferences(context, NotificationManager.NoOp(), appScope) + originalCrashReports = preferences.collectCrashReports + originalUpdateFrequency = preferences.updatesFrequency + preferences.collectCrashReports = false + preferences.updatesFrequency = 0 + runBlocking { + assertTrue( + preferences.saveAccount( + AuthAccount(firstAccount, AuthTokenBlocking.ACCOUNT_TYPE, "4d2", "checkin", "config"), + deviceRegistrationPending = false, + deviceRegistrationAuthorized = false, + deviceConfigRevision = DeviceRegistration.DEVICE_CONFIG_REVISION + ) + ) + } + database = Room.inMemoryDatabaseBuilder(context, AppsDatabase::class.java) + .allowMainThreadQueries() + .build() + val authToken = AuthTokenBlocking.create(tokenProvider) + val initializer = AuthAccountInitializer(preferences, authToken, FakeDfeApi(), PlaySessionCoordinator()) + startKoin { + modules(module { + single { context } + single { preferences } + single { database } + single { authToken } + single { initializer } + }) + } + viewModel = MainViewModel() + viewModelStore.put("main", viewModel) + } + + @After + fun tearDown() { + tokenProvider.releaseFirst.countDown() + viewModelStore.clear() + database.close() + preferences.account = null + preferences.collectCrashReports = originalCrashReports + preferences.updatesFrequency = originalUpdateFrequency + stopKoin() + appScope.cancel() + } + + @Test + fun choosingAnotherAccountWhileResumingOldOneSwitchesInBothDirections() = runBlocking { + viewModel.handleEvent(MainViewEvent.OnResume) + awaitFirstToken() + + selectAccount(secondAccount) + tokenProvider.releaseFirst.countDown() + + awaitAccount(secondAccount) + assertEquals(secondAccount, preferences.account?.name) + + selectAccount(firstAccount) + awaitAccount(firstAccount) + assertEquals(firstAccount, preferences.account?.name) + assertEquals(listOf(firstAccount, secondAccount, firstAccount), tokenProvider.requestedAccounts) + } + + @Test + fun latestExplicitAccountSelectionIsNotDropped() = runBlocking { + selectAccount(firstAccount) + awaitFirstToken() + + selectAccount(secondAccount) + tokenProvider.releaseFirst.countDown() + + awaitAccount(secondAccount) + assertEquals(secondAccount, preferences.account?.name) + assertEquals(listOf(firstAccount, secondAccount), tokenProvider.requestedAccounts) + } + + @Test + fun searchAccountPickerResultOverridesInFlightAuthentication() = runBlocking { + val searchViewModel = SearchViewModel(SearchViewState()) + viewModelStore.put("search", searchViewModel) + searchViewModel.handleEvent( + SearchViewEvent.SetAccount( + AccountSelectionResult.Success(Account(firstAccount, AuthTokenBlocking.ACCOUNT_TYPE)) + ) + ) + awaitFirstToken() + + searchViewModel.handleEvent( + SearchViewEvent.SetAccount( + AccountSelectionResult.Success(Account(secondAccount, AuthTokenBlocking.ACCOUNT_TYPE)) + ) + ) + tokenProvider.releaseFirst.countDown() + + withTimeout(5_000) { + while (!searchViewModel.viewState.authenticated || preferences.account?.name != secondAccount) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + delay(10) + } + } + assertEquals(listOf(firstAccount, secondAccount), tokenProvider.requestedAccounts) + } + + private fun selectAccount(name: String) { + viewModel.handleEvent( + MainViewEvent.SetAccount( + AccountSelectionResult.Success(Account(name, AuthTokenBlocking.ACCOUNT_TYPE)) + ) + ) + } + + private suspend fun awaitAccount(name: String) { + withTimeout(5_000) { + while (viewModel.viewState.account?.name != name) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + delay(10) + } + } + } + + private suspend fun awaitFirstToken() { + withTimeout(5_000) { + while (!tokenProvider.firstRequested.await(0, TimeUnit.MILLISECONDS)) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + delay(10) + } + } + } + + private class BlockingTokenProvider(private val blockedAccount: String) : AccountAuthTokenProvider { + val firstRequested = CountDownLatch(1) + val releaseFirst = CountDownLatch(1) + val requestedAccounts = CopyOnWriteArrayList() + private var blocked = false + + override fun getAuthToken(account: Account): String { + requestedAccounts.add(account.name) + if (account.name == blockedAccount && !blocked) { + blocked = true + firstRequested.countDown() + check(releaseFirst.await(5, TimeUnit.SECONDS)) + } + return "token-${account.name}" + } + + override fun invalidateAuthToken(token: String) = Unit + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 9f90468d..c526b420 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,6 +15,7 @@ android.nonTransitiveRClass=true kapt.incremental.apt=true org.gradle.jvmargs=-Xmx3072M -Dkotlin.daemon.jvm.options\="-Xmx2048M" org.gradle.workers.max=2 +org.gradle.priority=low android.useAndroidX=true android.enableJetifier=false android.nonFinalResIds=true From ecf064d0abfdddad9c6fccc4f805954b4a5e0df0 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:32:47 +0300 Subject: [PATCH 2/6] Keep account selections ordered through authentication Share a cancellation-safe initialization handoff between Main and Search, finish scheduling when WorkManager reaches a terminal state, and cover rapid account changes with auto-sync enabled. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- app/build.gradle.kts | 1 + .../accounts/AccountInitialization.kt | 30 +++++++++++ .../anod/appwatcher/search/SearchViewModel.kt | 12 ++--- .../appwatcher/watchlist/MainViewModel.kt | 16 +++--- .../AccountSelectionViewModelTest.kt | 51 +++++++++++++++++-- gradle/libs.versions.toml | 1 + 6 files changed, 91 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/anod/appwatcher/accounts/AccountInitialization.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e2fe47e4..36182713 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -201,6 +201,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.androidx.test.core) testImplementation(libs.robolectric) + testImplementation(libs.work.testing) testImplementation(platform(libs.androidx.compose.bom)) testImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/anod/appwatcher/accounts/AccountInitialization.kt b/app/src/main/java/com/anod/appwatcher/accounts/AccountInitialization.kt new file mode 100644 index 00000000..18b6505a --- /dev/null +++ b/app/src/main/java/com/anod/appwatcher/accounts/AccountInitialization.kt @@ -0,0 +1,30 @@ +package com.anod.appwatcher.accounts + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +internal fun CoroutineScope.launchAccountInitialization( + previousJob: Job?, + userInitiated: Boolean, + initialize: suspend () -> Unit +): Job? { + if (!userInitiated && previousJob?.isActive == true) { + return previousJob + } + if (userInitiated) { + previousJob?.cancel() + } + return launch { + // Keep canceled intermediate jobs waiting so later selections cannot overtake an active session. + withContext(NonCancellable) { + previousJob?.join() + } + currentCoroutineContext().ensureActive() + initialize() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt b/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt index 2bc734e8..69562cb6 100644 --- a/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt @@ -25,6 +25,7 @@ import com.anod.appwatcher.accounts.AuthTokenStartIntent import com.anod.appwatcher.accounts.CheckTokenError import com.anod.appwatcher.accounts.CheckTokenResult import com.anod.appwatcher.accounts.DeviceRegistrationException +import com.anod.appwatcher.accounts.launchAccountInitialization import com.anod.appwatcher.accounts.showAccountSelectionAction import com.anod.appwatcher.accounts.toAndroidAccount import com.anod.appwatcher.database.AppsDatabase @@ -299,16 +300,13 @@ class SearchViewModel(initialState: SearchViewState) : BaseFlowViewModel() - preferences = Preferences(context, NotificationManager.NoOp(), appScope) + preferences = Preferences(context, NotificationManager.NoOp(areNotificationsEnabled = true), appScope) originalCrashReports = preferences.collectCrashReports originalUpdateFrequency = preferences.updatesFrequency + originalVersionCode = preferences.versionCode preferences.collectCrashReports = false preferences.updatesFrequency = 0 runBlocking { @@ -103,6 +109,7 @@ class AccountSelectionViewModelTest { preferences.account = null preferences.collectCrashReports = originalCrashReports preferences.updatesFrequency = originalUpdateFrequency + preferences.versionCode = originalVersionCode stopKoin() appScope.cancel() } @@ -130,11 +137,34 @@ class AccountSelectionViewModelTest { awaitFirstToken() selectAccount(secondAccount) + selectAccount(thirdAccount) + Shadows.shadowOf(Looper.getMainLooper()).idle() tokenProvider.releaseFirst.countDown() + awaitAccount(thirdAccount) + assertEquals(thirdAccount, preferences.account?.name) + assertEquals(listOf(firstAccount, thirdAccount), tokenProvider.requestedAccounts) + } + + @Test + fun autoSyncSchedulingFinishesBeforeSwitchingAccounts() = runBlocking { + WorkManagerTestInitHelper.initializeTestWorkManager(ApplicationProvider.getApplicationContext()) + preferences.updatesFrequency = 3600 + preferences.versionCode = 0 + + viewModel.handleEvent(MainViewEvent.OnResume) + awaitFirstToken() + tokenProvider.releaseFirst.countDown() + + withTimeout(10_000) { + while (preferences.versionCode != BuildConfig.VERSION_CODE) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + delay(10) + } + } + selectAccount(secondAccount) awaitAccount(secondAccount) assertEquals(secondAccount, preferences.account?.name) - assertEquals(listOf(firstAccount, secondAccount), tokenProvider.requestedAccounts) } @Test @@ -153,15 +183,21 @@ class AccountSelectionViewModelTest { AccountSelectionResult.Success(Account(secondAccount, AuthTokenBlocking.ACCOUNT_TYPE)) ) ) + searchViewModel.handleEvent( + SearchViewEvent.SetAccount( + AccountSelectionResult.Success(Account(thirdAccount, AuthTokenBlocking.ACCOUNT_TYPE)) + ) + ) + Shadows.shadowOf(Looper.getMainLooper()).idle() tokenProvider.releaseFirst.countDown() withTimeout(5_000) { - while (!searchViewModel.viewState.authenticated || preferences.account?.name != secondAccount) { + while (!searchViewModel.viewState.authenticated || preferences.account?.name != thirdAccount) { Shadows.shadowOf(Looper.getMainLooper()).idle() delay(10) } } - assertEquals(listOf(firstAccount, secondAccount), tokenProvider.requestedAccounts) + assertEquals(listOf(firstAccount, thirdAccount), tokenProvider.requestedAccounts) } private fun selectAccount(name: String) { @@ -173,12 +209,17 @@ class AccountSelectionViewModelTest { } private suspend fun awaitAccount(name: String) { - withTimeout(5_000) { + val observed = withTimeoutOrNull(10_000) { while (viewModel.viewState.account?.name != name) { Shadows.shadowOf(Looper.getMainLooper()).idle() delay(10) } + true } + assertTrue( + "Expected $name; shown=${viewModel.viewState.account?.name}, saved=${preferences.account?.name}, requests=${tokenProvider.requestedAccounts}", + observed == true + ) } private suspend fun awaitFirstToken() { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6f090901..456e1c45 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -138,6 +138,7 @@ runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version work-runtime = { module = "androidx.work:work-runtime", version.ref = "work-runtime" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work-runtime" } +work-testing = { module = "androidx.work:work-testing", version.ref = "work-runtime" } androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } [plugins] From d918e68ede0c6bd5380d3c1125a7f77146c018d8 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:08:04 +0300 Subject: [PATCH 3/6] Coordinate account selection across scenes Cancel and join an in-flight initializer when the modal account picker returns a new choice from another screen. Refresh the main drawer account on resume, and cover picker handoff with deterministic Robolectric tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../accounts/AccountInitialization.kt | 14 ++-- .../accounts/AuthAccountInitializer.kt | 42 ++++++++-- .../appwatcher/watchlist/MainViewModel.kt | 3 + .../AccountSelectionViewModelTest.kt | 82 +++++++++++++++++-- 4 files changed, 120 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/anod/appwatcher/accounts/AccountInitialization.kt b/app/src/main/java/com/anod/appwatcher/accounts/AccountInitialization.kt index 18b6505a..13ad3210 100644 --- a/app/src/main/java/com/anod/appwatcher/accounts/AccountInitialization.kt +++ b/app/src/main/java/com/anod/appwatcher/accounts/AccountInitialization.kt @@ -20,11 +20,15 @@ internal fun CoroutineScope.launchAccountInitialization( previousJob?.cancel() } return launch { - // Keep canceled intermediate jobs waiting so later selections cannot overtake an active session. - withContext(NonCancellable) { - previousJob?.join() - } - currentCoroutineContext().ensureActive() + awaitPreviousInitialization(previousJob) initialize() } +} + +internal suspend fun awaitPreviousInitialization(previousJob: Job?) { + // Keep canceled intermediate jobs waiting so later selections cannot overtake an active session. + withContext(NonCancellable) { + previousJob?.join() + } + currentCoroutineContext().ensureActive() } \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/accounts/AuthAccountInitializer.kt b/app/src/main/java/com/anod/appwatcher/accounts/AuthAccountInitializer.kt index f6f3f73e..9a3fc729 100644 --- a/app/src/main/java/com/anod/appwatcher/accounts/AuthAccountInitializer.kt +++ b/app/src/main/java/com/anod/appwatcher/accounts/AuthAccountInitializer.kt @@ -5,6 +5,9 @@ import android.os.Build import com.anod.appwatcher.preferences.Preferences import finsky.api.DfeApi import info.anodsplace.applog.AppLog +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -36,6 +39,8 @@ class AuthAccountInitializer( private val dfeApi: DfeApi, private val playSessionCoordinator: PlaySessionCoordinator ) { + private var activeInitialization: Job? = null + private val deviceRegistration = DeviceRegistration( preferences = preferences, dfeApi = dfeApi, @@ -45,16 +50,39 @@ class AuthAccountInitializer( suspend fun initialize( account: Account, userInitiated: Boolean - ): AuthAccount = - if (userInitiated) { - playSessionCoordinator.withUserInitiatedSession { - initializeInSession(account, userInitiated = true) + ): AuthAccount { + val context = currentCoroutineContext() + context.ensureActive() + val job = checkNotNull(context[Job]) { + "Account initialization requires an active coroutine job" + } + val previousJob = synchronized(this) { + val previous = activeInitialization + if (userInitiated) { + previous?.cancel() } - } else { - playSessionCoordinator.withSession { - initializeInSession(account, userInitiated = false) + activeInitialization = job + previous + } + return try { + awaitPreviousInitialization(previousJob) + if (userInitiated) { + playSessionCoordinator.withUserInitiatedSession { + initializeInSession(account, userInitiated = true) + } + } else { + playSessionCoordinator.withSession { + initializeInSession(account, userInitiated = false) + } + } + } finally { + synchronized(this) { + if (activeInitialization === job) { + activeInitialization = null + } } } + } private suspend fun initializeInSession( account: Account, diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/MainViewModel.kt b/app/src/main/java/com/anod/appwatcher/watchlist/MainViewModel.kt index 9b18b385..0f4ef985 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/MainViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/MainViewModel.kt @@ -186,6 +186,9 @@ class MainViewModel : BaseFlowViewModel { context } @@ -116,6 +117,7 @@ class AccountSelectionViewModelTest { @Test fun choosingAnotherAccountWhileResumingOldOneSwitchesInBothDirections() = runBlocking { + preferences.versionCode = 0 viewModel.handleEvent(MainViewEvent.OnResume) awaitFirstToken() @@ -124,6 +126,7 @@ class AccountSelectionViewModelTest { awaitAccount(secondAccount) assertEquals(secondAccount, preferences.account?.name) + awaitUpgradeCheck() selectAccount(firstAccount) awaitAccount(firstAccount) @@ -156,12 +159,7 @@ class AccountSelectionViewModelTest { awaitFirstToken() tokenProvider.releaseFirst.countDown() - withTimeout(10_000) { - while (preferences.versionCode != BuildConfig.VERSION_CODE) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - delay(10) - } - } + awaitUpgradeCheck() selectAccount(secondAccount) awaitAccount(secondAccount) assertEquals(secondAccount, preferences.account?.name) @@ -200,6 +198,61 @@ class AccountSelectionViewModelTest { assertEquals(listOf(firstAccount, thirdAccount), tokenProvider.requestedAccounts) } + @Test + fun searchPickerSwitchesAccountWhileMainRegistrationIsRunning() = runBlocking { + preferences.account = AuthAccount(firstAccount, AuthTokenBlocking.ACCOUNT_TYPE, "", "", "") + selectAccount(firstAccount) + awaitFirstToken() + + assertTrue(preferences.isDeviceRegistrationRequired) + val searchViewModel = SearchViewModel(SearchViewState()) + viewModelStore.put("search", searchViewModel) + searchViewModel.handleEvent( + SearchViewEvent.SetAccount( + AccountSelectionResult.Success(Account(secondAccount, AuthTokenBlocking.ACCOUNT_TYPE)) + ) + ) + Shadows.shadowOf(Looper.getMainLooper()).idle() + tokenProvider.releaseFirst.countDown() + + withTimeout(10_000) { + while (!searchViewModel.viewState.authenticated || preferences.account?.name != secondAccount) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + delay(10) + } + } + assertEquals(listOf(firstAccount, secondAccount), tokenProvider.requestedAccounts) + } + + @Test + fun mainShowsAccountSelectedFromSearchPickerOnReturn() = runBlocking { + preferences.account = AuthAccount(firstAccount, AuthTokenBlocking.ACCOUNT_TYPE, "", "", "") + preferences.versionCode = 0 + selectAccount(firstAccount) + awaitFirstToken() + + assertTrue(preferences.isDeviceRegistrationRequired) + val searchViewModel = SearchViewModel(SearchViewState()) + viewModelStore.put("search", searchViewModel) + tokenProvider.releaseFirst.countDown() + awaitUpgradeCheck() + + searchViewModel.handleEvent( + SearchViewEvent.SetAccount( + AccountSelectionResult.Success(Account(secondAccount, AuthTokenBlocking.ACCOUNT_TYPE)) + ) + ) + withTimeout(10_000) { + while (!searchViewModel.viewState.authenticated || preferences.account?.name != secondAccount) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + delay(10) + } + } + + viewModel.handleEvent(MainViewEvent.OnResume) + assertEquals(secondAccount, viewModel.viewState.account?.name) + } + private fun selectAccount(name: String) { viewModel.handleEvent( MainViewEvent.SetAccount( @@ -217,7 +270,9 @@ class AccountSelectionViewModelTest { true } assertTrue( - "Expected $name; shown=${viewModel.viewState.account?.name}, saved=${preferences.account?.name}, requests=${tokenProvider.requestedAccounts}", + "Expected $name; shown=${viewModel.viewState.account?.name}, saved=${preferences.account?.name}, " + + "configReady=${!preferences.account?.deviceConfig.isNullOrEmpty()}, uploads=${dfeApi.uploadCalls}, " + + "requests=${tokenProvider.requestedAccounts}", observed == true ) } @@ -231,6 +286,15 @@ class AccountSelectionViewModelTest { } } + private suspend fun awaitUpgradeCheck() { + withTimeout(10_000) { + while (preferences.versionCode != BuildConfig.VERSION_CODE) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + delay(10) + } + } + } + private class BlockingTokenProvider(private val blockedAccount: String) : AccountAuthTokenProvider { val firstRequested = CountDownLatch(1) val releaseFirst = CountDownLatch(1) From 6efe38561df67ba860e8503dd64b13d0411514a3 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:18:06 +0300 Subject: [PATCH 4/6] Order cross-screen account selections in one initializer Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../accounts/AccountInitialization.kt | 34 ------------------- .../accounts/AuthAccountInitializer.kt | 8 ++++- .../anod/appwatcher/search/SearchViewModel.kt | 7 ++-- .../appwatcher/watchlist/MainViewModel.kt | 7 ++-- .../AccountSelectionViewModelTest.kt | 29 ++++++++++++++++ 5 files changed, 40 insertions(+), 45 deletions(-) delete mode 100644 app/src/main/java/com/anod/appwatcher/accounts/AccountInitialization.kt diff --git a/app/src/main/java/com/anod/appwatcher/accounts/AccountInitialization.kt b/app/src/main/java/com/anod/appwatcher/accounts/AccountInitialization.kt deleted file mode 100644 index 13ad3210..00000000 --- a/app/src/main/java/com/anod/appwatcher/accounts/AccountInitialization.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.anod.appwatcher.accounts - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -internal fun CoroutineScope.launchAccountInitialization( - previousJob: Job?, - userInitiated: Boolean, - initialize: suspend () -> Unit -): Job? { - if (!userInitiated && previousJob?.isActive == true) { - return previousJob - } - if (userInitiated) { - previousJob?.cancel() - } - return launch { - awaitPreviousInitialization(previousJob) - initialize() - } -} - -internal suspend fun awaitPreviousInitialization(previousJob: Job?) { - // Keep canceled intermediate jobs waiting so later selections cannot overtake an active session. - withContext(NonCancellable) { - previousJob?.join() - } - currentCoroutineContext().ensureActive() -} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/accounts/AuthAccountInitializer.kt b/app/src/main/java/com/anod/appwatcher/accounts/AuthAccountInitializer.kt index 9a3fc729..357b49ef 100644 --- a/app/src/main/java/com/anod/appwatcher/accounts/AuthAccountInitializer.kt +++ b/app/src/main/java/com/anod/appwatcher/accounts/AuthAccountInitializer.kt @@ -6,10 +6,12 @@ import com.anod.appwatcher.preferences.Preferences import finsky.api.DfeApi import info.anodsplace.applog.AppLog import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext class AuthTokenUnavailableException : IllegalStateException("Unable to retrieve authentication token") class AccountSessionBusyException : IllegalStateException("A Play Store synchronization is in progress") @@ -65,7 +67,11 @@ class AuthAccountInitializer( previous } return try { - awaitPreviousInitialization(previousJob) + // Keep canceled intermediate selections waiting so the latest one cannot overtake an active session. + withContext(NonCancellable) { + previousJob?.join() + } + context.ensureActive() if (userInitiated) { playSessionCoordinator.withUserInitiatedSession { initializeInSession(account, userInitiated = true) diff --git a/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt b/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt index 69562cb6..9c3d92f1 100644 --- a/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt @@ -25,7 +25,6 @@ import com.anod.appwatcher.accounts.AuthTokenStartIntent import com.anod.appwatcher.accounts.CheckTokenError import com.anod.appwatcher.accounts.CheckTokenResult import com.anod.appwatcher.accounts.DeviceRegistrationException -import com.anod.appwatcher.accounts.launchAccountInitialization import com.anod.appwatcher.accounts.showAccountSelectionAction import com.anod.appwatcher.accounts.toAndroidAccount import com.anod.appwatcher.database.AppsDatabase @@ -302,11 +301,9 @@ class SearchViewModel(initialState: SearchViewState) : BaseFlowViewModel Date: Wed, 30 Sep 2026 17:36:27 +0300 Subject: [PATCH 5/6] Reuse search account test helpers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AccountSelectionViewModelTest.kt | 81 +++++++------------ 1 file changed, 27 insertions(+), 54 deletions(-) diff --git a/app/src/test/java/com/anod/appwatcher/watchlist/AccountSelectionViewModelTest.kt b/app/src/test/java/com/anod/appwatcher/watchlist/AccountSelectionViewModelTest.kt index ffb66779..0b4daeb3 100644 --- a/app/src/test/java/com/anod/appwatcher/watchlist/AccountSelectionViewModelTest.kt +++ b/app/src/test/java/com/anod/appwatcher/watchlist/AccountSelectionViewModelTest.kt @@ -169,32 +169,15 @@ class AccountSelectionViewModelTest { fun searchAccountPickerResultOverridesInFlightAuthentication() = runBlocking { val searchViewModel = SearchViewModel(SearchViewState()) viewModelStore.put("search", searchViewModel) - searchViewModel.handleEvent( - SearchViewEvent.SetAccount( - AccountSelectionResult.Success(Account(firstAccount, AuthTokenBlocking.ACCOUNT_TYPE)) - ) - ) + selectSearchAccount(searchViewModel, firstAccount) awaitFirstToken() - searchViewModel.handleEvent( - SearchViewEvent.SetAccount( - AccountSelectionResult.Success(Account(secondAccount, AuthTokenBlocking.ACCOUNT_TYPE)) - ) - ) - searchViewModel.handleEvent( - SearchViewEvent.SetAccount( - AccountSelectionResult.Success(Account(thirdAccount, AuthTokenBlocking.ACCOUNT_TYPE)) - ) - ) + selectSearchAccount(searchViewModel, secondAccount) + selectSearchAccount(searchViewModel, thirdAccount) Shadows.shadowOf(Looper.getMainLooper()).idle() tokenProvider.releaseFirst.countDown() - withTimeout(5_000) { - while (!searchViewModel.viewState.authenticated || preferences.account?.name != thirdAccount) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - delay(10) - } - } + awaitSearchAccount(searchViewModel, thirdAccount, timeoutMillis = 5_000) assertEquals(listOf(firstAccount, thirdAccount), tokenProvider.requestedAccounts) } @@ -207,20 +190,11 @@ class AccountSelectionViewModelTest { assertTrue(preferences.isDeviceRegistrationRequired) val searchViewModel = SearchViewModel(SearchViewState()) viewModelStore.put("search", searchViewModel) - searchViewModel.handleEvent( - SearchViewEvent.SetAccount( - AccountSelectionResult.Success(Account(secondAccount, AuthTokenBlocking.ACCOUNT_TYPE)) - ) - ) + selectSearchAccount(searchViewModel, secondAccount) Shadows.shadowOf(Looper.getMainLooper()).idle() tokenProvider.releaseFirst.countDown() - withTimeout(10_000) { - while (!searchViewModel.viewState.authenticated || preferences.account?.name != secondAccount) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - delay(10) - } - } + awaitSearchAccount(searchViewModel, secondAccount, timeoutMillis = 10_000) assertEquals(listOf(firstAccount, secondAccount), tokenProvider.requestedAccounts) } @@ -236,20 +210,11 @@ class AccountSelectionViewModelTest { assertTrue(preferences.isDeviceRegistrationRequired) val searchViewModel = SearchViewModel(SearchViewState()) viewModelStore.put("search", searchViewModel) - searchViewModel.handleEvent( - SearchViewEvent.SetAccount( - AccountSelectionResult.Success(Account(thirdAccount, AuthTokenBlocking.ACCOUNT_TYPE)) - ) - ) + selectSearchAccount(searchViewModel, thirdAccount) Shadows.shadowOf(Looper.getMainLooper()).idle() tokenProvider.releaseFirst.countDown() - withTimeout(10_000) { - while (!searchViewModel.viewState.authenticated || preferences.account?.name != thirdAccount) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - delay(10) - } - } + awaitSearchAccount(searchViewModel, thirdAccount, timeoutMillis = 10_000) assertEquals(listOf(firstAccount, thirdAccount), tokenProvider.requestedAccounts) } @@ -266,17 +231,8 @@ class AccountSelectionViewModelTest { tokenProvider.releaseFirst.countDown() awaitUpgradeCheck() - searchViewModel.handleEvent( - SearchViewEvent.SetAccount( - AccountSelectionResult.Success(Account(secondAccount, AuthTokenBlocking.ACCOUNT_TYPE)) - ) - ) - withTimeout(10_000) { - while (!searchViewModel.viewState.authenticated || preferences.account?.name != secondAccount) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - delay(10) - } - } + selectSearchAccount(searchViewModel, secondAccount) + awaitSearchAccount(searchViewModel, secondAccount, timeoutMillis = 10_000) viewModel.handleEvent(MainViewEvent.OnResume) assertEquals(secondAccount, viewModel.viewState.account?.name) @@ -290,6 +246,14 @@ class AccountSelectionViewModelTest { ) } + private fun selectSearchAccount(searchViewModel: SearchViewModel, name: String) { + searchViewModel.handleEvent( + SearchViewEvent.SetAccount( + AccountSelectionResult.Success(Account(name, AuthTokenBlocking.ACCOUNT_TYPE)) + ) + ) + } + private suspend fun awaitAccount(name: String) { val observed = withTimeoutOrNull(10_000) { while (viewModel.viewState.account?.name != name) { @@ -306,6 +270,15 @@ class AccountSelectionViewModelTest { ) } + private suspend fun awaitSearchAccount(searchViewModel: SearchViewModel, name: String, timeoutMillis: Long) { + withTimeout(timeoutMillis) { + while (!searchViewModel.viewState.authenticated || preferences.account?.name != name) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + delay(10) + } + } + } + private suspend fun awaitFirstToken() { withTimeout(5_000) { while (!tokenProvider.firstRequested.await(0, TimeUnit.MILLISECONDS)) { From c1728b7c96906f2d4dd901281e4ee45a2ab086df Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:09:32 +0300 Subject: [PATCH 6/6] Cancel queued and running account initializations Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../accounts/AuthAccountInitializer.kt | 19 ++++---- .../accounts/AuthAccountInitializerTest.kt | 45 +++++++++++++++++++ .../AccountSelectionViewModelTest.kt | 22 +++++++++ 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/anod/appwatcher/accounts/AuthAccountInitializer.kt b/app/src/main/java/com/anod/appwatcher/accounts/AuthAccountInitializer.kt index 357b49ef..8e840cbf 100644 --- a/app/src/main/java/com/anod/appwatcher/accounts/AuthAccountInitializer.kt +++ b/app/src/main/java/com/anod/appwatcher/accounts/AuthAccountInitializer.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.joinAll import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -41,7 +42,7 @@ class AuthAccountInitializer( private val dfeApi: DfeApi, private val playSessionCoordinator: PlaySessionCoordinator ) { - private var activeInitialization: Job? = null + private val initializations = mutableSetOf() private val deviceRegistration = DeviceRegistration( preferences = preferences, @@ -58,18 +59,18 @@ class AuthAccountInitializer( val job = checkNotNull(context[Job]) { "Account initialization requires an active coroutine job" } - val previousJob = synchronized(this) { - val previous = activeInitialization + val previousJobs = synchronized(this) { + val previous = initializations.toList() if (userInitiated) { - previous?.cancel() + previous.forEach { it.cancel() } } - activeInitialization = job + initializations.add(job) previous } return try { - // Keep canceled intermediate selections waiting so the latest one cannot overtake an active session. + // Canceled initializations may still be finishing device registration. withContext(NonCancellable) { - previousJob?.join() + previousJobs.joinAll() } context.ensureActive() if (userInitiated) { @@ -83,9 +84,7 @@ class AuthAccountInitializer( } } finally { synchronized(this) { - if (activeInitialization === job) { - activeInitialization = null - } + initializations.remove(job) } } } diff --git a/app/src/test/java/com/anod/appwatcher/accounts/AuthAccountInitializerTest.kt b/app/src/test/java/com/anod/appwatcher/accounts/AuthAccountInitializerTest.kt index 54483573..58e70383 100644 --- a/app/src/test/java/com/anod/appwatcher/accounts/AuthAccountInitializerTest.kt +++ b/app/src/test/java/com/anod/appwatcher/accounts/AuthAccountInitializerTest.kt @@ -9,9 +9,11 @@ import java.io.IOException import java.util.ArrayDeque import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -245,6 +247,49 @@ class AuthAccountInitializerTest { assertEquals(1, tokenProvider.requestedAccounts.size) } + @Test + fun explicitSelectionCancelsRunningInitializationBehindQueuedAutomaticRequest() = runBlocking { + preferences.account = AuthAccount("account@example.com", AuthTokenBlocking.ACCOUNT_TYPE, "", "", "") + val checkInStarted = CompletableDeferred() + val finishCheckIn = CompletableDeferred() + val dfeApi = FakeDfeApi().apply { + beforeCheckIn = { + checkInStarted.complete(Unit) + finishCheckIn.await() + } + } + val tokenProvider = RecordingTokenProvider("token-a", "token-b") + val initializer = AuthAccountInitializer( + preferences, + AuthTokenBlocking.create(tokenProvider), + dfeApi, + PlaySessionCoordinator() + ) + val accountA = Account("account@example.com", AuthTokenBlocking.ACCOUNT_TYPE) + val accountB = Account("b@example.com", AuthTokenBlocking.ACCOUNT_TYPE) + val running = async { initializer.initialize(accountA, userInitiated = true) } + + try { + withTimeout(5_000) { checkInStarted.await() } + val automatic = async(start = CoroutineStart.UNDISPATCHED) { + initializer.initialize(accountA, userInitiated = false) + } + val selected = async(start = CoroutineStart.UNDISPATCHED) { + initializer.initialize(accountB, userInitiated = true) + } + + assertTrue("The running initialization must be canceled, not just its queued successor", running.isCancelled) + assertTrue(automatic.isCancelled) + finishCheckIn.complete(Unit) + + assertEquals(accountB.name, withTimeout(5_000) { selected.await().name }) + assertEquals(accountB.name, preferences.account?.name) + assertEquals(listOf(accountA.name, accountB.name), tokenProvider.requestedAccounts) + } finally { + finishCheckIn.complete(Unit) + } + } + private fun completeAccount() = AuthAccount( name = "account@example.com", type = AuthTokenBlocking.ACCOUNT_TYPE, diff --git a/app/src/test/java/com/anod/appwatcher/watchlist/AccountSelectionViewModelTest.kt b/app/src/test/java/com/anod/appwatcher/watchlist/AccountSelectionViewModelTest.kt index 0b4daeb3..9ccf8cfe 100644 --- a/app/src/test/java/com/anod/appwatcher/watchlist/AccountSelectionViewModelTest.kt +++ b/app/src/test/java/com/anod/appwatcher/watchlist/AccountSelectionViewModelTest.kt @@ -198,6 +198,28 @@ class AccountSelectionViewModelTest { assertEquals(listOf(firstAccount, secondAccount), tokenProvider.requestedAccounts) } + @Test + fun searchSelectionCancelsMainRegistrationBehindAutomaticResume() = runBlocking { + preferences.account = AuthAccount(firstAccount, AuthTokenBlocking.ACCOUNT_TYPE, "", "", "") + preferences.versionCode = 0 + selectAccount(firstAccount) + awaitFirstToken() + + assertTrue(preferences.isDeviceRegistrationAuthorized) + val searchViewModel = SearchViewModel(SearchViewState()) + viewModelStore.put("search", searchViewModel) + searchViewModel.handleEvent(SearchViewEvent.OnResume) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + selectSearchAccount(searchViewModel, secondAccount) + Shadows.shadowOf(Looper.getMainLooper()).idle() + tokenProvider.releaseFirst.countDown() + + awaitSearchAccount(searchViewModel, secondAccount, timeoutMillis = 10_000) + assertEquals(0, preferences.versionCode) + assertEquals(listOf(firstAccount, secondAccount), tokenProvider.requestedAccounts) + } + @Test fun searchSelectionWinsOverQueuedMainSelection() = runBlocking { preferences.account = AuthAccount(firstAccount, AuthTokenBlocking.ACCOUNT_TYPE, "", "", "")