diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt index d55b221..570837d 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt @@ -298,6 +298,64 @@ class BoardsFlowTest { } } + @Test + fun logoutConfirmationClearsSessionAndReturnsToLogin() { + val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1") + val apiKeyStore = InMemoryApiKeyStore("api") + MainActivity.dependencies.sessionStoreFactory = { sessionStore } + MainActivity.dependencies.apiKeyStoreFactory = { apiKeyStore } + MainActivity.dependencies.apiClientFactory = { + FakeBoardsApiClient( + boards = mutableListOf(BoardSummary("1", "Alpha")), + templates = emptyList(), + ) + } + + val scenario = ActivityScenario.launch(BoardsActivity::class.java) + + onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open()) + onView(withId(R.id.drawerLogoutButton)).perform(click()) + onView(withText(R.string.drawer_logout)).inRoot(isDialog()).perform(click()) + + Intents.intended(hasComponent(MainActivity::class.java.name)) + assertEquals(null, sessionStore.getBaseUrl()) + assertEquals(null, sessionStore.getWorkspaceId()) + assertEquals(null, sessionStore.getDraftBaseUrl()) + assertEquals(null, sessionStore.getDraftApiKey()) + assertEquals(listOf("https://kan.bn/"), apiKeyStore.invalidatedBaseUrls) + scenario.onActivity { activity -> + assertTrue(activity.isFinishing) + } + } + + @Test + fun logoutConfirmationCancelKeepsUserOnBoards() { + val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1") + val apiKeyStore = InMemoryApiKeyStore("api") + MainActivity.dependencies.sessionStoreFactory = { sessionStore } + MainActivity.dependencies.apiKeyStoreFactory = { apiKeyStore } + MainActivity.dependencies.apiClientFactory = { + FakeBoardsApiClient( + boards = mutableListOf(BoardSummary("1", "Alpha")), + templates = emptyList(), + ) + } + + val scenario = ActivityScenario.launch(BoardsActivity::class.java) + + onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open()) + onView(withId(R.id.drawerLogoutButton)).perform(click()) + onView(withText(R.string.cancel)).inRoot(isDialog()).perform(click()) + + onView(withText("Alpha")).check(matches(isDisplayed())) + assertEquals("https://kan.bn/", sessionStore.getBaseUrl()) + assertEquals("ws-1", sessionStore.getWorkspaceId()) + assertTrue(apiKeyStore.invalidatedBaseUrls.isEmpty()) + scenario.onActivity { activity -> + assertFalse(activity.isFinishing) + } + } + @Test fun settingsDialogOpensFromDrawerAndShowsPreferences() { MainActivity.dependencies.apiClientFactory = { @@ -536,6 +594,9 @@ class BoardsFlowTest { private var baseUrl: String? = null, private var workspaceId: String? = "ws-1", ) : SessionStore { + private var draftBaseUrl: String? = baseUrl + private var draftApiKey: String? = null + override fun getBaseUrl(): String? = baseUrl override fun saveBaseUrl(url: String) { @@ -555,11 +616,29 @@ class BoardsFlowTest { override fun clearWorkspaceId() { workspaceId = null } + + override fun initializeDraftsFromCommitted() { + draftBaseUrl = baseUrl + } + + override fun getDraftBaseUrl(): String? = draftBaseUrl + + override fun saveDraftBaseUrl(url: String) { + draftBaseUrl = url + } + + override fun getDraftApiKey(): String? = draftApiKey + + override fun saveDraftApiKey(apiKey: String) { + draftApiKey = apiKey + } } private class InMemoryApiKeyStore( private var key: String?, ) : ApiKeyStore { + val invalidatedBaseUrls = mutableListOf() + override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result { key = apiKey return Result.success(Unit) @@ -568,6 +647,7 @@ class BoardsFlowTest { override suspend fun getApiKey(baseUrl: String): Result = Result.success(key) override suspend fun invalidateApiKey(baseUrl: String): Result { + invalidatedBaseUrls += baseUrl key = null return Result.success(Unit) } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsActivity.kt index 1070186..5ace07d 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsActivity.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsActivity.kt @@ -178,7 +178,7 @@ class BoardsActivity : AppCompatActivity() { } drawerLogoutButton.setOnClickListener { - forceSignOutToLogin() + showLogoutConfirmation() } drawerLayout.addDrawerListener( @@ -300,7 +300,12 @@ class BoardsActivity : AppCompatActivity() { apiKeyStore.invalidateApiKey(baseUrl) } } + sessionStore.clearBaseUrl() + sessionStore.clearApiKey() sessionStore.clearWorkspaceId() + sessionStore.saveDraftBaseUrl("") + sessionStore.saveDraftApiKey("") + viewModel.clearSessionUiState() val intent = Intent(this@BoardsActivity, MainActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) startActivity(intent) @@ -308,6 +313,16 @@ class BoardsActivity : AppCompatActivity() { } } + private fun showLogoutConfirmation() { + MaterialAlertDialogBuilder(this) + .setMessage(getString(R.string.drawer_logout_confirmation_message)) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.drawer_logout) { _, _ -> + forceSignOutToLogin() + } + .show() + } + private fun showCreateBoardDialog() { val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_create_board, null) val nameLayout: TextInputLayout = dialogView.findViewById(R.id.createBoardNameLayout) diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt index 8d266d9..37bf305 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt @@ -13,6 +13,10 @@ class BoardsRepository( private val apiClient: KanbnApiClient, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { + fun clearSessionCaches() { + // No-op: this repository currently keeps no in-memory session cache. + } + suspend fun loadDrawerData(): DrawerDataResult { val session = when (val sessionResult = authSession()) { is BoardsApiResult.Success -> sessionResult.value diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt index 24810a2..579f1b0 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt @@ -129,6 +129,13 @@ class BoardsViewModel( fetchBoards(initial = false, refresh = true) } + fun clearSessionUiState() { + repository.clearSessionCaches() + lastDrawerLoadAtMillis = null + hadDrawerLoadFailureSinceLastSuccess = false + _uiState.value = BoardsUiState() + } + fun onSettingsApplied(credentialsChanged: Boolean) { if (!credentialsChanged) { return diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09b0ffe..2464e97 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -87,6 +87,7 @@ Settings Workspaces Log out + Log out and clear this device session? Retry Profile unavailable Workspaces unavailable diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt index 1fa488d..da427ce 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt @@ -478,6 +478,33 @@ class BoardsViewModelTest { assertTrue(api.listBoardsCalls >= 1) } + @Test + fun clearSessionUiStateResetsBoardsStateToDefaults() = runTest { + val api = FakeBoardsApiClient().apply { + listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha"))) + usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com")) + workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))) + } + val viewModel = newViewModel(api) + + viewModel.loadBoards() + viewModel.loadDrawerData() + advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.boards.size) + assertEquals("Alice", viewModel.uiState.value.drawer.profile?.displayName) + + viewModel.clearSessionUiState() + + val state = viewModel.uiState.value + assertTrue(state.isInitialLoading) + assertFalse(state.isRefreshing) + assertFalse(state.isMutating) + assertTrue(state.boards.isEmpty()) + assertTrue(state.templates.isEmpty()) + assertFalse(state.isTemplatesLoading) + assertEquals(BoardsDrawerState(), state.drawer) + } + private fun newViewModel( apiClient: FakeBoardsApiClient, sessionStore: InMemorySessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1"),