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/AuthAccountInitializer.kt b/app/src/main/java/com/anod/appwatcher/accounts/AuthAccountInitializer.kt index f6f3f73e..8e840cbf 100644 --- a/app/src/main/java/com/anod/appwatcher/accounts/AuthAccountInitializer.kt +++ b/app/src/main/java/com/anod/appwatcher/accounts/AuthAccountInitializer.kt @@ -5,8 +5,14 @@ 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.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 class AuthTokenUnavailableException : IllegalStateException("Unable to retrieve authentication token") class AccountSessionBusyException : IllegalStateException("A Play Store synchronization is in progress") @@ -36,6 +42,8 @@ class AuthAccountInitializer( private val dfeApi: DfeApi, private val playSessionCoordinator: PlaySessionCoordinator ) { + private val initializations = mutableSetOf() + private val deviceRegistration = DeviceRegistration( preferences = preferences, dfeApi = dfeApi, @@ -45,16 +53,41 @@ 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 previousJobs = synchronized(this) { + val previous = initializations.toList() + if (userInitiated) { + previous.forEach { it.cancel() } } - } else { - playSessionCoordinator.withSession { - initializeInSession(account, userInitiated = false) + initializations.add(job) + previous + } + return try { + // Canceled initializations may still be finishing device registration. + withContext(NonCancellable) { + previousJobs.joinAll() + } + context.ensureActive() + if (userInitiated) { + playSessionCoordinator.withUserInitiatedSession { + initializeInSession(account, userInitiated = true) + } + } else { + playSessionCoordinator.withSession { + initializeInSession(account, userInitiated = false) + } + } + } finally { + synchronized(this) { + initializations.remove(job) } } + } private suspend fun initializeInSession( account: Account, 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..9c3d92f1 100644 --- a/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/search/SearchViewModel.kt @@ -299,8 +299,9 @@ class SearchViewModel(initialState: SearchViewState) : BaseFlowViewModel() + 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 new file mode 100644 index 00000000..9ccf8cfe --- /dev/null +++ b/app/src/test/java/com/anod/appwatcher/watchlist/AccountSelectionViewModelTest.kt @@ -0,0 +1,340 @@ +package com.anod.appwatcher.watchlist + +import android.accounts.Account +import android.app.Application +import android.content.Context +import android.os.Looper +import androidx.lifecycle.ViewModelStore +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.work.testing.WorkManagerTestInitHelper +import com.anod.appwatcher.BuildConfig +import com.anod.appwatcher.accounts.AccountAuthTokenProvider +import com.anod.appwatcher.accounts.AccountSelectionResult +import com.anod.appwatcher.accounts.AuthAccount +import com.anod.appwatcher.accounts.AuthAccountInitializer +import com.anod.appwatcher.accounts.AuthTokenBlocking +import com.anod.appwatcher.accounts.DeviceRegistration +import com.anod.appwatcher.accounts.FakeDfeApi +import com.anod.appwatcher.accounts.PlaySessionCoordinator +import com.anod.appwatcher.database.AppsDatabase +import com.anod.appwatcher.preferences.Preferences +import com.anod.appwatcher.search.SearchViewEvent +import com.anod.appwatcher.search.SearchViewModel +import com.anod.appwatcher.search.SearchViewState +import info.anodsplace.notification.NotificationManager +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [35]) +@LooperMode(LooperMode.Mode.PAUSED) +class AccountSelectionViewModelTest { + private val firstAccount = "first@example.com" + private val secondAccount = "second@example.com" + private val thirdAccount = "third@example.com" + private val tokenProvider = BlockingTokenProvider(firstAccount) + private val dfeApi = FakeDfeApi() + private val appScope = CoroutineScope(Dispatchers.Unconfined) + private val viewModelStore = ViewModelStore() + private lateinit var preferences: Preferences + private lateinit var database: AppsDatabase + private lateinit var viewModel: MainViewModel + private var originalCrashReports = false + private var originalUpdateFrequency = 0 + private var originalVersionCode = 0 + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + preferences = Preferences(context, NotificationManager.NoOp(areNotificationsEnabled = true), appScope) + originalCrashReports = preferences.collectCrashReports + originalUpdateFrequency = preferences.updatesFrequency + originalVersionCode = preferences.versionCode + 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, dfeApi, 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 + preferences.versionCode = originalVersionCode + stopKoin() + appScope.cancel() + } + + @Test + fun choosingAnotherAccountWhileResumingOldOneSwitchesInBothDirections() = runBlocking { + preferences.versionCode = 0 + viewModel.handleEvent(MainViewEvent.OnResume) + awaitFirstToken() + + selectAccount(secondAccount) + tokenProvider.releaseFirst.countDown() + + awaitAccount(secondAccount) + assertEquals(secondAccount, preferences.account?.name) + awaitUpgradeCheck() + + 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) + 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() + + awaitUpgradeCheck() + selectAccount(secondAccount) + awaitAccount(secondAccount) + assertEquals(secondAccount, preferences.account?.name) + } + + @Test + fun searchAccountPickerResultOverridesInFlightAuthentication() = runBlocking { + val searchViewModel = SearchViewModel(SearchViewState()) + viewModelStore.put("search", searchViewModel) + selectSearchAccount(searchViewModel, firstAccount) + awaitFirstToken() + + selectSearchAccount(searchViewModel, secondAccount) + selectSearchAccount(searchViewModel, thirdAccount) + Shadows.shadowOf(Looper.getMainLooper()).idle() + tokenProvider.releaseFirst.countDown() + + awaitSearchAccount(searchViewModel, thirdAccount, timeoutMillis = 5_000) + 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) + selectSearchAccount(searchViewModel, secondAccount) + Shadows.shadowOf(Looper.getMainLooper()).idle() + tokenProvider.releaseFirst.countDown() + + awaitSearchAccount(searchViewModel, secondAccount, timeoutMillis = 10_000) + 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, "", "", "") + selectAccount(firstAccount) + awaitFirstToken() + + selectAccount(secondAccount) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + assertTrue(preferences.isDeviceRegistrationRequired) + val searchViewModel = SearchViewModel(SearchViewState()) + viewModelStore.put("search", searchViewModel) + selectSearchAccount(searchViewModel, thirdAccount) + Shadows.shadowOf(Looper.getMainLooper()).idle() + tokenProvider.releaseFirst.countDown() + + awaitSearchAccount(searchViewModel, thirdAccount, timeoutMillis = 10_000) + assertEquals(listOf(firstAccount, thirdAccount), 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() + + selectSearchAccount(searchViewModel, secondAccount) + awaitSearchAccount(searchViewModel, secondAccount, timeoutMillis = 10_000) + + viewModel.handleEvent(MainViewEvent.OnResume) + assertEquals(secondAccount, viewModel.viewState.account?.name) + } + + private fun selectAccount(name: String) { + viewModel.handleEvent( + MainViewEvent.SetAccount( + AccountSelectionResult.Success(Account(name, AuthTokenBlocking.ACCOUNT_TYPE)) + ) + ) + } + + 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) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + delay(10) + } + true + } + assertTrue( + "Expected $name; shown=${viewModel.viewState.account?.name}, saved=${preferences.account?.name}, " + + "configReady=${!preferences.account?.deviceConfig.isNullOrEmpty()}, uploads=${dfeApi.uploadCalls}, " + + "requests=${tokenProvider.requestedAccounts}", + observed == true + ) + } + + 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)) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + delay(10) + } + } + } + + 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) + 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 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]