Compare commits

...

25 Commits

Author SHA1 Message Date
028c05c0c8 Updated AGENTS.md 2026-03-18 14:54:16 -04:00
8b2f14c470 docs: merge full design spec with settings status 2026-03-18 14:52:55 -04:00
ca005a4de7 fix: key stored api keys by base url 2026-03-18 14:30:04 -04:00
fed1c58ae9 docs: strengthen task 9 handoff evidence 2026-03-18 14:12:29 -04:00
3cc5a3e837 docs: add settings implementation handoff note 2026-03-18 14:07:29 -04:00
4d46c49a6d docs: record task 8 verification evidence 2026-03-18 14:00:56 -04:00
81cfbe070d docs: update settings view implementation status 2026-03-18 13:51:40 -04:00
ed98520de7 fix: clear draft settings on logout 2026-03-18 13:40:28 -04:00
542ec5c181 feat: add drawer logout confirmation and session clear flow 2026-03-18 13:38:19 -04:00
769b893959 fix: make settings dialog recreation-safe 2026-03-18 13:32:20 -04:00
f3349f5dee feat: add in-place settings dialog with immediate apply 2026-03-18 12:21:25 -04:00
8d847ae4ea fix: persist settings api key through api key store 2026-03-18 10:04:22 -04:00
24fccc4d7e feat: implement settings apply coordinator with rollback 2026-03-18 09:57:17 -04:00
41bb01e40c chore: set conservative default api key summary 2026-03-18 09:47:41 -04:00
67fa02525c chore: clarify api key summary wording 2026-03-18 09:45:41 -04:00
b199aa62e5 fix: secure api key preference input and summary handling 2026-03-18 09:42:38 -04:00
03a04b82c5 build: add preference dependency and settings resources 2026-03-18 09:37:05 -04:00
3188fc472a fix: refresh drawer after failed load and use rtl-safe drawer close 2026-03-18 09:30:45 -04:00
eeffb3de49 feat: integrate boards drawer interactions and workspace switching 2026-03-18 09:23:23 -04:00
149663662b refactor: centralize unauthorized detection and simplify workspace rollback 2026-03-18 08:54:57 -04:00
d9d751c461 fix: handle workspace-switch unauthorized and rollback edge case 2026-03-18 08:50:07 -04:00
f27aa6969d feat: add boards drawer profile and workspace state flow 2026-03-18 08:46:22 -04:00
2daad8e7ac fix: harden drawer test assertions and workspace state selectors 2026-03-18 08:26:29 -04:00
964da060ce fix: add selected-state styling hook for workspace drawer rows 2026-03-18 08:20:09 -04:00
717c87122d feat: scaffold boards drawer layout and UI resources 2026-03-18 08:16:27 -04:00
36 changed files with 3929 additions and 229 deletions

298
AGENTS.md
View File

@@ -1,174 +1,164 @@
# AGENTS.md
This file provides guidance to AI coding agents when working with code in this repository.
This file is for agentic coding assistants working in this repository.
It captures build/test commands and code conventions observed in the codebase.
## Project Overview
## Project Snapshot
Kanbn4Droid is an unofficial app to connect to and manipulate data stored in self-hosted Kan.bn repositories. The app allows the user to authenticate with a Kan.bn server, display a list of available boards, open boards to edit lists and move cards around, and to create and edit cards. The app is written in Kotlin using mostly standard Android libraries and components and is designed to be compiled and edited through the command line, without Android Studio.
- Name: `Kanbn4Droid`
- Platform: Android app (Kotlin, XML views, coroutines)
- Build system: Gradle Kotlin DSL (`*.gradle.kts`)
- Module layout: single app module `:app`
- Namespace/application id: `space.hackenslacker.kanbn4droid.app`
- Compile/target SDK: 35
- Min SDK: 29
- Java/Kotlin target: 17
## Dependencies
## Environment Prerequisites
- AndroidX Preferences library.
- AndroidX SplashScreen library.
- Kotlin coroutines (Android dispatcher).
- Java 17 installed and available on PATH
- Android SDK command-line tools installed
- Android SDK packages:
- `platforms;android-35`
- `build-tools;35.0.0`
- `platform-tools`
- If SDK is not auto-detected, copy `local.properties.example` to `local.properties` and set `sdk.dir`
## Current bootstrap status
## Source of Truth for Commands
- Build system: Gradle Kotlin DSL with version catalog (`gradle/libs.versions.toml`).
- Wrapper: Gradle 8.11.1.
- Android plugin and language: AGP 8.9.2, Kotlin 2.1.20, Java 17 target.
- Module layout: single Android app module at `app/`.
- Namespace and application id: `space.hackenslacker.kanbn4droid.app`.
- Minimum SDK: API 29.
- Compile/target SDK: API 35.
- Baseline tests:
- JVM unit tests for auth URL normalization and auth error mapping in `app/src/test/`.
- JVM unit tests for boards repository and boards view model in `app/src/test/space/hackenslacker/kanbn4droid/app/boards/`.
- Instrumentation tests for login and boards flows in `app/src/androidTest/`.
- Prefer Gradle wrapper (`./gradlew`) over system Gradle
- Most Android tasks should be run with explicit module prefix when possible (`:app:<task>`)
- Discover tasks with: `./gradlew tasks --all`
## Command-line workflow
## Build / Lint / Test Commands
- List tasks: `./gradlew tasks`
- Run unit tests: `./gradlew test`
- Build debug APK: `./gradlew assembleDebug`
- Install debug APK: `./gradlew installDebug`
- Run instrumentation tests: `./gradlew connectedDebugAndroidTest`
### Core Daily Commands
`installDebug` and `connectedDebugAndroidTest` require a connected device or emulator.
- List tasks: `./gradlew tasks --all`
- Clean: `./gradlew clean`
- Build everything in app module: `./gradlew :app:build`
- Build debug APK: `./gradlew :app:assembleDebug`
- Install debug APK on connected device/emulator: `./gradlew :app:installDebug`
## Architecture
### Lint
**Splash screen**
- The app displays a standard Android splash screen when open from a cold start.
- Current status: implemented through `Theme.Kanbn4Droid.Splash` with a temporary placeholder image resource at `app/src/main/res/drawable/splash_placeholder.xml`.
- Run default lint: `./gradlew :app:lint`
- Run debug lint explicitly: `./gradlew :app:lintDebug`
- Run release lint explicitly: `./gradlew :app:lintRelease`
- Auto-fix safe lint issues: `./gradlew :app:lintFix`
**Login view**
- It's the first screen the user sees when opening the app if no login has been successfully stored so far.
- The view has fields to request the user for an instance base URL (http or https, with or without port number) and an API key.
- The default base URL is that of the standard Kan.bn instance at https://kan.bn/
- The view tries to check connection with the server at the given base URL using the Kan.bn API healthcheck endpoint.
- The API key is stored in app preferences together with the base URL.
- No migration is performed from prior Credential Manager storage, so users must re-enter their API key one time after upgrading.
- On success, the view stores the URL and API key pair in preferences and moves over to the boards view.
- On successful manual sign-in, the stored workspace id is cleared so the boards flow can resolve a fresh default workspace for the account.
- If there is a URL and API Key pair stored, the view tries to authenticate the user through the API automatically and proceeds to the boards view instantly without showing the login screen if successful.
- If startup authentication fails due to invalid credentials then the stored API key is invalidated; transient connectivity/server failures keep the stored key and return to login.
- Current status: implemented in `MainActivity` with XML views and navigation into `BoardsActivity`.
### Unit Tests (JVM)
**Boards list view**
- Displays a list of boards as rounded-square cards with the board's title centered in it.
- Clicking on a board card moves on to that board's detail view.
- The boards list is refreshed automatically when entering the view.
- The boards list can be refreshed manually with a pull-down gesture on the list.
- The view has a floating + button at the bottom right that shows a modal dialog for creating a new board.
- The modal board creation dialog requests the user for a board name.
- The modal board creation dialog has a toggleable pill button labeled "Use template".
- Enabling the "Use template" button shows a list of available templates to use when creating the board.
- The list of templates is obtained through the Kan.bn API.
- The modal board creation dialog has two buttons at the bottom right for "Cancel" and "Create" respectively.
- Board creation is done through the Kan.bn API.
- On success creating a board moves on to that new board's detal view immediately.
- On failure creating a board shows a modal dialog with the server's reported cause of failure and an OK button.
- Long-pressing an existing board shows a modal dialog asking the user if they want to delete the board, with buttons for "Cancel" and "Delete".
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
- Current status: implemented in `BoardsActivity` using XML Views, `RecyclerView`, `SwipeRefreshLayout`, and a `BoardsViewModel`/`BoardsRepository` flow. The screen includes auto-refresh on entry, pull-to-refresh, create board dialog with optional template selector, and two-step board delete confirmation. Board taps navigate to `BoardDetailActivity`. API calls are workspace-scoped; when no workspace is stored the app resolves workspaces through the API, stores the first workspace id as default, and uses it for board listing, template listing (`type=template`), and board creation.
- Run all unit tests (all variants): `./gradlew :app:test`
- Run debug unit tests: `./gradlew :app:testDebugUnitTest`
- Run release unit tests: `./gradlew :app:testReleaseUnitTest`
**Board detail view**
- The board detail view shows the lists in the board as vertical lists.
- Each list has it's title at the top.
- Clicking on the title of a list allows editing the title.
- Below the title each card is shown in a vertically scrolling list of rounded-square shaped cards with one entry for each card in the list.
- Each card contains it's title in bold font at the top, a list of the card's tags and it's due date if available, in that order.
- The tag list of a card is a series of pill-shaped labels with the tag's name on them and a colored border with the color of the tag.
- The due date of the card is shown in black (or light in dark mode) text if it's still valid, or in red text if it's expired.
- The due date MUST be formatted in the system's locale.
- Swiping right or left on a list allows the user to change to the next or previous list in the board respectively if any.
- On reaching the first or last list in the board it's no longer possible to keep swiping in that direction.
- Long-pressing a card in a list allows selecting that card.
- Tapping other cards in the same or other lists selects them as well.
- Tapping an already selected card deselects it.
- When one or more cards are selected, the top bar of the application MUST display the following buttons using the indicated icon for each one, without text: "Select all" (a 4x4 square grid), "Move cards" (a double-ended left-right arrow), "Delete cards" (a trash can).
- Tapping the "Select all" button selects all cards in the list that is being shown to the user, NOT all cards in all available lists.
- Tapping the "Move cards" button shows a modal dialog that asks the user to what list does he want to move the cards.
- This modal dialog shows a selector with all the lists available in the current board.
- This modal dialog has two buttons at the bottom for "Cancel" and "Move"
- Tapping the "Delete cards" button asks the user for confirmation to delete the cards. This confirmation dialog has two buttons at the bottom for "Cancel" and "Delete".
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
- Long-pressing any of the buttons must show a tooltip with the button name.
- The view has a floating + button that allows choosing between two options: "Add new list", "Add new card"
- The Add new list option shows a modal dialog that asks for a list title. The modal dialog has two buttons at the bottom for "Cancel" and "Create"
- The list is created using the Kan.bn API.
- The new list is added at the end of the lists in the current board after the current last list.
- The Add new card dialog shows a modal dialog that works as follows:
- The modal dialog has a field for the card's name. This field is mandatory
- Below the card name field there is a markdown-enabled text area for an optional card description.
- Below the card description field there is an optional date field to set the card's due date.
- Below the card's due date field there is an optional multi-value selector that allows choosing the card's tags from the tags available for the current board.
- The new card is created using the Kan.bn API.
- The new card is added to the top of the currently shown list.
- The title bar of the view has two icon-only buttons for "Filter by tag" (icon is three bars of decreasing width, widest on top) and "Search" (icon is a leaning looking glass)
- The filter by tag button opens a modal dialog that shows a multi-value selector that allows choosing from the tags available on the current board. The modal has a title that says "Filter by tag". The modal has buttons for "Cancel" and "Filter".
- The search button a modal dialog that shows a text field that has the placeholder value "Search". The modal has a title that seas "Search by title". The modal has buttons for "Cancel" and "Search".
- Applying a filter or search makes the active board show only the cards that match the given criteria (selected tags or matching title).
- The filters are applied locally without contacting the server.
- The search by title filter matches any part of the title. Example: searching for "Duke" matches "Duke Nukem" as well as "Nukem Duke"
- When a filter by tag or search is applied the corresponding button in the title bar gets highlighted.
- Tapping on the filter by tag or search buttonswhen either of them is applied disables the active filter.
- When a card(s) is selected by a long press, the filter by tag and search buttons get hidden by the select all, move card and delete card buttons until all cards are deselected.
- When a card(s) is selected by a long press, the back arrow in the title bar and the back system button remove all selections.
- Current status: implemented in `BoardDetailActivity` with `ViewPager2` (one list per page), inline list-title edit, and card rendering (title/tags/due date locale formatting and expiry color).
- FAB flows are implemented for both add-list and add-card dialogs.
- Filter/search behavior is local (no server roundtrip), and active filter/search icons are highlighted.
- Cross-page card selection is implemented. In selection mode, toolbar actions are replaced (filter/search hidden; select-all/move/delete shown).
- Select-all is page-scoped, move uses a list selector dialog, and delete uses two-step confirmation.
- Back handling clears selection from both the top-bar back arrow and the system back button before navigation.
- The screen includes mutation guards while in progress and API-backed reload/reconciliation behavior through `BoardDetailViewModel` and `BoardDetailRepository`.
- Card move requests try these variants for Kan.bn API compatibility: `PUT /api/v1/cards/{cardPublicId}` with `listPublicId`, then GET+full-body `PUT /api/v1/cards/{cardPublicId}` payload (`title`, `description`, `index`, `listPublicId`, `dueDate`), then `PUT /api/v1/cards/{cardPublicId}` with `listId`, then `PATCH /api/v1/cards/{cardPublicId}` with `listId`.
- Board detail parsing prefers public ids (`publicId`/`public_id`) over internal `id` values so follow-up card/list mutations target correct API identifiers.
- Label chip border colors are hydrated from Kan.bn `Get a label by public ID` (`colourCode`) and cached in-memory by `BoardDetailRepository` so each label color is fetched only once per app process.
- Filter/search toolbar icons use local vector drawables (`ic_filter_list_24`, `ic_search_24`) with day/night variants so dark mode uses light icon fills automatically.
- Selection action icons use local vector drawables (`ic_select_all_grid_24`, `ic_move_cards_horizontal_24`, `ic_delete_24`) with day/night variants so dark mode uses light icon fills automatically.
- Startup blocking dialogs are shown for missing board id and missing session.
### Run a Single Unit Test (important)
**Card detail view**
- The view shows the card's title in bold letters. Tapping on the card's title allows editing it.
- Below the title a horizontally scrollable there is a list of the card's tags, shown as pills with the border color set to the tag's color obtained from the Kan.bn API.
- Below the tags there is a date field showing the card's due date, if any, or the option to set a due date.
- If the due date is set and is still valid then it is shown in black (or light in dark mode) text.
- If the due date is set and is expired, then it is shown in red text.
- Below the due date the view shows the card's description if any in a markdown-enabled, editable text field.
- Below the description the app shows the latest 10 elements of the card's edit history (named card activities in the Kan.bn documentation) obtained from the Kan.bn API, in newest-first order.
- The view has a floating + button that shows a modal dialog that allows adding a comment to the card's history using the Kan.bn API.
- The modal dialog has "Add comment" as a title.
- The modal dialog has an editable markdown-enabled text field for the comment.
- The modal dialog has two buttons at the bottom on the right side for "Cancel" and "Add" respectively.
- Current status: fully implemented in `CardDetailActivity` with API-backed load and mutation flows through `CardDetailViewModel` and `CardDetailRepository`.
- Title, description, and due date support live-save behavior with field-level saving indicators, inline errors, and retry actions.
- Description supports markdown editing and preview toggle; the add-comment dialog also supports markdown edit/preview and submits through the API, then refreshes timeline data.
- Timeline is rendered as a vertical activity feed with relative timestamps, comment/update action text, markdown-rendered comment bodies, loading/error/empty states, and newest-first ordering.
- Board-detail card taps navigate directly to `CardDetailActivity`; the placeholder card-detail route/activity has been removed.
- Verification note: `./gradlew test`, `./gradlew assembleDebug`, and `./gradlew connectedDebugAndroidTest` are currently passing.
- Single test class:
- `./gradlew :app:testDebugUnitTest --tests "space.hackenslacker.kanbn4droid.app.boards.BoardsViewModelTest"`
- Single test method:
- `./gradlew :app:testDebugUnitTest --tests "space.hackenslacker.kanbn4droid.app.boards.BoardsViewModelTest.createBoardSuccessEmitsNavigateEvent"`
- Pattern match within class/package:
- `./gradlew :app:testDebugUnitTest --tests "space.hackenslacker.kanbn4droid.app.boarddetail.*"`
**Settings view**
- The view shows a list of settings that can be changed by the user. The following settings are available:
- Theme (selector with three choices: Light, Dark, Follow System)
- Base URL (editable text field)
- API Key (editable password text field with obfuscated characters)
- Logout (single button that shows a modal dialog asking the user if it's OK to sign out)
- All settings are managed using the AndroidX Preferences library.
- Signing out clears the stored API key and app cache and returns the user to the login screen.
### Instrumentation Tests (device/emulator required)
## Considerations
- Run all connected debug instrumentation tests:
- `./gradlew :app:connectedDebugAndroidTest`
- Equivalent aggregate task:
- `./gradlew :app:connectedAndroidTest`
- All views support light or dark theme following the system's indicated preference by default.
- The app's default accent color is obtained from the system's theme.
- The app's design follows Material You guidelines.
- Every new feature MUST include a full set of tests, using the standard Android SDK testing framework.
- The minimum supported Android version is Android 10 (API level 29)
- It is preferable to use standard Android libraries and tools whenever possible.
- If a task would be better served by or can only be done with external libraries then it is allowed to use them but you MUST ask the user for permission to add the library to the project first.
- The documentation for the Kan.bn API is available here https://docs.kan.bn/api-reference/introduction
- Never push code unless explicitely prompted to do so.
- After every run, update this file as needed to reflect the changes made.
### Run a Single Instrumentation Test (important)
- Single instrumentation test class:
- `./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=space.hackenslacker.kanbn4droid.app.BoardsFlowTest`
- Single instrumentation test method:
- `./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=space.hackenslacker.kanbn4droid.app.BoardsFlowTest#pullToRefreshWorks`
## Repository-Local Rules (Cursor/Copilot)
- Checked for Cursor rules in `.cursor/rules/` and `.cursorrules`: none found
- Checked for Copilot instructions in `.github/copilot-instructions.md`: none found
- Therefore, no additional repo-local AI instruction files are currently enforced beyond this document
- If those files are added later, treat them as higher-priority guidance and update this AGENTS.md
## Architecture and Code Organization
- UI is Activity-driven (not Compose)
- Feature folders under `app/src/main/java/space/hackenslacker/kanbn4droid/app/`: `auth/`, `boards/`, `boarddetail/`, `carddetail/`
- Typical layering:
- Activity handles view binding and user interaction wiring
- ViewModel holds UI state and events (`StateFlow` + `SharedFlow`)
- Repository/data source handles session and API calls
- API client encapsulates HTTP + response parsing
## Kotlin Style Guidelines (observed conventions)
### Formatting
- Follow Kotlin official code style (`gradle.properties: kotlin.code.style=official`)
- Use 4-space indentation
- Use trailing commas in multiline argument lists/constructors where already used
- Keep functions focused; prefer extraction for repeated logic
- Avoid unnecessary comments; use clear naming instead
### Imports
- Do not use wildcard imports
- Keep imports grouped (platform/library/project), matching existing file style
- Remove unused imports
- Prefer explicit imports for readability in large files
### Types and API design
- Prefer immutable `val`; use `var` only when mutation is required
- Use data classes for immutable UI/domain models
- Use sealed interfaces/classes for event/result hierarchies (`BoardsUiEvent`, mutation results)
- Keep nullability explicit; avoid nullable types unless required by API/domain
- Return domain/result wrappers instead of throwing for expected failures
### Naming
- Packages: lowercase, dot-separated (`space.hackenslacker...`)
- Classes/interfaces/objects: PascalCase
- Functions/properties/locals: camelCase
- Constants: UPPER_SNAKE_CASE (`private const val ...`)
- Test method names: descriptive camelCase phrases that state behavior
### Coroutines and threading
- Use `viewModelScope.launch` for ViewModel async work
- Use `withContext(Dispatchers.IO)` or injected IO dispatcher for blocking/network work
- Keep dispatcher injection points testable (repositories already do this)
- Avoid blocking calls on main thread
### State and events
- Model screen state as immutable `data class` in ViewModel
- Update state via copy semantics (`state.copy(...)`), often with `MutableStateFlow.update`
- Use one-off events via `SharedFlow` rather than state flags for navigation/toasts/dialog alerts
- Keep rendering deterministic from current state
### Error handling
- Prefer explicit result types (`BoardsApiResult`, `AuthResult`) over exceptions for normal failure paths
- Surface user-visible messages through UI events/state
- Validate user input early (blank checks, URL normalization) before network calls
### Android/UI conventions
- Keep user-facing text in `res/values/strings.xml` when practical
- Use Material components already present in the project
- Keep Activity responsibilities focused on binding and orchestration, not business logic
- Respect existing navigation via explicit intents and extras constants
## Testing Conventions
- Unit tests live in `app/src/test/...` and use JUnit4 + coroutines test utilities
- Instrumentation tests live in `app/src/androidTest/...` and use Espresso/Intents
- Prefer fakes/in-memory stores for API/session dependencies in tests
- In coroutine tests:
- set/reset main dispatcher in `@Before/@After`
- use `runTest` and `advanceUntilIdle()`
- Keep tests behavior-focused (arrange -> act -> assert)

190
DESIGN.md Normal file
View File

@@ -0,0 +1,190 @@
# Project Overview
Kanbn4Droid is an unofficial app to connect to and manipulate data stored in self-hosted Kan.bn repositories. The app allows the user to authenticate with a Kan.bn server, display a list of available boards, open boards to edit lists and move cards around, and to create and edit cards. The app is written in Kotlin using mostly standard Android libraries and components and is designed to be compiled and edited through the command line, without Android Studio.
## Dependencies
- AndroidX Preferences library.
- AndroidX SplashScreen library.
- Kotlin coroutines (Android dispatcher).
## Current bootstrap status
- Build system: Gradle Kotlin DSL with version catalog (`gradle/libs.versions.toml`).
- Wrapper: Gradle 8.11.1.
- Android plugin and language: AGP 8.9.2, Kotlin 2.1.20, Java 17 target.
- Module layout: single Android app module at `app/`.
- Namespace and application id: `space.hackenslacker.kanbn4droid.app`.
- Minimum SDK: API 29.
- Compile/target SDK: API 35.
- Baseline tests:
- JVM unit tests for auth URL normalization and auth error mapping in `app/src/test/`.
- JVM unit tests for boards repository and boards view model in `app/src/test/space/hackenslacker/kanbn4droid/app/boards/`.
- Instrumentation tests for login and boards flows in `app/src/androidTest/`.
## Command-line workflow
- List tasks: `./gradlew tasks`
- Run unit tests: `./gradlew test`
- Build debug APK: `./gradlew assembleDebug`
- Install debug APK: `./gradlew installDebug`
- Run instrumentation tests: `./gradlew connectedDebugAndroidTest`
`installDebug` and `connectedDebugAndroidTest` require a connected device or emulator.
## Architecture
**Splash screen**
- The app displays a standard Android splash screen when open from a cold start.
- Current status: implemented through `Theme.Kanbn4Droid.Splash` with a temporary placeholder image resource at `app/src/main/res/drawable/splash_placeholder.xml`.
**Login view**
- It's the first screen the user sees when opening the app if no login has been successfully stored so far.
- The view has fields to request the user for an instance base URL (http or https, with or without port number) and an API key.
- The default base URL is that of the standard Kan.bn instance at https://kan.bn/
- The view tries to check connection with the server at the given base URL using the Kan.bn API healthcheck endpoint.
- The API key is stored in app preferences together with the base URL.
- No migration is performed from prior Credential Manager storage, so users must re-enter their API key one time after upgrading.
- On success, the view stores the URL and API key pair in preferences and moves over to the boards view.
- On successful manual sign-in, the stored workspace id is cleared so the boards flow can resolve a fresh default workspace for the account.
- If there is a URL and API Key pair stored, the view tries to authenticate the user through the API automatically and proceeds to the boards view instantly without showing the login screen if successful.
- If startup authentication fails due to invalid credentials then the stored API key is invalidated; transient connectivity/server failures keep the stored key and return to login.
- Current status: implemented in `MainActivity` with XML views and navigation into `BoardsActivity`.
**Boards list view**
- Displays a list of boards as rounded-square cards with the board's title centered in it.
- Clicking on a board card moves on to that board's detail view.
- The boards list is refreshed automatically when entering the view.
- The boards list can be refreshed manually with a pull-down gesture on the list.
- The view has a floating + button at the bottom right that shows a modal dialog for creating a new board.
- The modal board creation dialog requests the user for a board name.
- The modal board creation dialog has a toggleable pill button labeled "Use template".
- Enabling the "Use template" button shows a list of available templates to use when creating the board.
- The list of templates is obtained through the Kan.bn API.
- The modal board creation dialog has two buttons at the bottom right for "Cancel" and "Create" respectively.
- Board creation is done through the Kan.bn API.
- On success creating a board moves on to that new board's detal view immediately.
- On failure creating a board shows a modal dialog with the server's reported cause of failure and an OK button.
- Long-pressing an existing board shows a modal dialog asking the user if they want to delete the board, with buttons for "Cancel" and "Delete".
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
- Current status: implemented in `BoardsActivity` using XML Views, `RecyclerView`, `SwipeRefreshLayout`, and a `BoardsViewModel`/`BoardsRepository` flow. The screen includes auto-refresh on entry, pull-to-refresh, create board dialog with optional template selector, and two-step board delete confirmation. Board taps navigate to `BoardDetailActivity`. API calls are workspace-scoped; when no workspace is stored the app resolves workspaces through the API, stores the first workspace id as default, and uses it for board listing, template listing (`type=template`), and board creation.
**Board detail view**
- The board detail view shows the lists in the board as vertical lists.
- Each list has it's title at the top.
- Clicking on the title of a list allows editing the title.
- Below the title each card is shown in a vertically scrolling list of rounded-square shaped cards with one entry for each card in the list.
- Each card contains it's title in bold font at the top, a list of the card's tags and it's due date if available, in that order.
- The tag list of a card is a series of pill-shaped labels with the tag's name on them and a colored border with the color of the tag.
- The due date of the card is shown in black (or light in dark mode) text if it's still valid, or in red text if it's expired.
- The due date MUST be formatted in the system's locale.
- Swiping right or left on a list allows the user to change to the next or previous list in the board respectively if any.
- On reaching the first or last list in the board it's no longer possible to keep swiping in that direction.
- Long-pressing a card in a list allows selecting that card.
- Tapping other cards in the same or other lists selects them as well.
- Tapping an already selected card deselects it.
- When one or more cards are selected, the top bar of the application MUST display the following buttons using the indicated icon for each one, without text: "Select all" (a 4x4 square grid), "Move cards" (a double-ended left-right arrow), "Delete cards" (a trash can).
- Tapping the "Select all" button selects all cards in the list that is being shown to the user, NOT all cards in all available lists.
- Tapping the "Move cards" button shows a modal dialog that asks the user to what list does he want to move the cards.
- This modal dialog shows a selector with all the lists available in the current board.
- This modal dialog has two buttons at the bottom for "Cancel" and "Move"
- Tapping the "Delete cards" button asks the user for confirmation to delete the cards. This confirmation dialog has two buttons at the bottom for "Cancel" and "Delete".
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
- Long-pressing any of the buttons must show a tooltip with the button name.
- The view has a floating + button that allows choosing between two options: "Add new list", "Add new card"
- The Add new list option shows a modal dialog that asks for a list title. The modal dialog has two buttons at the bottom for "Cancel" and "Create"
- The list is created using the Kan.bn API.
- The new list is added at the end of the lists in the current board after the current last list.
- The Add new card dialog shows a modal dialog that works as follows:
- The modal dialog has a field for the card's name. This field is mandatory
- Below the card name field there is a markdown-enabled text area for an optional card description.
- Below the card description field there is an optional date field to set the card's due date.
- Below the card's due date field there is an optional multi-value selector that allows choosing the card's tags from the tags available for the current board.
- The new card is created using the Kan.bn API.
- The new card is added to the top of the currently shown list.
- The title bar of the view has two icon-only buttons for "Filter by tag" (icon is three bars of decreasing width, widest on top) and "Search" (icon is a leaning looking glass)
- The filter by tag button opens a modal dialog that shows a multi-value selector that allows choosing from the tags available on the current board. The modal has a title that says "Filter by tag". The modal has buttons for "Cancel" and "Filter".
- The search button a modal dialog that shows a text field that has the placeholder value "Search". The modal has a title that seas "Search by title". The modal has buttons for "Cancel" and "Search".
- Applying a filter or search makes the active board show only the cards that match the given criteria (selected tags or matching title).
- The filters are applied locally without contacting the server.
- The search by title filter matches any part of the title. Example: searching for "Duke" matches "Duke Nukem" as well as "Nukem Duke"
- When a filter by tag or search is applied the corresponding button in the title bar gets highlighted.
- Tapping on the filter by tag or search buttonswhen either of them is applied disables the active filter.
- When a card(s) is selected by a long press, the filter by tag and search buttons get hidden by the select all, move card and delete card buttons until all cards are deselected.
- When a card(s) is selected by a long press, the back arrow in the title bar and the back system button remove all selections.
- Current status: implemented in `BoardDetailActivity` with `ViewPager2` (one list per page), inline list-title edit, and card rendering (title/tags/due date locale formatting and expiry color).
- FAB flows are implemented for both add-list and add-card dialogs.
- Filter/search behavior is local (no server roundtrip), and active filter/search icons are highlighted.
- Cross-page card selection is implemented. In selection mode, toolbar actions are replaced (filter/search hidden; select-all/move/delete shown).
- Select-all is page-scoped, move uses a list selector dialog, and delete uses two-step confirmation.
- Back handling clears selection from both the top-bar back arrow and the system back button before navigation.
- The screen includes mutation guards while in progress and API-backed reload/reconciliation behavior through `BoardDetailViewModel` and `BoardDetailRepository`.
- Card move requests try these variants for Kan.bn API compatibility: `PUT /api/v1/cards/{cardPublicId}` with `listPublicId`, then GET+full-body `PUT /api/v1/cards/{cardPublicId}` payload (`title`, `description`, `index`, `listPublicId`, `dueDate`), then `PUT /api/v1/cards/{cardPublicId}` with `listId`, then `PATCH /api/v1/cards/{cardPublicId}` with `listId`.
- Board detail parsing prefers public ids (`publicId`/`public_id`) over internal `id` values so follow-up card/list mutations target correct API identifiers.
- Label chip border colors are hydrated from Kan.bn `Get a label by public ID` (`colourCode`) and cached in-memory by `BoardDetailRepository` so each label color is fetched only once per app process.
- Filter/search toolbar icons use local vector drawables (`ic_filter_list_24`, `ic_search_24`) with day/night variants so dark mode uses light icon fills automatically.
- Selection action icons use local vector drawables (`ic_select_all_grid_24`, `ic_move_cards_horizontal_24`, `ic_delete_24`) with day/night variants so dark mode uses light icon fills automatically.
- Startup blocking dialogs are shown for missing board id and missing session.
**Card detail view**
- The view shows the card's title in bold letters. Tapping on the card's title allows editing it.
- Below the title a horizontally scrollable there is a list of the card's tags, shown as pills with the border color set to the tag's color obtained from the Kan.bn API.
- Below the tags there is a date field showing the card's due date, if any, or the option to set a due date.
- If the due date is set and is still valid then it is shown in black (or light in dark mode) text.
- If the due date is set and is expired, then it is shown in red text.
- Below the due date the view shows the card's description if any in a markdown-enabled, editable text field.
- Below the description the app shows the latest 10 elements of the card's edit history (named card activities in the Kan.bn documentation) obtained from the Kan.bn API, in newest-first order.
- The view has a floating + button that shows a modal dialog that allows adding a comment to the card's history using the Kan.bn API.
- The modal dialog has "Add comment" as a title.
- The modal dialog has an editable markdown-enabled text field for the comment.
- The modal dialog has two buttons at the bottom on the right side for "Cancel" and "Add" respectively.
- Current status: fully implemented in `CardDetailActivity` with API-backed load and mutation flows through `CardDetailViewModel` and `CardDetailRepository`.
- Title, description, and due date support live-save behavior with field-level saving indicators, inline errors, and retry actions.
- Description supports markdown editing and preview toggle; the add-comment dialog also supports markdown edit/preview and submits through the API, then refreshes timeline data.
- Timeline is rendered as a vertical activity feed with relative timestamps, comment/update action text, markdown-rendered comment bodies, loading/error/empty states, and newest-first ordering.
- Board-detail card taps navigate directly to `CardDetailActivity`; the placeholder card-detail route/activity has been removed.
- Verification note: `./gradlew test`, `./gradlew assembleDebug`, and `./gradlew connectedDebugAndroidTest` are currently passing.
**Settings view**
- The view is available from a side panel that can be shown in the Boards list view by pulling in from the left side of the screen.
- The side panel only occupies up to a third of the screen.
- The view behind the side panel dims when the side panel is open.
- The side panel shows the following content from top to bottom:
- Username as a title in bold text of the currently active user obtained with the "users/me" endpoint of the Kan.bn API.
- Email of the currently active user.
- A button with a gear as icon and the label "Settings"
- Clicking on the "Settings" button shows the settings view as described ahead.
- The word "Workspaces" as a smaller title.
- A vertically scrollable list of workspace names available on the server.
- The currently active workspace is highlighted
- Clicking on a different workspace sets that as the active workspace and reloads the Boards list view to refresh the available boards.
- At the bottom of the side panel there is a logout button that shows a modal dialog asking the user if it's OK to sign out.
- Signing out clears the stored API key and app cache and returns the user to the login screen.
- The settings view shows a list of settings that can be changed by the user. The following settings are available:
- Theme (selector with three choices: Light, Dark, Follow System)
- Base URL (editable text field)
- API Key (editable password text field with obfuscated characters)
- All settings are managed using the AndroidX Preferences library.
- Changing any settings makes it apply instantly when leaving the settings view without logging out.
- Current status: implemented through a left-side drawer in `BoardsActivity` plus an in-place settings dialog (`SettingsDialogFragment` + `SettingsPreferencesFragment`) using AndroidX Preferences.
- Drawer behavior is implemented with `DrawerLayout`: left-edge gesture + toolbar open action, dimmed background, and runtime width capped to one-third of screen width.
- Drawer content is implemented with profile header (`users/me`), workspaces list with active highlight, settings entry, retry/error states, and logout action with confirmation.
- Workspace switching is implemented with active-workspace persistence and boards refresh; switch failures restore previous selection and unauthorized responses force sign-out.
- Settings dialog implements Theme/Base URL/API key drafts, save-and-close apply flow, immediate theme application, credential re-auth on URL/key changes, and safe rollback on apply failure.
- Logout clears session/auth/workspace state and returns to login.
- Verification note: `./gradlew :app:testDebugUnitTest` and `./gradlew :app:assembleDebug` are passing locally; `./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=space.hackenslacker.kanbn4droid.app.BoardsFlowTest` is currently blocked in this environment due to no connected device.
## Considerations
- All views support light or dark theme following the system's indicated preference by default.
- The app's default accent color is obtained from the system's theme.
- The app's design follows Material You guidelines.
- Every new feature MUST include a full set of tests, using the standard Android SDK testing framework.
- The minimum supported Android version is Android 10 (API level 29)
- It is preferable to use standard Android libraries and tools whenever possible.
- If a task would be better served by or can only be done with external libraries then it is allowed to use them but you MUST ask the user for permission to add the library to the project first.
- The documentation for the Kan.bn API is available here https://docs.kan.bn/api-reference/introduction
- Never push code unless explicitely prompted to do so.
- After every run, update this file as needed to reflect the changes made.

View File

@@ -53,6 +53,7 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.preference)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.commonmark)

View File

@@ -2,6 +2,13 @@ package space.hackenslacker.kanbn4droid.app
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.ViewAction
import androidx.test.espresso.contrib.DrawerActions
import androidx.test.espresso.action.CoordinatesProvider
import androidx.test.espresso.action.GeneralSwipeAction
import androidx.test.espresso.action.Press
import androidx.test.espresso.action.Swipe
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.longClick
import androidx.test.espresso.action.ViewActions.replaceText
@@ -12,11 +19,21 @@ import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CompletableDeferred
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -24,11 +41,18 @@ import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.LabelDetail
import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyCoordinator
import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyResult
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
import space.hackenslacker.kanbn4droid.app.boards.DrawerProfile
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary
import space.hackenslacker.kanbn4droid.app.settings.SettingsDialogFragment
import java.util.ArrayDeque
import android.view.View
import android.widget.TextView
@RunWith(AndroidJUnit4::class)
class BoardsFlowTest {
@@ -117,9 +141,462 @@ class BoardsFlowTest {
onView(withText("Alpha")).check(matches(isDisplayed()))
}
@Test
fun drawerOpensAndShowsWorkspaceSection() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withId(R.id.drawerWorkspacesTitle)).check(matches(isDisplayed()))
onView(withId(R.id.drawerWorkspacesTitle)).check(matches(withText(R.string.drawer_workspaces)))
onView(withId(R.id.drawerWorkspacesRecyclerView)).check(matches(isDisplayed()))
onView(withId(R.id.drawerLogoutButton)).check(matches(isDisplayed()))
}
@Test
fun workspaceSelectionHighlightsAndRefreshesBoards() {
val fake = MultiWorkspaceFakeBoardsApiClient(
boardsByWorkspace = mapOf(
"ws-1" to listOf(BoardSummary("1", "Alpha")),
"ws-2" to listOf(BoardSummary("2", "Beta")),
),
)
MainActivity.dependencies.apiClientFactory = { fake }
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1") }
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withText("Platform")).perform(click())
onView(withText("Beta")).check(matches(isDisplayed()))
onView(withText("Alpha")).check(doesNotExist())
scenario.onActivity { activity ->
val recycler = activity.findViewById<RecyclerView>(R.id.drawerWorkspacesRecyclerView)
val hasActivatedPlatform = (0 until recycler.childCount)
.map { recycler.getChildAt(it) }
.any { row ->
val title = row.findViewById<TextView>(R.id.workspaceTitleText).text.toString()
title == "Platform" && row.isActivated
}
assertTrue(hasActivatedPlatform)
}
assertTrue(fake.listBoardsWorkspaceCalls.contains("ws-2"))
}
@Test
fun drawerOpensFromLeftEdgeGesture() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(swipeFromLeftEdge())
scenario.onActivity { activity ->
val drawer = activity.findViewById<DrawerLayout>(R.id.boardsDrawerLayout)
assertTrue(drawer.isDrawerOpen(GravityCompat.START))
}
}
@Test
fun drawerRetryButtonReloadsAfterRecoverableFailure() {
val fake = MultiWorkspaceFakeBoardsApiClient(
boardsByWorkspace = mapOf("ws-1" to listOf(BoardSummary("1", "Alpha"))),
usersMeResponses = ArrayDeque(
listOf(
BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."),
BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com")),
),
),
workspaceResponses = ArrayDeque(
listOf(
BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."),
BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"), WorkspaceSummary("ws-2", "Platform"))),
),
),
)
MainActivity.dependencies.apiClientFactory = { fake }
ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withId(R.id.drawerRetryButton)).check(matches(isDisplayed())).perform(click())
onView(withText("Main")).check(matches(isDisplayed()))
assertTrue(fake.listWorkspacesCalls >= 2)
}
@Test
fun drawerUnauthorizedForcesSignOutToLogin() {
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val fake = MultiWorkspaceFakeBoardsApiClient(
boardsByWorkspace = mapOf("ws-1" to listOf(BoardSummary("1", "Alpha"))),
usersMeResponses = ArrayDeque(listOf(BoardsApiResult.Failure("Server error: 401"))),
workspaceResponses = ArrayDeque(listOf(BoardsApiResult.Failure("Server error: 401"))),
)
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
MainActivity.dependencies.apiClientFactory = { fake }
ActivityScenario.launch(BoardsActivity::class.java)
Intents.intended(hasComponent(MainActivity::class.java.name))
assertEquals(null, sessionStore.getWorkspaceId())
}
@Test
fun workspaceSelectionClosesDrawerAfterSuccess() {
val fake = MultiWorkspaceFakeBoardsApiClient(
boardsByWorkspace = mapOf(
"ws-1" to listOf(BoardSummary("1", "Alpha")),
"ws-2" to listOf(BoardSummary("2", "Beta")),
),
)
MainActivity.dependencies.apiClientFactory = { fake }
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1") }
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withText("Platform")).perform(click())
onView(withText("Beta")).check(matches(isDisplayed()))
scenario.onActivity { activity ->
val drawer = activity.findViewById<DrawerLayout>(R.id.boardsDrawerLayout)
assertFalse(drawer.isDrawerOpen(GravityCompat.START))
}
}
@Test
fun drawerWidthNeverExceedsOneThirdOfScreen() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
scenario.onActivity { activity ->
val drawerContent = activity.findViewById<View>(R.id.boardsDrawerContent)
val displayWidthPx = activity.resources.displayMetrics.widthPixels
assertTrue(drawerContent.layoutParams.width <= displayWidthPx / 3)
}
}
@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 = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
onView(withText(R.string.settings_theme_title)).check(matches(isDisplayed()))
onView(withText(R.string.settings_base_url_title)).check(matches(isDisplayed()))
onView(withText(R.string.settings_api_key_title)).check(matches(isDisplayed()))
onView(withId(R.id.settingsSaveAndCloseButton)).check(matches(isDisplayed()))
}
@Test
fun settingsDialogBlocksBackAndOutsideDismiss() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
pressBack()
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
onView(isRoot()).perform(clickTopLeftCorner())
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
}
@Test
fun settingsButtonClosesDrawerBeforeOpeningDialog() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
scenario.onActivity { activity ->
val drawer = activity.findViewById<DrawerLayout>(R.id.boardsDrawerLayout)
assertFalse(drawer.isDrawerOpen(GravityCompat.START))
}
}
@Test
fun settingsDialogDisablesControlsWhileApplyInProgress() {
val blocked = CompletableDeferred<Unit>()
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
MainActivity.dependencies.settingsApplyCoordinatorFactory = { _, sessionStore, apiClient, apiKeyStore ->
object : SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore) {
override suspend fun apply(): SettingsApplyResult {
blocked.await()
return SettingsApplyResult.SuccessNoCredentialChange(themeChanged = false)
}
}
}
ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
onView(withId(R.id.settingsSaveAndCloseButton)).perform(click())
onView(withId(R.id.settingsApplyProgress)).check(matches(isDisplayed()))
onView(withId(R.id.settingsSaveAndCloseButton)).check(matches(org.hamcrest.Matchers.not(isEnabled())))
blocked.complete(Unit)
}
@Test
fun settingsDialogClosesOnlyOnSuccessfulApply() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
MainActivity.dependencies.settingsApplyCoordinatorFactory = { _, sessionStore, apiClient, apiKeyStore ->
object : SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore) {
override suspend fun apply(): SettingsApplyResult {
return SettingsApplyResult.ValidationError(
field = "apiKey",
message = "API key is required",
)
}
}
}
ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
onView(withId(R.id.settingsSaveAndCloseButton)).perform(click())
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
onView(withId(R.id.settingsErrorText)).check(matches(withText("API key is required")))
}
@Test
fun settingsSaveSuccessReauthsAndRefreshesBoards() {
val fake = QueueBoardsApiClient()
fake.enqueueBoards(BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha"))))
fake.enqueueBoards(BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha"))))
fake.enqueueBoards(BoardsApiResult.Success(listOf(BoardSummary("2", "Beta"))))
MainActivity.dependencies.apiClientFactory = { fake }
MainActivity.dependencies.settingsApplyCoordinatorFactory = { _, sessionStore, apiClient, apiKeyStore ->
object : SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore) {
override suspend fun apply(): SettingsApplyResult = SettingsApplyResult.SuccessCredentialChange
}
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
waitForIdle()
val baselineBoardsCalls = fake.listBoardsCalls
val baselineMeCalls = fake.getCurrentUserCalls
val baselineWorkspacesCalls = fake.listWorkspacesCalls
openSettingsFromDrawer()
onView(withId(R.id.settingsSaveAndCloseButton)).perform(click())
onView(withText("Beta")).check(matches(isDisplayed()))
assertTrue(fake.listBoardsCalls > baselineBoardsCalls)
assertTrue(fake.getCurrentUserCalls > baselineMeCalls)
assertTrue(fake.listWorkspacesCalls > baselineWorkspacesCalls)
scenario.onActivity {
assertTrue(it.supportFragmentManager.findFragmentByTag(SettingsDialogFragment.TAG) == null)
}
}
@Test
fun settingsSaveFailureRollsBackAndStaysOnBoards() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
MainActivity.dependencies.settingsApplyCoordinatorFactory = { _, sessionStore, apiClient, apiKeyStore ->
object : SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore) {
override suspend fun apply(): SettingsApplyResult {
return SettingsApplyResult.AuthError("Authentication failed. Check your API key.")
}
}
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
onView(withId(R.id.settingsSaveAndCloseButton)).perform(click())
onView(withText("Alpha")).check(matches(isDisplayed()))
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
scenario.onActivity { activity ->
assertFalse(activity.isFinishing)
}
}
@Test
fun settingsApplySuccessThenRefreshFailureShowsRetryableBoardsError() {
val fake = QueueBoardsApiClient()
fake.enqueueBoards(BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha"))))
fake.enqueueBoards(BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha"))))
fake.enqueueBoards(BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."))
MainActivity.dependencies.apiClientFactory = { fake }
MainActivity.dependencies.settingsApplyCoordinatorFactory = { _, sessionStore, apiClient, apiKeyStore ->
object : SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore) {
override suspend fun apply(): SettingsApplyResult = SettingsApplyResult.SuccessCredentialChange
}
}
ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
onView(withId(R.id.settingsSaveAndCloseButton)).perform(click())
onView(withText("Cannot reach server. Check your connection and URL."))
.inRoot(isDialog())
.check(matches(isDisplayed()))
}
@Test
fun settingsDialogSurvivesActivityRecreate() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
scenario.recreate()
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
onView(withId(R.id.settingsSaveAndCloseButton)).check(matches(isDisplayed()))
}
private fun openSettingsFromDrawer() {
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withId(R.id.drawerSettingsButton)).perform(click())
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
}
private fun clickTopLeftCorner(): ViewAction {
val start = CoordinatesProvider { floatArrayOf(2f, 2f) }
val end = CoordinatesProvider { floatArrayOf(2f, 2f) }
return GeneralSwipeAction(Swipe.FAST, start, end, Press.FINGER)
}
private fun waitForIdle() {
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
}
private fun swipeFromLeftEdge(): ViewAction {
val start = CoordinatesProvider { view -> floatArrayOf(5f, view.height * 0.5f) }
val end = CoordinatesProvider { view -> floatArrayOf(view.width * 0.6f, view.height * 0.5f) }
return GeneralSwipeAction(Swipe.FAST, start, end, Press.FINGER)
}
private class InMemorySessionStore(
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) {
@@ -130,18 +607,38 @@ class BoardsFlowTest {
baseUrl = null
}
override fun getWorkspaceId(): String? = "ws-1"
override fun getWorkspaceId(): String? = workspaceId
override fun saveWorkspaceId(workspaceId: String) {
this.workspaceId = workspaceId
}
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<String>()
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
key = apiKey
return Result.success(Unit)
@@ -150,6 +647,7 @@ class BoardsFlowTest {
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(key)
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
invalidatedBaseUrls += baseUrl
key = null
return Result.success(Unit)
}
@@ -158,14 +656,23 @@ class BoardsFlowTest {
private class FakeBoardsApiClient(
private val boards: MutableList<BoardSummary>,
private val templates: List<BoardTemplate>,
private val profile: DrawerProfile = DrawerProfile("Alice", "alice@example.com"),
private val workspaces: List<WorkspaceSummary> = listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
) : KanbnApiClient {
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
return BoardsApiResult.Success(profile)
}
override suspend fun listWorkspaces(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> {
return BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
return BoardsApiResult.Success(workspaces)
}
override suspend fun listBoards(
@@ -209,4 +716,159 @@ class BoardsFlowTest {
return BoardsApiResult.Failure("Not needed in boards flow tests")
}
}
private class MultiWorkspaceFakeBoardsApiClient(
private val boardsByWorkspace: Map<String, List<BoardSummary>>,
private val usersMeResponses: ArrayDeque<BoardsApiResult<DrawerProfile>> = ArrayDeque(
listOf(BoardsApiResult.Success(DrawerProfile("Alice", "alice@example.com"))),
),
private val workspaceResponses: ArrayDeque<BoardsApiResult<List<WorkspaceSummary>>> = ArrayDeque(
listOf(
BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
),
),
),
) : KanbnApiClient {
var listWorkspacesCalls: Int = 0
val listBoardsWorkspaceCalls = mutableListOf<String>()
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
return if (usersMeResponses.isEmpty()) {
BoardsApiResult.Success(DrawerProfile("Alice", "alice@example.com"))
} else {
usersMeResponses.removeFirst()
}
}
override suspend fun listWorkspaces(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> {
listWorkspacesCalls += 1
return if (workspaceResponses.isEmpty()) {
BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
)
} else {
workspaceResponses.removeFirst()
}
}
override suspend fun listBoards(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardSummary>> {
listBoardsWorkspaceCalls += workspaceId
return BoardsApiResult.Success(boardsByWorkspace[workspaceId].orEmpty())
}
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> {
return BoardsApiResult.Success(emptyList())
}
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
workspaceId: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
return BoardsApiResult.Success(BoardSummary("new", name))
}
override suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Success(Unit)
}
override suspend fun getLabelByPublicId(
baseUrl: String,
apiKey: String,
labelId: String,
): BoardsApiResult<LabelDetail> {
return BoardsApiResult.Failure("Not needed in boards flow tests")
}
}
private class QueueBoardsApiClient : KanbnApiClient {
private val boardsResponses: ArrayDeque<BoardsApiResult<List<BoardSummary>>> = ArrayDeque()
var listBoardsCalls: Int = 0
var getCurrentUserCalls: Int = 0
var listWorkspacesCalls: Int = 0
fun enqueueBoards(result: BoardsApiResult<List<BoardSummary>>) {
boardsResponses.addLast(result)
}
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
getCurrentUserCalls += 1
return BoardsApiResult.Success(DrawerProfile("Alice", "alice@example.com"))
}
override suspend fun listWorkspaces(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> {
listWorkspacesCalls += 1
return BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
}
override suspend fun listBoards(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardSummary>> {
listBoardsCalls += 1
return if (boardsResponses.isEmpty()) {
BoardsApiResult.Success(emptyList())
} else {
boardsResponses.removeFirst()
}
}
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> {
return BoardsApiResult.Success(emptyList())
}
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
workspaceId: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
return BoardsApiResult.Success(BoardSummary("new", name))
}
override suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Success(Unit)
}
override suspend fun getLabelByPublicId(
baseUrl: String,
apiKey: String,
labelId: String,
): BoardsApiResult<LabelDetail> {
return BoardsApiResult.Failure("Not needed in boards flow tests")
}
}
}

View File

@@ -16,7 +16,10 @@ class PreferencesApiKeyStoreTest {
@Before
fun clearStore() = runBlocking {
store.invalidateApiKey("setup").getOrThrow()
context.getSharedPreferences("kanbn_api_key_store", android.content.Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
}
@Test
@@ -47,15 +50,56 @@ class PreferencesApiKeyStoreTest {
}
@Test
fun blankAndMalformedBaseUrlStillWork() = runBlocking {
val saveBlankResult = store.saveApiKey("", "kan_key")
fun saveGetForDifferentBaseUrlsAreIsolated() = runBlocking {
store.saveApiKey("https://kan.bn/", "kan_key").getOrThrow()
store.saveApiKey("https://next.kan.bn/", "next_key").getOrThrow()
val firstResult = store.getApiKey("https://kan.bn/")
val secondResult = store.getApiKey("https://next.kan.bn/")
assertEquals("kan_key", firstResult.getOrNull())
assertEquals("next_key", secondResult.getOrNull())
}
@Test
fun invalidateOneBaseUrlDoesNotRemoveOtherBaseUrlKey() = runBlocking {
store.saveApiKey("https://kan.bn/", "kan_key").getOrThrow()
store.saveApiKey("https://next.kan.bn/", "next_key").getOrThrow()
val invalidateResult = store.invalidateApiKey("https://kan.bn/")
val removedResult = store.getApiKey("https://kan.bn/")
val keptResult = store.getApiKey("https://next.kan.bn/")
assertEquals(true, invalidateResult.isSuccess)
assertNull(removedResult.getOrNull())
assertEquals("next_key", keptResult.getOrNull())
}
@Test
fun keyDerivationUsesNormalizedBaseUrl() = runBlocking {
val saveResult = store.saveApiKey(" HTTPS://KAN.BN/api/v1 ", "kan_key")
val getNormalizedResult = store.getApiKey("https://kan.bn/api/v1/")
val getEquivalentResult = store.getApiKey("https://kan.bn/api/v1")
assertEquals(true, saveResult.isSuccess)
assertEquals("kan_key", getNormalizedResult.getOrNull())
assertEquals("kan_key", getEquivalentResult.getOrNull())
}
@Test
fun malformedBaseUrlStillUsesDeterministicFallbackKeying() = runBlocking {
val saveBlankResult = store.saveApiKey("", "blank_key")
val getBlankResult = store.getApiKey(" ")
val saveMalformedResult = store.saveApiKey("not a url", "bad_url_key")
val getMalformedResult = store.getApiKey("not a url")
val invalidateMalformedResult = store.invalidateApiKey("::://")
val getAfterInvalidateResult = store.getApiKey(" ")
val getAfterUnrelatedInvalidateResult = store.getApiKey("not a url")
assertEquals(true, saveBlankResult.isSuccess)
assertEquals("kan_key", getMalformedResult.getOrNull())
assertEquals("blank_key", getBlankResult.getOrNull())
assertEquals(true, saveMalformedResult.isSuccess)
assertEquals("bad_url_key", getMalformedResult.getOrNull())
assertEquals(true, invalidateMalformedResult.isSuccess)
assertNull(getAfterInvalidateResult.getOrNull())
assertEquals("bad_url_key", getAfterUnrelatedInvalidateResult.getOrNull())
}
}

View File

@@ -12,15 +12,19 @@ import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.chip.Chip
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
@@ -31,24 +35,53 @@ import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardsAdapter
import space.hackenslacker.kanbn4droid.app.boards.BoardsDrawerAdapter
import space.hackenslacker.kanbn4droid.app.boards.BoardsRepository
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiEvent
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiState
import space.hackenslacker.kanbn4droid.app.boards.BoardsViewModel
import space.hackenslacker.kanbn4droid.app.boards.DrawerDataErrorCode
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity
import space.hackenslacker.kanbn4droid.app.settings.SettingsDialogFragment
class BoardsActivity : AppCompatActivity() {
private lateinit var sessionStore: SessionStore
private lateinit var apiKeyStore: ApiKeyStore
private lateinit var apiClient: KanbnApiClient
internal val sessionStoreForSettingsDialog: SessionStore
get() = sessionStore
internal val apiKeyStoreForSettingsDialog: ApiKeyStore
get() = apiKeyStore
internal val apiClientForSettingsDialog: KanbnApiClient
get() = apiClient
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var recyclerView: RecyclerView
private lateinit var emptyStateText: TextView
private lateinit var initialProgress: ProgressBar
private lateinit var createFab: FloatingActionButton
private lateinit var toolbar: MaterialToolbar
private lateinit var drawerLayout: DrawerLayout
private lateinit var drawerContent: View
private lateinit var drawerUsernameText: TextView
private lateinit var drawerEmailText: TextView
private lateinit var drawerLoadingIndicator: ProgressBar
private lateinit var drawerErrorText: TextView
private lateinit var drawerRetryButton: Button
private lateinit var drawerSettingsButton: Button
private lateinit var drawerLogoutButton: Button
private lateinit var drawerWorkspacesRecyclerView: RecyclerView
private lateinit var boardsAdapter: BoardsAdapter
private lateinit var drawerAdapter: BoardsDrawerAdapter
private var hasRequestedInitialDrawerLoad = false
private var lastDrawerSwitchInFlight = false
private var pendingWorkspaceSelectionId: String? = null
private var pendingOpenSettingsAfterDrawerClose = false
private val viewModel: BoardsViewModel by viewModels {
BoardsViewModel.Factory(
@@ -72,6 +105,7 @@ class BoardsActivity : AppCompatActivity() {
setupRecycler()
setupInteractions()
observeViewModel()
observeSettingsResult()
viewModel.loadBoards()
}
@@ -82,11 +116,24 @@ class BoardsActivity : AppCompatActivity() {
}
private fun bindViews() {
drawerLayout = findViewById(R.id.boardsDrawerLayout)
drawerContent = findViewById(R.id.boardsDrawerContent)
toolbar = findViewById(R.id.boardsToolbar)
swipeRefresh = findViewById(R.id.boardsSwipeRefresh)
recyclerView = findViewById(R.id.boardsRecyclerView)
emptyStateText = findViewById(R.id.boardsEmptyStateText)
initialProgress = findViewById(R.id.boardsInitialProgress)
createFab = findViewById(R.id.createBoardFab)
drawerUsernameText = findViewById(R.id.drawerUsernameText)
drawerEmailText = findViewById(R.id.drawerEmailText)
drawerLoadingIndicator = findViewById(R.id.drawerLoadingIndicator)
drawerErrorText = findViewById(R.id.drawerErrorText)
drawerRetryButton = findViewById(R.id.drawerRetryButton)
drawerSettingsButton = findViewById(R.id.drawerSettingsButton)
drawerLogoutButton = findViewById(R.id.drawerLogoutButton)
drawerWorkspacesRecyclerView = findViewById(R.id.drawerWorkspacesRecyclerView)
applyDrawerWidth()
}
private fun setupRecycler() {
@@ -96,9 +143,64 @@ class BoardsActivity : AppCompatActivity() {
)
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = boardsAdapter
drawerAdapter = BoardsDrawerAdapter(
onWorkspaceClick = { workspace ->
if (viewModel.uiState.value.drawer.isWorkspaceInteractionEnabled) {
pendingWorkspaceSelectionId = workspace.id
viewModel.onWorkspaceSelected(workspace.id)
}
},
)
drawerWorkspacesRecyclerView.layoutManager = LinearLayoutManager(this)
drawerWorkspacesRecyclerView.adapter = drawerAdapter
}
private fun setupInteractions() {
toolbar.setNavigationIcon(android.R.drawable.ic_menu_sort_by_size)
toolbar.setNavigationContentDescription(R.string.drawer_workspaces)
toolbar.setNavigationOnClickListener {
drawerLayout.openDrawer(GravityCompat.START)
}
drawerRetryButton.setOnClickListener {
viewModel.retryDrawerData()
}
drawerSettingsButton.setOnClickListener {
sessionStore.initializeDraftsFromCommitted()
if (drawerLayout.isDrawerOpen(GravityCompat.START)) {
pendingOpenSettingsAfterDrawerClose = true
drawerLayout.closeDrawer(GravityCompat.START)
} else {
showSettingsDialog()
}
}
drawerLogoutButton.setOnClickListener {
showLogoutConfirmation()
}
drawerLayout.addDrawerListener(
object : DrawerLayout.SimpleDrawerListener() {
override fun onDrawerOpened(drawerView: View) {
if (drawerView.id == R.id.boardsDrawerContent) {
viewModel.loadDrawerDataIfStale()
}
}
override fun onDrawerClosed(drawerView: View) {
if (drawerView.id != R.id.boardsDrawerContent) {
return
}
if (pendingOpenSettingsAfterDrawerClose) {
pendingOpenSettingsAfterDrawerClose = false
showSettingsDialog()
}
}
},
)
swipeRefresh.setOnRefreshListener {
viewModel.refreshBoards()
}
@@ -126,17 +228,98 @@ class BoardsActivity : AppCompatActivity() {
.setPositiveButton(R.string.ok, null)
.show()
}
BoardsUiEvent.ForceSignOut -> {
forceSignOutToLogin()
}
}
}
}
}
private fun render(state: BoardsUiState) {
if (!hasRequestedInitialDrawerLoad) {
hasRequestedInitialDrawerLoad = true
viewModel.loadDrawerData()
}
boardsAdapter.submitBoards(state.boards)
swipeRefresh.isRefreshing = state.isRefreshing
initialProgress.visibility = if (state.isInitialLoading) View.VISIBLE else View.GONE
emptyStateText.visibility = if (!state.isInitialLoading && state.boards.isEmpty()) View.VISIBLE else View.GONE
createFab.isEnabled = !state.isMutating
val profile = state.drawer.profile
drawerUsernameText.text = profile?.displayName?.ifBlank { getString(R.string.drawer_profile_unavailable) }
?: getString(R.string.drawer_profile_unavailable)
drawerEmailText.text = profile?.email?.ifBlank { getString(R.string.drawer_profile_unavailable) }
?: getString(R.string.drawer_profile_unavailable)
drawerAdapter.submitItems(
workspaces = state.drawer.workspaces,
activeWorkspaceId = state.drawer.activeWorkspaceId,
)
drawerLoadingIndicator.visibility = if (state.drawer.isLoading) View.VISIBLE else View.GONE
val hasError = state.drawer.profileError != null || state.drawer.workspacesError != null
drawerErrorText.visibility = if (hasError) View.VISIBLE else View.GONE
drawerErrorText.text = state.drawer.workspacesError
?: state.drawer.profileError
?: getString(R.string.drawer_workspaces_unavailable)
drawerRetryButton.visibility = if (state.drawer.isRetryable) View.VISIBLE else View.GONE
val isSwitchInFlight = state.drawer.isWorkspaceSwitchInFlight
if (lastDrawerSwitchInFlight && !isSwitchInFlight) {
val selectedWorkspace = pendingWorkspaceSelectionId
if (
selectedWorkspace != null &&
selectedWorkspace == state.drawer.activeWorkspaceId &&
state.drawer.errorCode == DrawerDataErrorCode.NONE
) {
drawerLayout.closeDrawer(GravityCompat.START)
}
pendingWorkspaceSelectionId = null
}
lastDrawerSwitchInFlight = isSwitchInFlight
}
private fun applyDrawerWidth() {
val maxWidthPx = resources.getDimensionPixelSize(R.dimen.boards_drawer_max_width)
val displayWidthPx = resources.displayMetrics.widthPixels
val computedWidth = minOf(displayWidthPx / 3, maxWidthPx)
drawerContent.layoutParams = drawerContent.layoutParams.apply {
width = computedWidth
}
}
private fun forceSignOutToLogin() {
lifecycleScope.launch {
val baseUrl = sessionStore.getBaseUrl().orEmpty()
if (baseUrl.isNotBlank()) {
kotlinx.coroutines.withContext(Dispatchers.IO) {
apiKeyStore.invalidateApiKey(baseUrl)
}
}
sessionStore.clearBaseUrl()
sessionStore.clearApiKey()
sessionStore.clearWorkspaceId()
sessionStore.resetDraftsFromCommitted()
viewModel.clearSessionUiState()
val intent = Intent(this@BoardsActivity, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(intent)
finish()
}
}
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() {
@@ -244,6 +427,28 @@ class BoardsActivity : AppCompatActivity() {
.show()
}
private fun observeSettingsResult() {
supportFragmentManager.setFragmentResultListener(
SettingsDialogFragment.REQUEST_KEY_SETTINGS_APPLIED,
this,
) { _, bundle ->
val credentialsChanged = bundle.getBoolean(SettingsDialogFragment.RESULT_KEY_CREDENTIALS_CHANGED)
val themeChanged = bundle.getBoolean(SettingsDialogFragment.RESULT_KEY_THEME_CHANGED)
viewModel.onSettingsApplied(
credentialsChanged = credentialsChanged,
)
}
}
private fun showSettingsDialog() {
if (supportFragmentManager.findFragmentByTag(SettingsDialogFragment.TAG) != null) {
return
}
SettingsDialogFragment
.newInstance()
.show(supportFragmentManager, SettingsDialogFragment.TAG)
}
private fun navigateToBoard(board: BoardSummary) {
startActivity(
Intent(this, BoardDetailActivity::class.java)

View File

@@ -24,6 +24,7 @@ import space.hackenslacker.kanbn4droid.app.auth.AuthResult
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.PreferencesApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyCoordinator
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.auth.UrlNormalizer
@@ -225,10 +226,12 @@ class TestDependencies {
var sessionStoreFactory: ((AppCompatActivity) -> SessionStore)? = null
var apiKeyStoreFactory: ((AppCompatActivity) -> ApiKeyStore)? = null
var apiClientFactory: (() -> KanbnApiClient)? = null
var settingsApplyCoordinatorFactory: ((AppCompatActivity, SessionStore, KanbnApiClient, ApiKeyStore) -> SettingsApplyCoordinator)? = null
fun clear() {
sessionStoreFactory = null
apiKeyStoreFactory = null
apiClientFactory = null
settingsApplyCoordinatorFactory = null
}
}

View File

@@ -1,6 +1,7 @@
package space.hackenslacker.kanbn4droid.app.auth
import android.content.Context
import java.security.MessageDigest
interface ApiKeyStore {
suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit>
@@ -15,26 +16,71 @@ class PreferencesApiKeyStore(
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
return runCatching {
preferences.edit().putString(API_KEY_PREFERENCE_KEY, apiKey).apply()
val preferenceKey = preferenceKeyForBaseUrl(baseUrl)
preferences.edit()
.remove(LEGACY_API_KEY_PREFERENCE_KEY)
.putString(preferenceKey, apiKey)
.apply()
Unit
}
}
override suspend fun getApiKey(baseUrl: String): Result<String?> {
return runCatching {
preferences.getString(API_KEY_PREFERENCE_KEY, null)
val preferenceKey = preferenceKeyForBaseUrl(baseUrl)
val keyedValue = preferences.getString(preferenceKey, null)
if (keyedValue != null) {
keyedValue
} else {
val legacyValue = preferences.getString(LEGACY_API_KEY_PREFERENCE_KEY, null)
if (legacyValue != null) {
preferences.edit()
.remove(LEGACY_API_KEY_PREFERENCE_KEY)
.putString(preferenceKey, legacyValue)
.apply()
}
legacyValue
}
}
}
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
return runCatching {
preferences.edit().remove(API_KEY_PREFERENCE_KEY).apply()
val preferenceKey = preferenceKeyForBaseUrl(baseUrl)
preferences.edit().remove(preferenceKey).apply()
Unit
}
}
internal fun preferenceKeyForBaseUrl(baseUrl: String): String {
return deriveApiKeyPreferenceKey(baseUrl)
}
private companion object {
private const val PREFERENCES_NAME = "kanbn_api_key_store"
private const val API_KEY_PREFERENCE_KEY = "api_key"
private const val API_KEY_PREFERENCE_KEY_PREFIX = "api_key_"
private const val LEGACY_API_KEY_PREFERENCE_KEY = "api_key"
}
}
internal fun deriveApiKeyPreferenceKey(baseUrl: String): String {
val normalizedBaseUrl = when (val normalized = UrlNormalizer.normalize(baseUrl)) {
is UrlValidationResult.Valid -> normalized.normalizedUrl
is UrlValidationResult.Invalid -> baseUrl.trim()
}
val digest = MessageDigest.getInstance("SHA-256")
.digest(normalizedBaseUrl.toByteArray(Charsets.UTF_8))
return "api_key_${digest.toHexString()}"
}
private fun ByteArray.toHexString(): String {
val builder = StringBuilder(size * 2)
for (byte in this) {
val value = byte.toInt() and 0xff
if (value < 0x10) {
builder.append('0')
}
builder.append(value.toString(16))
}
return builder.toString()
}

View File

@@ -19,6 +19,7 @@ import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
import space.hackenslacker.kanbn4droid.app.boards.DrawerProfile
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary
import space.hackenslacker.kanbn4droid.app.carddetail.CardActivity
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetail
@@ -27,6 +28,10 @@ import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailTag
interface KanbnApiClient {
suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult
suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
return BoardsApiResult.Failure("Current user endpoint is not implemented.")
}
suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult<List<WorkspaceSummary>> {
return BoardsApiResult.Failure("Workspace listing is not implemented.")
}
@@ -134,6 +139,25 @@ data class LabelDetail(
class HttpKanbnApiClient : KanbnApiClient {
override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
return withContext(Dispatchers.IO) {
request(
baseUrl = baseUrl,
path = "/api/v1/users/me",
method = "GET",
apiKey = apiKey,
) { code, body ->
if (code in 200..299) {
parseUsersMeProfile(body)
?.let { BoardsApiResult.Success(it) }
?: BoardsApiResult.Failure("Malformed users/me response.")
} else {
BoardsApiResult.Failure(serverMessage(body, code))
}
}
}
}
override suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult<List<WorkspaceSummary>> {
return withContext(Dispatchers.IO) {
request(

View File

@@ -7,12 +7,28 @@ class SessionPreferences(context: Context) : SessionStore {
private val preferences: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
override fun getThemeMode(): String = preferences.getString(KEY_THEME_MODE, THEME_MODE_SYSTEM) ?: THEME_MODE_SYSTEM
override fun saveThemeMode(themeMode: String) {
preferences.edit().putString(KEY_THEME_MODE, themeMode).apply()
}
override fun getBaseUrl(): String? = preferences.getString(KEY_BASE_URL, null)
override fun saveBaseUrl(url: String) {
preferences.edit().putString(KEY_BASE_URL, url).apply()
}
override fun getApiKey(): String? = preferences.getString(KEY_API_KEY, null)
override fun saveApiKey(apiKey: String) {
preferences.edit().putString(KEY_API_KEY, apiKey).apply()
}
override fun clearApiKey() {
preferences.edit().remove(KEY_API_KEY).apply()
}
override fun getWorkspaceId(): String? = preferences.getString(KEY_WORKSPACE_ID, null)
override fun saveWorkspaceId(workspaceId: String) {
@@ -27,9 +43,55 @@ class SessionPreferences(context: Context) : SessionStore {
preferences.edit().remove(KEY_WORKSPACE_ID).apply()
}
override fun initializeDraftsFromCommitted() {
val editor = preferences.edit()
editor.putString(KEY_DRAFT_THEME_MODE, getThemeMode())
editor.putString(KEY_DRAFT_BASE_URL, getBaseUrl())
editor.putString(KEY_DRAFT_API_KEY, getApiKey())
editor.apply()
}
override fun getDraftThemeMode(): String {
return preferences.getString(KEY_DRAFT_THEME_MODE, getThemeMode()) ?: getThemeMode()
}
override fun saveDraftThemeMode(themeMode: String) {
preferences.edit().putString(KEY_DRAFT_THEME_MODE, themeMode).apply()
}
override fun getDraftBaseUrl(): String? = preferences.getString(KEY_DRAFT_BASE_URL, getBaseUrl())
override fun saveDraftBaseUrl(url: String) {
preferences.edit().putString(KEY_DRAFT_BASE_URL, url).apply()
}
override fun getDraftApiKey(): String? = preferences.getString(KEY_DRAFT_API_KEY, getApiKey())
override fun saveDraftApiKey(apiKey: String) {
preferences.edit().putString(KEY_DRAFT_API_KEY, apiKey).apply()
}
override fun resetDraftsFromCommitted() {
initializeDraftsFromCommitted()
}
override fun syncDraftsToCommitted() {
val editor = preferences.edit()
editor.putString(KEY_THEME_MODE, getDraftThemeMode())
editor.putString(KEY_BASE_URL, getDraftBaseUrl())
editor.putString(KEY_API_KEY, getDraftApiKey())
editor.apply()
}
private companion object {
private const val PREFS_NAME = "kanbn_session"
private const val THEME_MODE_SYSTEM = "system"
private const val KEY_THEME_MODE = "theme_mode"
private const val KEY_BASE_URL = "base_url"
private const val KEY_API_KEY = "api_key"
private const val KEY_WORKSPACE_ID = "workspace_id"
private const val KEY_DRAFT_THEME_MODE = "draft_theme_mode"
private const val KEY_DRAFT_BASE_URL = "draft_base_url"
private const val KEY_DRAFT_API_KEY = "draft_api_key"
}
}

View File

@@ -1,10 +1,41 @@
package space.hackenslacker.kanbn4droid.app.auth
interface SessionStore {
fun getThemeMode(): String = "system"
fun saveThemeMode(themeMode: String) {}
fun getBaseUrl(): String?
fun saveBaseUrl(url: String)
fun getApiKey(): String? = null
fun saveApiKey(apiKey: String) {}
fun clearApiKey() {}
fun getWorkspaceId(): String?
fun saveWorkspaceId(workspaceId: String)
fun clearBaseUrl()
fun clearWorkspaceId()
fun initializeDraftsFromCommitted() {}
fun getDraftThemeMode(): String = getThemeMode()
fun saveDraftThemeMode(themeMode: String) {
saveThemeMode(themeMode)
}
fun getDraftBaseUrl(): String? = getBaseUrl()
fun saveDraftBaseUrl(url: String) {
saveBaseUrl(url)
}
fun getDraftApiKey(): String? = getApiKey()
fun saveDraftApiKey(apiKey: String) {
saveApiKey(apiKey)
}
fun resetDraftsFromCommitted() {
initializeDraftsFromCommitted()
}
fun syncDraftsToCommitted() {
saveThemeMode(getDraftThemeMode())
getDraftBaseUrl()?.let(::saveBaseUrl) ?: clearBaseUrl()
getDraftApiKey()?.let(::saveApiKey) ?: clearApiKey()
}
}

View File

@@ -0,0 +1,146 @@
package space.hackenslacker.kanbn4droid.app.auth
sealed interface SettingsApplyResult {
data object NoChanges : SettingsApplyResult
data class SuccessNoCredentialChange(val themeChanged: Boolean) : SettingsApplyResult
data object SuccessCredentialChange : SettingsApplyResult
data class ValidationError(val field: String, val message: String) : SettingsApplyResult
data class AuthError(val message: String) : SettingsApplyResult
data class NetworkError(val message: String) : SettingsApplyResult
}
open class SettingsApplyCoordinator(
private val sessionStore: SessionStore,
private val apiClient: KanbnApiClient,
private val apiKeyStore: ApiKeyStore,
) {
open suspend fun apply(): SettingsApplyResult {
val snapshot = try {
LastKnownGoodSnapshot.capture(sessionStore, apiKeyStore)
} catch (_: Exception) {
return SettingsApplyResult.NetworkError("Failed to apply settings changes.")
}
val draftTheme = sessionStore.getDraftThemeMode().trim().ifBlank { "system" }
val draftBaseUrlRaw = sessionStore.getDraftBaseUrl().orEmpty()
val draftApiKey = sessionStore.getDraftApiKey().orEmpty().trim()
val normalizedDraftBaseUrl = when (val normalized = UrlNormalizer.normalize(draftBaseUrlRaw)) {
is UrlValidationResult.Valid -> normalized.normalizedUrl
is UrlValidationResult.Invalid -> {
sessionStore.resetDraftsFromCommitted()
return SettingsApplyResult.ValidationError(
field = FIELD_BASE_URL,
message = normalized.message,
)
}
}
if (draftApiKey.isBlank()) {
sessionStore.resetDraftsFromCommitted()
return SettingsApplyResult.ValidationError(
field = FIELD_API_KEY,
message = "API key is required",
)
}
val themeChanged = draftTheme != snapshot.committedThemeMode
val committedApiKey = snapshot.committedApiStoreKey.orEmpty()
val credentialChanged = normalizedDraftBaseUrl != snapshot.committedBaseUrl ||
draftApiKey != committedApiKey
if (!themeChanged && !credentialChanged) {
return SettingsApplyResult.NoChanges
}
if (credentialChanged) {
when (val auth = apiClient.healthCheck(normalizedDraftBaseUrl, draftApiKey)) {
is AuthResult.Success -> Unit
is AuthResult.Failure -> {
rollback(snapshot, normalizedDraftBaseUrl)
return when (auth.reason) {
AuthFailureReason.Authentication -> SettingsApplyResult.AuthError(auth.message)
AuthFailureReason.Connectivity,
AuthFailureReason.Server,
AuthFailureReason.Unexpected,
-> SettingsApplyResult.NetworkError(auth.message)
}
}
}
}
try {
sessionStore.saveDraftThemeMode(draftTheme)
sessionStore.saveDraftBaseUrl(normalizedDraftBaseUrl)
sessionStore.saveDraftApiKey(draftApiKey)
if (credentialChanged) {
apiKeyStore.saveApiKey(normalizedDraftBaseUrl, draftApiKey).getOrThrow()
if (normalizedDraftBaseUrl != snapshot.committedBaseUrl) {
apiKeyStore.invalidateApiKey(snapshot.committedBaseUrl).getOrThrow()
}
}
sessionStore.syncDraftsToCommitted()
} catch (_: Exception) {
rollback(snapshot, normalizedDraftBaseUrl)
return SettingsApplyResult.NetworkError("Failed to apply settings changes.")
}
return if (credentialChanged) {
SettingsApplyResult.SuccessCredentialChange
} else {
SettingsApplyResult.SuccessNoCredentialChange(themeChanged = themeChanged)
}
}
private suspend fun rollback(snapshot: LastKnownGoodSnapshot, candidateBaseUrl: String) {
snapshot.restoreSession(sessionStore)
snapshot.restoreApiKeys(apiKeyStore, candidateBaseUrl)
}
private data class LastKnownGoodSnapshot(
val committedThemeMode: String,
val committedBaseUrl: String,
val committedSessionApiKey: String,
val committedApiStoreKey: String?,
) {
fun restoreSession(sessionStore: SessionStore) {
sessionStore.saveThemeMode(committedThemeMode)
sessionStore.saveBaseUrl(committedBaseUrl)
sessionStore.saveApiKey(committedSessionApiKey)
sessionStore.saveDraftThemeMode(committedThemeMode)
sessionStore.saveDraftBaseUrl(committedBaseUrl)
sessionStore.saveDraftApiKey(committedSessionApiKey)
}
suspend fun restoreApiKeys(apiKeyStore: ApiKeyStore, candidateBaseUrl: String) {
if (candidateBaseUrl != committedBaseUrl) {
apiKeyStore.invalidateApiKey(candidateBaseUrl)
}
if (committedApiStoreKey == null) {
apiKeyStore.invalidateApiKey(committedBaseUrl)
} else {
apiKeyStore.saveApiKey(committedBaseUrl, committedApiStoreKey)
}
}
companion object {
suspend fun capture(sessionStore: SessionStore, apiKeyStore: ApiKeyStore): LastKnownGoodSnapshot {
val committedBaseUrl = sessionStore.getBaseUrl().orEmpty()
val committedApiStoreKey = apiKeyStore.getApiKey(committedBaseUrl).getOrThrow()
return LastKnownGoodSnapshot(
committedThemeMode = sessionStore.getThemeMode(),
committedBaseUrl = committedBaseUrl,
committedSessionApiKey = sessionStore.getApiKey().orEmpty(),
committedApiStoreKey = committedApiStoreKey,
)
}
}
}
private companion object {
private const val FIELD_BASE_URL = "baseUrl"
private const val FIELD_API_KEY = "apiKey"
}
}

View File

@@ -1,6 +1,7 @@
package space.hackenslacker.kanbn4droid.app.auth
import java.net.URI
import java.util.Locale
sealed interface UrlValidationResult {
data class Valid(val normalizedUrl: String) : UrlValidationResult
@@ -26,7 +27,7 @@ object UrlNormalizer {
return UrlValidationResult.Invalid("Base URL must start with http:// or https://")
}
val host = uri.host
val host = uri.host?.lowercase(Locale.ROOT)
if (host.isNullOrBlank()) {
return UrlValidationResult.Invalid("Enter a valid server URL")
}

View File

@@ -0,0 +1,204 @@
package space.hackenslacker.kanbn4droid.app.auth
import space.hackenslacker.kanbn4droid.app.boards.DrawerProfile
internal fun parseUsersMeProfile(body: String): DrawerProfile? {
if (body.isBlank()) {
return null
}
val root = parseUsersMeJsonObject(body) ?: return null
val data = root["data"] as? Map<*, *>
val user = (data?.get("user") as? Map<*, *>)
?: (root["user"] as? Map<*, *>)
?: data
?: root
val displayName = extractUsersMeString(user, "displayName", "username", "name", "email")
if (displayName.isBlank()) {
return null
}
val email = extractUsersMeString(user, "email").ifBlank { null }
return DrawerProfile(displayName = displayName, email = email)
}
private fun extractUsersMeString(source: Map<*, *>, vararg keys: String): String {
return keys.firstNotNullOfOrNull { key -> source[key]?.toString()?.trim()?.takeIf { it.isNotEmpty() } }.orEmpty()
}
private fun parseUsersMeJsonObject(body: String): Map<String, Any?>? {
val parsed = parseUsersMeJsonValue(body)
@Suppress("UNCHECKED_CAST")
return parsed as? Map<String, Any?>
}
private fun parseUsersMeJsonValue(body: String): Any? {
val trimmed = body.trim()
if (trimmed.isBlank()) {
return null
}
return runCatching { UsersMeMiniJsonParser(trimmed).parseValue() }.getOrNull()
}
private class UsersMeMiniJsonParser(private val input: String) {
private var index = 0
fun parseValue(): Any? {
skipWhitespace()
if (index >= input.length) {
return null
}
return when (val ch = input[index]) {
'{' -> parseObject()
'[' -> parseArray()
'"' -> parseString()
't' -> parseLiteral("true", true)
'f' -> parseLiteral("false", false)
'n' -> parseLiteral("null", null)
'-', in '0'..'9' -> parseNumber()
else -> throw IllegalArgumentException("Unexpected token $ch at index $index")
}
}
private fun parseObject(): Map<String, Any?> {
expect('{')
skipWhitespace()
val result = linkedMapOf<String, Any?>()
if (peek() == '}') {
index += 1
return result
}
while (index < input.length) {
val key = parseString()
skipWhitespace()
expect(':')
val value = parseValue()
result[key] = value
skipWhitespace()
when (peek()) {
',' -> index += 1
'}' -> {
index += 1
return result
}
else -> throw IllegalArgumentException("Expected , or } at index $index")
}
skipWhitespace()
}
throw IllegalArgumentException("Unclosed object")
}
private fun parseArray(): List<Any?> {
expect('[')
skipWhitespace()
val result = mutableListOf<Any?>()
if (peek() == ']') {
index += 1
return result
}
while (index < input.length) {
result += parseValue()
skipWhitespace()
when (peek()) {
',' -> index += 1
']' -> {
index += 1
return result
}
else -> throw IllegalArgumentException("Expected , or ] at index $index")
}
skipWhitespace()
}
throw IllegalArgumentException("Unclosed array")
}
private fun parseString(): String {
expect('"')
val result = StringBuilder()
while (index < input.length) {
val ch = input[index++]
when (ch) {
'"' -> return result.toString()
'\\' -> {
val escaped = input.getOrNull(index++) ?: throw IllegalArgumentException("Invalid escape")
when (escaped) {
'"' -> result.append('"')
'\\' -> result.append('\\')
'/' -> result.append('/')
'b' -> result.append('\b')
'f' -> result.append('\u000C')
'n' -> result.append('\n')
'r' -> result.append('\r')
't' -> result.append('\t')
'u' -> {
val hex = input.substring(index, index + 4)
index += 4
result.append(hex.toInt(16).toChar())
}
else -> throw IllegalArgumentException("Invalid escape token")
}
}
else -> result.append(ch)
}
}
throw IllegalArgumentException("Unclosed string")
}
private fun parseNumber(): Any {
val start = index
if (peek() == '-') {
index += 1
}
while (peek()?.isDigit() == true) {
index += 1
}
var isFloating = false
if (peek() == '.') {
isFloating = true
index += 1
while (peek()?.isDigit() == true) {
index += 1
}
}
if (peek() == 'e' || peek() == 'E') {
isFloating = true
index += 1
if (peek() == '+' || peek() == '-') {
index += 1
}
while (peek()?.isDigit() == true) {
index += 1
}
}
val token = input.substring(start, index)
return if (isFloating) token.toDouble() else token.toLong()
}
private fun parseLiteral(token: String, value: Any?): Any? {
if (!input.startsWith(token, index)) {
throw IllegalArgumentException("Expected $token at index $index")
}
index += token.length
return value
}
private fun expect(expected: Char) {
skipWhitespace()
if (peek() != expected) {
throw IllegalArgumentException("Expected $expected at index $index")
}
index += 1
}
private fun peek(): Char? = input.getOrNull(index)
private fun skipWhitespace() {
while (peek()?.isWhitespace() == true) {
index += 1
}
}
}

View File

@@ -0,0 +1,57 @@
package space.hackenslacker.kanbn4droid.app.boards
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import space.hackenslacker.kanbn4droid.app.R
class BoardsDrawerAdapter(
private val onWorkspaceClick: (WorkspaceSummary) -> Unit,
) : RecyclerView.Adapter<BoardsDrawerAdapter.WorkspaceViewHolder>() {
private val workspaces = mutableListOf<WorkspaceSummary>()
private var activeWorkspaceId: String? = null
fun submitItems(workspaces: List<WorkspaceSummary>, activeWorkspaceId: String?) {
this.workspaces.clear()
this.workspaces.addAll(workspaces)
this.activeWorkspaceId = activeWorkspaceId
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WorkspaceViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_workspace_drawer, parent, false)
return WorkspaceViewHolder(view)
}
override fun onBindViewHolder(holder: WorkspaceViewHolder, position: Int) {
val workspace = workspaces[position]
holder.bind(
workspace = workspace,
isSelected = workspace.id == activeWorkspaceId,
onWorkspaceClick = onWorkspaceClick,
)
}
override fun getItemCount(): Int = workspaces.size
class WorkspaceViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val workspaceTitleText: TextView = itemView.findViewById(R.id.workspaceTitleText)
fun bind(
workspace: WorkspaceSummary,
isSelected: Boolean,
onWorkspaceClick: (WorkspaceSummary) -> Unit,
) {
workspaceTitleText.text = workspace.name
itemView.isSelected = isSelected
itemView.isActivated = isSelected
workspaceTitleText.isSelected = isSelected
workspaceTitleText.isActivated = isSelected
itemView.setOnClickListener {
onWorkspaceClick(workspace)
}
}
}
}

View File

@@ -0,0 +1,41 @@
package space.hackenslacker.kanbn4droid.app.boards
enum class DrawerDataErrorCode {
NONE,
UNAUTHORIZED,
NETWORK,
SERVER,
}
data class DrawerProfile(
val displayName: String,
val email: String?,
)
data class DrawerDataResult(
val profile: DrawerProfile?,
val workspaces: List<WorkspaceSummary>,
val activeWorkspaceId: String?,
val profileError: String?,
val workspacesError: String?,
val errorCode: DrawerDataErrorCode,
val didFallbackWorkspace: Boolean = false,
)
data class BoardsDrawerState(
val isLoading: Boolean = false,
val profile: DrawerProfile? = null,
val workspaces: List<WorkspaceSummary> = emptyList(),
val activeWorkspaceId: String? = null,
val profileError: String? = null,
val workspacesError: String? = null,
val errorCode: DrawerDataErrorCode = DrawerDataErrorCode.NONE,
val isRetryable: Boolean = false,
val isWorkspaceInteractionEnabled: Boolean = false,
val isWorkspaceSwitchInFlight: Boolean = false,
)
internal fun String.isUnauthorizedFailureMessage(): Boolean {
val normalized = lowercase()
return "401" in normalized || "403" in normalized || "authentication" in normalized || "unauthorized" in normalized
}

View File

@@ -13,6 +13,81 @@ 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
is BoardsApiResult.Failure -> {
return DrawerDataResult(
profile = null,
workspaces = emptyList(),
activeWorkspaceId = null,
profileError = sessionResult.message,
workspacesError = sessionResult.message,
errorCode = DrawerDataErrorCode.SERVER,
)
}
}
val profileResult = apiClient.getCurrentUser(session.baseUrl, session.apiKey)
val workspacesResult = apiClient.listWorkspaces(session.baseUrl, session.apiKey)
val profile = (profileResult as? BoardsApiResult.Success)?.value
val profileError = (profileResult as? BoardsApiResult.Failure)?.message
val workspaces = (workspacesResult as? BoardsApiResult.Success)?.value.orEmpty()
val workspacesError = (workspacesResult as? BoardsApiResult.Failure)?.message
val workspaceResolution = resolveActiveWorkspaceForDrawer(
workspaces = workspaces,
workspacesError = workspacesError,
)
val activeWorkspaceId = workspaceResolution.activeWorkspaceId
val errorCode = deriveDrawerErrorCode(profileError = profileError, workspacesError = workspacesError)
return DrawerDataResult(
profile = profile,
workspaces = workspaces,
activeWorkspaceId = activeWorkspaceId,
profileError = profileError,
workspacesError = workspacesError,
errorCode = errorCode,
didFallbackWorkspace = workspaceResolution.didFallback,
)
}
suspend fun switchWorkspace(workspaceId: String): BoardsApiResult<Unit> {
val normalizedWorkspaceId = workspaceId.trim()
if (normalizedWorkspaceId.isBlank()) {
return BoardsApiResult.Failure("Workspace id is required")
}
val session = when (val sessionResult = authSession()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return sessionResult
}
val previousWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() }
sessionStore.saveWorkspaceId(normalizedWorkspaceId)
val listBoardsResult = apiClient.listBoards(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
workspaceId = normalizedWorkspaceId,
)
return when (listBoardsResult) {
is BoardsApiResult.Success -> BoardsApiResult.Success(Unit)
is BoardsApiResult.Failure -> {
if (previousWorkspaceId != null) {
sessionStore.saveWorkspaceId(previousWorkspaceId)
} else {
sessionStore.clearWorkspaceId()
}
listBoardsResult
}
}
}
suspend fun listBoards(): BoardsApiResult<List<BoardSummary>> {
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
@@ -66,23 +141,31 @@ class BoardsRepository(
}
private suspend fun session(): BoardsApiResult<SessionSnapshot> {
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
val apiKey = withContext(ioDispatcher) {
apiKeyStore.getApiKey(baseUrl)
}.getOrNull()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
val authSession = when (val sessionResult = authSession()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return sessionResult
}
val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = baseUrl, apiKey = apiKey)) {
val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = authSession.baseUrl, apiKey = authSession.apiKey)) {
is BoardsApiResult.Success -> workspaceResult.value
is BoardsApiResult.Failure -> return workspaceResult
}
return BoardsApiResult.Success(
SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey, workspaceId = workspaceId),
SessionSnapshot(baseUrl = authSession.baseUrl, apiKey = authSession.apiKey, workspaceId = workspaceId),
)
}
private suspend fun authSession(): BoardsApiResult<AuthSessionSnapshot> {
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure(MISSING_SESSION_MESSAGE)
val apiKey = withContext(ioDispatcher) {
apiKeyStore.getApiKey(baseUrl)
}.getOrNull()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure(MISSING_SESSION_MESSAGE)
return BoardsApiResult.Success(AuthSessionSnapshot(baseUrl = baseUrl, apiKey = apiKey))
}
private suspend fun resolveWorkspaceId(baseUrl: String, apiKey: String): BoardsApiResult<String> {
val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() }
if (storedWorkspaceId != null) {
@@ -101,9 +184,70 @@ class BoardsRepository(
}
}
private fun resolveActiveWorkspaceForDrawer(
workspaces: List<WorkspaceSummary>,
workspacesError: String?,
): WorkspaceResolution {
val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() }
if (workspacesError != null) {
return WorkspaceResolution(activeWorkspaceId = storedWorkspaceId, didFallback = false)
}
if (workspaces.isEmpty()) {
if (storedWorkspaceId != null) {
sessionStore.clearWorkspaceId()
}
return WorkspaceResolution(activeWorkspaceId = null, didFallback = false)
}
if (storedWorkspaceId != null && workspaces.any { it.id == storedWorkspaceId }) {
return WorkspaceResolution(activeWorkspaceId = storedWorkspaceId, didFallback = false)
}
val fallbackWorkspaceId = workspaces.first().id
sessionStore.saveWorkspaceId(fallbackWorkspaceId)
return WorkspaceResolution(activeWorkspaceId = fallbackWorkspaceId, didFallback = true)
}
private fun deriveDrawerErrorCode(profileError: String?, workspacesError: String?): DrawerDataErrorCode {
val errors = listOfNotNull(profileError, workspacesError)
if (errors.isEmpty()) {
return DrawerDataErrorCode.NONE
}
if (errors.any { isUnauthorizedMessage(it) }) {
return DrawerDataErrorCode.UNAUTHORIZED
}
if (errors.any { isNetworkMessage(it) }) {
return DrawerDataErrorCode.NETWORK
}
return DrawerDataErrorCode.SERVER
}
private fun isUnauthorizedMessage(message: String): Boolean {
return message.isUnauthorizedFailureMessage()
}
private fun isNetworkMessage(message: String): Boolean {
val normalized = message.lowercase()
return "cannot reach server" in normalized || "connection" in normalized || "timeout" in normalized
}
private data class AuthSessionSnapshot(
val baseUrl: String,
val apiKey: String,
)
private data class WorkspaceResolution(
val activeWorkspaceId: String?,
val didFallback: Boolean,
)
private data class SessionSnapshot(
val baseUrl: String,
val apiKey: String,
val workspaceId: String,
)
private companion object {
private const val MISSING_SESSION_MESSAGE = "Missing session. Please sign in again."
}
}

View File

@@ -19,15 +19,18 @@ data class BoardsUiState(
val boards: List<BoardSummary> = emptyList(),
val templates: List<BoardTemplate> = emptyList(),
val isTemplatesLoading: Boolean = false,
val drawer: BoardsDrawerState = BoardsDrawerState(),
)
sealed interface BoardsUiEvent {
interface BoardsUiEvent {
data class NavigateToBoard(val boardId: String, val boardTitle: String) : BoardsUiEvent
data class ShowServerError(val message: String) : BoardsUiEvent
data object ForceSignOut : BoardsUiEvent
}
class BoardsViewModel(
private val repository: BoardsRepository,
private val nowProvider: () -> Long = { System.currentTimeMillis() },
) : ViewModel() {
private val _uiState = MutableStateFlow(BoardsUiState())
val uiState: StateFlow<BoardsUiState> = _uiState.asStateFlow()
@@ -35,14 +38,112 @@ class BoardsViewModel(
private val _events = MutableSharedFlow<BoardsUiEvent>()
val events: SharedFlow<BoardsUiEvent> = _events.asSharedFlow()
private var lastDrawerLoadAtMillis: Long? = null
private var hadDrawerLoadFailureSinceLastSuccess: Boolean = false
fun loadBoards() {
fetchBoards(initial = true)
}
fun loadDrawerData() {
fetchDrawerData()
}
fun loadDrawerDataIfStale() {
if (hadDrawerLoadFailureSinceLastSuccess) {
fetchDrawerData()
return
}
val now = nowProvider()
val isStale = lastDrawerLoadAtMillis?.let { now - it >= DRAWER_STALE_MS } ?: true
if (!isStale) {
return
}
fetchDrawerData()
}
fun retryDrawerData() {
fetchDrawerData()
}
fun onWorkspaceSelected(workspaceId: String) {
val normalizedWorkspaceId = workspaceId.trim()
if (normalizedWorkspaceId.isBlank()) {
return
}
val currentDrawer = _uiState.value.drawer
if (currentDrawer.isWorkspaceSwitchInFlight) {
return
}
val currentWorkspaceId = currentDrawer.activeWorkspaceId
if (currentWorkspaceId == normalizedWorkspaceId) {
return
}
viewModelScope.launch {
_uiState.update {
it.copy(
drawer = it.drawer.copy(
activeWorkspaceId = normalizedWorkspaceId,
isWorkspaceSwitchInFlight = true,
isWorkspaceInteractionEnabled = false,
),
)
}
when (val result = repository.switchWorkspace(normalizedWorkspaceId)) {
is BoardsApiResult.Success -> {
_uiState.update {
it.copy(
drawer = it.drawer.copy(
isWorkspaceSwitchInFlight = false,
isWorkspaceInteractionEnabled = it.drawer.workspaces.isNotEmpty(),
errorCode = DrawerDataErrorCode.NONE,
),
)
}
fetchBoards(initial = false, refresh = true)
}
is BoardsApiResult.Failure -> {
_uiState.update {
it.copy(
drawer = it.drawer.copy(
activeWorkspaceId = currentWorkspaceId,
isWorkspaceSwitchInFlight = false,
isWorkspaceInteractionEnabled = it.drawer.workspaces.isNotEmpty(),
),
)
}
if (result.message.isUnauthorizedFailureMessage()) {
_events.emit(BoardsUiEvent.ForceSignOut)
} else {
_events.emit(BoardsUiEvent.ShowServerError(result.message))
}
}
}
}
}
fun refreshBoards() {
fetchBoards(initial = false, refresh = true)
}
fun clearSessionUiState() {
repository.clearSessionCaches()
lastDrawerLoadAtMillis = null
hadDrawerLoadFailureSinceLastSuccess = false
_uiState.value = BoardsUiState()
}
fun onSettingsApplied(credentialsChanged: Boolean) {
if (!credentialsChanged) {
return
}
fetchDrawerData()
fetchBoards(initial = false, refresh = true)
}
fun loadTemplatesIfNeeded() {
val current = _uiState.value
if (current.templates.isNotEmpty() || current.isTemplatesLoading) {
@@ -144,6 +245,57 @@ class BoardsViewModel(
}
}
private fun fetchDrawerData() {
if (_uiState.value.drawer.isLoading) {
return
}
viewModelScope.launch {
_uiState.update {
it.copy(
drawer = it.drawer.copy(isLoading = true),
)
}
val result = repository.loadDrawerData()
val interactionEnabled = result.workspacesError == null && result.workspaces.isNotEmpty() && result.activeWorkspaceId != null
val retryable = result.errorCode == DrawerDataErrorCode.NETWORK ||
(result.profileError != null || result.workspacesError != null)
_uiState.update {
it.copy(
drawer = it.drawer.copy(
isLoading = false,
profile = result.profile,
workspaces = result.workspaces,
activeWorkspaceId = result.activeWorkspaceId,
profileError = result.profileError,
workspacesError = result.workspacesError,
errorCode = result.errorCode,
isRetryable = retryable,
isWorkspaceInteractionEnabled = interactionEnabled,
),
)
}
if (result.errorCode == DrawerDataErrorCode.NONE) {
lastDrawerLoadAtMillis = nowProvider()
hadDrawerLoadFailureSinceLastSuccess = false
} else {
hadDrawerLoadFailureSinceLastSuccess = true
}
if (result.errorCode == DrawerDataErrorCode.UNAUTHORIZED) {
_events.emit(BoardsUiEvent.ForceSignOut)
return@launch
}
if (result.didFallbackWorkspace && result.activeWorkspaceId != null) {
fetchBoards(initial = false, refresh = true)
}
}
}
private suspend fun refetchBoardsAfterMutation() {
when (val boardsResult = repository.listBoards()) {
is BoardsApiResult.Success -> {
@@ -175,4 +327,8 @@ class BoardsViewModel(
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}
private companion object {
private const val DRAWER_STALE_MS = 2 * 60 * 1000L
}
}

View File

@@ -0,0 +1,222 @@
package space.hackenslacker.kanbn4droid.app.settings
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.commitNow
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.BoardsActivity
import space.hackenslacker.kanbn4droid.app.MainActivity
import space.hackenslacker.kanbn4droid.app.R
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.PreferencesApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyCoordinator
import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyResult
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
class SettingsDialogFragment : DialogFragment() {
lateinit var sessionStore: SessionStore
private set
private lateinit var apiClient: KanbnApiClient
private lateinit var apiKeyStore: ApiKeyStore
private var progress: ProgressBar? = null
private var saveButton: Button? = null
private var errorText: TextView? = null
private var controlsEnabled: Boolean = true
override fun onAttach(context: Context) {
super.onAttach(context)
resolveDependencies(context)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialogView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_settings, null)
childFragmentManager.commitNow {
replace(
R.id.settingsFragmentContainer,
SettingsPreferencesFragment(),
SETTINGS_PREFS_TAG,
)
}
progress = dialogView.findViewById(R.id.settingsApplyProgress)
errorText = dialogView.findViewById(R.id.settingsErrorText)
saveButton = dialogView.findViewById(R.id.settingsSaveAndCloseButton)
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.drawer_settings)
.setView(dialogView)
.setCancelable(false)
.create()
isCancelable = false
dialog.setCanceledOnTouchOutside(false)
dialog.setOnShowListener {
saveButton?.setOnClickListener {
onSaveClicked()
}
setApplyInProgress(false)
}
return dialog
}
private fun onSaveClicked() {
if (!controlsEnabled) {
return
}
val hostActivity = requireActivity() as AppCompatActivity
val resolvedCoordinator = MainActivity.dependencies.settingsApplyCoordinatorFactory?.invoke(
hostActivity,
sessionStore,
apiClient,
apiKeyStore,
)
?: SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
lifecycleScope.launch {
setApplyInProgress(true)
val previousTheme = sessionStore.getThemeMode()
when (val result = resolvedCoordinator.apply()) {
is SettingsApplyResult.SuccessCredentialChange -> {
val themeChanged = sessionStore.getThemeMode() != previousTheme
if (themeChanged) {
applyThemeFrom(sessionStore.getThemeMode())
}
parentFragmentManager.setFragmentResult(
REQUEST_KEY_SETTINGS_APPLIED,
bundleOf(
RESULT_KEY_CREDENTIALS_CHANGED to true,
RESULT_KEY_THEME_CHANGED to themeChanged,
),
)
dismissAllowingStateLoss()
}
is SettingsApplyResult.SuccessNoCredentialChange -> {
if (result.themeChanged) {
applyThemeFrom(sessionStore.getThemeMode())
}
parentFragmentManager.setFragmentResult(
REQUEST_KEY_SETTINGS_APPLIED,
bundleOf(
RESULT_KEY_CREDENTIALS_CHANGED to false,
RESULT_KEY_THEME_CHANGED to result.themeChanged,
),
)
dismissAllowingStateLoss()
}
SettingsApplyResult.NoChanges -> {
parentFragmentManager.setFragmentResult(
REQUEST_KEY_SETTINGS_APPLIED,
bundleOf(
RESULT_KEY_CREDENTIALS_CHANGED to false,
RESULT_KEY_THEME_CHANGED to false,
),
)
dismissAllowingStateLoss()
}
is SettingsApplyResult.ValidationError -> {
setApplyInProgress(false)
showError(result.message)
findPreferencesFragment()?.focusField(result.field)
}
is SettingsApplyResult.AuthError -> {
setApplyInProgress(false)
showError(result.message)
}
is SettingsApplyResult.NetworkError -> {
setApplyInProgress(false)
showError(result.message)
}
}
}
}
private fun setApplyInProgress(inProgress: Boolean) {
controlsEnabled = !inProgress
progress?.visibility = if (inProgress) android.view.View.VISIBLE else android.view.View.GONE
saveButton?.isEnabled = !inProgress
findPreferencesFragment()?.preferenceScreen?.isEnabled = !inProgress
if (inProgress) {
errorText?.visibility = android.view.View.GONE
}
}
private fun showError(message: String) {
errorText?.text = message
errorText?.visibility = android.view.View.VISIBLE
}
private fun findPreferencesFragment(): SettingsPreferencesFragment? {
return childFragmentManager.findFragmentByTag(SETTINGS_PREFS_TAG) as? SettingsPreferencesFragment
}
private fun applyThemeFrom(themeMode: String) {
val mode = when (themeMode.lowercase()) {
"light" -> AppCompatDelegate.MODE_NIGHT_NO
"dark" -> AppCompatDelegate.MODE_NIGHT_YES
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
AppCompatDelegate.setDefaultNightMode(mode)
}
private fun resolveDependencies(context: Context) {
val host = activity as? BoardsActivity
if (host != null) {
sessionStore = host.sessionStoreForSettingsDialog
apiClient = host.apiClientForSettingsDialog
apiKeyStore = host.apiKeyStoreForSettingsDialog
return
}
val appCompatActivity = activity as? AppCompatActivity
if (appCompatActivity != null) {
sessionStore = MainActivity.dependencies.sessionStoreFactory?.invoke(appCompatActivity)
?: SessionPreferences(appCompatActivity.applicationContext)
apiClient = MainActivity.dependencies.apiClientFactory?.invoke()
?: HttpKanbnApiClient()
apiKeyStore = MainActivity.dependencies.apiKeyStoreFactory?.invoke(appCompatActivity)
?: PreferencesApiKeyStore(appCompatActivity)
return
}
sessionStore = SessionPreferences(context.applicationContext)
apiClient = HttpKanbnApiClient()
apiKeyStore = PreferencesApiKeyStore(context)
}
companion object {
const val TAG: String = "settings_dialog"
const val REQUEST_KEY_SETTINGS_APPLIED: String = "settings_applied_result"
const val RESULT_KEY_CREDENTIALS_CHANGED: String = "credentials_changed"
const val RESULT_KEY_THEME_CHANGED: String = "theme_changed"
private const val SETTINGS_PREFS_TAG: String = "settings_preferences_fragment"
fun newInstance(): SettingsDialogFragment {
return SettingsDialogFragment()
}
}
}

View File

@@ -0,0 +1,75 @@
package space.hackenslacker.kanbn4droid.app.settings
import android.os.Bundle
import android.text.InputType
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import space.hackenslacker.kanbn4droid.app.R
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
class SettingsPreferencesFragment : PreferenceFragmentCompat() {
private lateinit var sessionStore: SessionStore
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
sessionStore = requireNotNull((requireParentFragment() as SettingsDialogFragment).sessionStore)
setPreferencesFromResource(R.xml.settings_preferences, rootKey)
bindThemePreference()
bindBaseUrlPreference()
bindApiKeyPreference()
}
fun focusField(field: String) {
when (field) {
FIELD_BASE_URL -> findPreference<Preference>(KEY_BASE_URL)?.performClick()
FIELD_API_KEY -> findPreference<Preference>(KEY_API_KEY)?.performClick()
}
}
private fun bindThemePreference() {
val pref = findPreference<ListPreference>(KEY_THEME) ?: return
pref.value = sessionStore.getDraftThemeMode().ifBlank { "system" }
pref.setOnPreferenceChangeListener { _, newValue ->
sessionStore.saveDraftThemeMode(newValue?.toString().orEmpty())
true
}
}
private fun bindBaseUrlPreference() {
val pref = findPreference<EditTextPreference>(KEY_BASE_URL) ?: return
pref.text = sessionStore.getDraftBaseUrl().orEmpty()
pref.setOnPreferenceChangeListener { _, newValue ->
sessionStore.saveDraftBaseUrl(newValue?.toString().orEmpty())
true
}
}
private fun bindApiKeyPreference() {
val pref = findPreference<EditTextPreference>(KEY_API_KEY) ?: return
pref.setOnBindEditTextListener { editText ->
editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
pref.text = sessionStore.getDraftApiKey().orEmpty()
pref.summaryProvider = Preference.SummaryProvider<EditTextPreference> { preference ->
if (preference.text.isNullOrBlank()) {
getString(R.string.settings_api_key_summary_not_configured)
} else {
getString(R.string.settings_api_key_summary_configured)
}
}
pref.setOnPreferenceChangeListener { _, newValue ->
sessionStore.saveDraftApiKey(newValue?.toString().orEmpty())
true
}
}
companion object {
const val KEY_THEME: String = "pref_theme_draft"
const val KEY_BASE_URL: String = "pref_base_url_draft"
const val KEY_API_KEY: String = "pref_api_key_draft"
const val FIELD_BASE_URL: String = "baseUrl"
const val FIELD_API_KEY: String = "apiKey"
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.14" android:color="?attr/colorPrimary" android:state_activated="true" />
<item android:alpha="0.14" android:color="?attr/colorPrimary" android:state_selected="true" />
<item android:color="?attr/colorSurface" />
</selector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.70" android:color="?attr/colorPrimary" android:state_activated="true" />
<item android:alpha="0.70" android:color="?attr/colorPrimary" android:state_selected="true" />
<item android:alpha="0.20" android:color="?attr/colorOnSurface" />
</selector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?attr/colorPrimary" android:state_activated="true" />
<item android:color="?attr/colorPrimary" android:state_selected="true" />
<item android:color="?attr/colorOnSurface" />
</selector>

View File

@@ -1,60 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/boardsDrawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/boardsToolbar"
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/ThemeOverlay.MaterialComponents.ActionBar"
app:title="@string/boards_title" />
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/boardsSwipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="?attr/actionBarSize">
<FrameLayout
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/boardsToolbar"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="wrap_content"
android:theme="@style/ThemeOverlay.MaterialComponents.ActionBar"
app:title="@string/boards_title" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/boardsRecyclerView"
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/boardsSwipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="?attr/actionBarSize">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="16dp" />
>
<TextView
android:id="@+id/boardsEmptyStateText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:text="@string/boards_empty_state"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/boardsRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="16dp" />
<ProgressBar
android:id="@+id/boardsInitialProgress"
style="@style/Widget.AppCompat.ProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<TextView
android:id="@+id/boardsEmptyStateText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:text="@string/boards_empty_state"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:visibility="gone" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/createBoardFab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="@string/create_board"
app:srcCompat="@android:drawable/ic_input_add" />
<ProgressBar
android:id="@+id/boardsInitialProgress"
style="@style/Widget.AppCompat.ProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/createBoardFab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="@string/create_board"
app:srcCompat="@android:drawable/ic_input_add" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<include
layout="@layout/view_boards_drawer"
android:layout_width="@dimen/boards_drawer_max_width"
android:layout_height="match_parent"
android:layout_gravity="start" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="8dp"
android:paddingEnd="24dp"
android:paddingBottom="8dp">
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fillViewport="true">
<FrameLayout
android:id="@+id/settingsFragmentContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="320dp" />
</ScrollView>
<ProgressBar
android:id="@+id/settingsApplyProgress"
style="@style/Widget.AppCompat.ProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="12dp"
android:visibility="gone" />
<TextView
android:id="@+id/settingsErrorText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/settingsSaveAndCloseButton"
style="@style/Widget.Material3.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="12dp"
android:text="@string/settings_save_and_close" />
</LinearLayout>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
app:cardBackgroundColor="@color/workspace_drawer_row_background"
app:cardCornerRadius="12dp"
app:strokeColor="@color/workspace_drawer_row_stroke"
app:strokeWidth="1dp">
<TextView
android:id="@+id/workspaceTitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:duplicateParentState="true"
android:paddingStart="16dp"
android:paddingTop="12dp"
android:paddingEnd="16dp"
android:paddingBottom="12dp"
android:textColor="@color/workspace_drawer_row_text"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1" />
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/boardsDrawerContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="24dp"
android:paddingEnd="16dp"
android:paddingBottom="16dp">
<TextView
android:id="@+id/drawerUsernameText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/drawer_profile_unavailable"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textStyle="bold" />
<TextView
android:id="@+id/drawerEmailText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/drawer_profile_unavailable"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2" />
<Button
android:id="@+id/drawerSettingsButton"
style="?attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/drawer_settings" />
<TextView
android:id="@+id/drawerWorkspacesTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/drawer_workspaces"
android:textAppearance="@style/TextAppearance.MaterialComponents.Overline" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/drawerWorkspacesRecyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:clipToPadding="false"
android:paddingBottom="8dp" />
<ProgressBar
android:id="@+id/drawerLoadingIndicator"
style="@style/Widget.AppCompat.ProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="8dp"
android:visibility="gone" />
<TextView
android:id="@+id/drawerErrorText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/drawer_workspaces_unavailable"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:visibility="gone" />
<Button
android:id="@+id/drawerRetryButton"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/drawer_retry"
android:visibility="gone" />
<Button
android:id="@+id/drawerLogoutButton"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/drawer_logout" />
</LinearLayout>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="boards_drawer_max_width">360dp</dimen>
</resources>

View File

@@ -84,4 +84,30 @@
<string name="add">Add</string>
<string name="card_detail_comment_required">Comment is required</string>
<string name="card_detail_comment_added">Comment added</string>
<string name="drawer_settings">Settings</string>
<string name="drawer_workspaces">Workspaces</string>
<string name="drawer_logout">Log out</string>
<string name="drawer_logout_confirmation_message">Log out and clear this device session?</string>
<string name="drawer_retry">Retry</string>
<string name="drawer_profile_unavailable">Profile unavailable</string>
<string name="drawer_workspaces_unavailable">Workspaces unavailable</string>
<string name="drawer_loading">Loading...</string>
<string name="settings_theme_title">Theme</string>
<string name="settings_base_url_title">Base URL</string>
<string name="settings_api_key_title">API key</string>
<string name="settings_api_key_summary_not_configured">Not configured</string>
<string name="settings_api_key_summary_configured">Configured</string>
<string name="settings_save_and_close">Save and close</string>
<string-array name="settings_theme_entries">
<item>Light</item>
<item>Dark</item>
<item>Follow System</item>
</string-array>
<string-array name="settings_theme_values">
<item>light</item>
<item>dark</item>
<item>system</item>
</string-array>
</resources>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ListPreference
android:defaultValue="system"
android:entries="@array/settings_theme_entries"
android:entryValues="@array/settings_theme_values"
android:key="pref_theme_draft"
android:title="@string/settings_theme_title" />
<EditTextPreference
android:defaultValue="@string/default_base_url"
android:key="pref_base_url_draft"
android:title="@string/settings_base_url_title"
app:useSimpleSummaryProvider="true" />
<EditTextPreference
android:key="pref_api_key_draft"
android:inputType="textPassword"
android:summary="@string/settings_api_key_summary_not_configured"
android:title="@string/settings_api_key_title"
app:useSimpleSummaryProvider="false" />
</PreferenceScreen>

View File

@@ -0,0 +1,38 @@
package space.hackenslacker.kanbn4droid.app.auth
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class ApiKeyStoreKeyDerivationTest {
@Test
fun equivalentNormalizedBaseUrlsProduceSamePreferenceKey() {
val first = deriveApiKeyPreferenceKey(" HTTPS://KAN.BN/api/v1 ")
val second = deriveApiKeyPreferenceKey("https://kan.bn/api/v1/")
val third = deriveApiKeyPreferenceKey("https://kan.bn/api/v1")
assertEquals(first, second)
assertEquals(second, third)
}
@Test
fun differentNormalizedBaseUrlsProduceDifferentPreferenceKeys() {
val first = deriveApiKeyPreferenceKey("https://kan.bn/")
val second = deriveApiKeyPreferenceKey("https://next.kan.bn/")
assertNotEquals(first, second)
}
@Test
fun malformedBaseUrlsUseTrimmedDeterministicFallback() {
val first = deriveApiKeyPreferenceKey("not a url")
val second = deriveApiKeyPreferenceKey("not a url")
val trimmedEquivalent = deriveApiKeyPreferenceKey(" not a url ")
val different = deriveApiKeyPreferenceKey("::://")
assertEquals(first, second)
assertEquals(first, trimmedEquivalent)
assertNotEquals(first, different)
}
}

View File

@@ -0,0 +1,251 @@
package space.hackenslacker.kanbn4droid.app.auth
import java.io.BufferedInputStream
import java.io.OutputStream
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
import space.hackenslacker.kanbn4droid.app.boards.DrawerProfile
class HttpKanbnApiClientUsersMeParsingTest {
@Test
fun getCurrentUser_parsesProfileFromWrappedPayload() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/users/me",
method = "GET",
status = 200,
responseBody =
"""
{
"data": {
"user": {
"name": "Alice",
"email": "alice@example.com"
}
}
}
""".trimIndent(),
)
val result = HttpKanbnApiClient().getCurrentUser(server.baseUrl, "api")
assertTrue(result is BoardsApiResult.Success<*>)
val profile = (result as BoardsApiResult.Success<*>).value as DrawerProfile
assertEquals("Alice", profile.displayName)
assertEquals("alice@example.com", profile.email)
}
}
@Test
fun getCurrentUser_usesFallbackKeys_usernameNameEmail() = runTest {
TestServer().use { server ->
server.registerSequence(
path = "/api/v1/users/me",
method = "GET",
responses = listOf(
200 to """{"username":"alpha","email":"a@example.com"}""",
200 to """{"name":"beta","email":"b@example.com"}""",
200 to """{"email":"c@example.com"}""",
),
)
val client = HttpKanbnApiClient()
val first = client.getCurrentUser(server.baseUrl, "api")
val second = client.getCurrentUser(server.baseUrl, "api")
val third = client.getCurrentUser(server.baseUrl, "api")
assertEquals("alpha", (first as BoardsApiResult.Success<DrawerProfile>).value.displayName)
assertEquals("beta", (second as BoardsApiResult.Success<DrawerProfile>).value.displayName)
assertEquals("c@example.com", (third as BoardsApiResult.Success<DrawerProfile>).value.displayName)
}
}
@Test
fun getCurrentUser_usesServerMessageOnFailure() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/users/me",
method = "GET",
status = 403,
responseBody = """{"message":"Invalid API key"}""",
)
val result = HttpKanbnApiClient().getCurrentUser(server.baseUrl, "api")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("Invalid API key", (result as BoardsApiResult.Failure).message)
}
}
@Test
fun getCurrentUser_requestMappingUsesExpectedEndpointAndApiKeyHeader() = runTest {
TestServer().use { server ->
server.register(path = "/api/v1/users/me", method = "GET", status = 200, responseBody = """{"username":"mapped"}""")
val result = HttpKanbnApiClient().getCurrentUser(server.baseUrl, "api-123")
assertTrue(result is BoardsApiResult.Success<*>)
val request = server.findRequest("GET", "/api/v1/users/me")
assertNotNull(request)
assertEquals("api-123", request?.apiKey)
}
}
private data class CapturedRequest(
val method: String,
val path: String,
val body: String,
val apiKey: String?,
)
private class TestServer : AutoCloseable {
private val requests = CopyOnWriteArrayList<CapturedRequest>()
private val responses = mutableMapOf<String, Pair<Int, String>>()
private val responseSequences = mutableMapOf<String, ArrayDeque<Pair<Int, String>>>()
private val running = AtomicBoolean(true)
private val serverSocket = ServerSocket().apply {
bind(InetSocketAddress("127.0.0.1", 0))
}
private val executor = Executors.newSingleThreadExecutor()
val baseUrl: String = "http://127.0.0.1:${serverSocket.localPort}"
init {
executor.execute {
while (running.get()) {
val socket = try {
serverSocket.accept()
} catch (_: Throwable) {
if (!running.get()) {
break
}
continue
}
handle(socket)
}
}
}
fun register(path: String, method: String, status: Int, responseBody: String) {
responses["${method.uppercase()} $path"] = status to responseBody
}
fun registerSequence(path: String, method: String, responses: List<Pair<Int, String>>) {
responseSequences["${method.uppercase()} $path"] = ArrayDeque(responses)
}
fun findRequest(method: String, path: String): CapturedRequest? {
return requests.firstOrNull { it.method == method && it.path == path }
}
private fun handle(socket: Socket) {
socket.use { s ->
s.soTimeout = 3_000
val input = BufferedInputStream(s.getInputStream())
val output = s.getOutputStream()
val requestLine = readHttpLine(input).orEmpty()
if (requestLine.isBlank()) {
return
}
val parts = requestLine.split(" ")
val method = parts.getOrNull(0).orEmpty()
val path = parts.getOrNull(1).orEmpty()
var apiKey: String? = null
var contentLength = 0
while (true) {
val line = readHttpLine(input).orEmpty()
if (line.isBlank()) {
break
}
val separatorIndex = line.indexOf(':')
if (separatorIndex <= 0) {
continue
}
val headerName = line.substring(0, separatorIndex).trim().lowercase()
val headerValue = line.substring(separatorIndex + 1).trim()
if (headerName == "x-api-key") {
apiKey = headerValue
} else if (headerName == "content-length") {
contentLength = headerValue.toIntOrNull() ?: 0
}
}
val bodyBytes = if (contentLength > 0) ByteArray(contentLength) else ByteArray(0)
if (contentLength > 0) {
var total = 0
while (total < contentLength) {
val read = input.read(bodyBytes, total, contentLength - total)
if (read <= 0) {
break
}
total += read
}
}
val body = String(bodyBytes)
requests += CapturedRequest(method = method, path = path, body = body, apiKey = apiKey)
val sequenceKey = "$method $path"
val sequence = responseSequences[sequenceKey]
val sequencedResponse = if (sequence != null && sequence.isNotEmpty()) sequence.removeFirst() else null
val response = sequencedResponse ?: responses[sequenceKey] ?: (404 to "")
writeResponse(output, response.first, response.second)
}
}
private fun writeResponse(output: OutputStream, status: Int, body: String) {
val bytes = body.toByteArray()
val reason = when (status) {
200 -> "OK"
403 -> "Forbidden"
404 -> "Not Found"
else -> "Error"
}
val responseHeaders =
"HTTP/1.1 $status $reason\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: ${bytes.size}\r\n" +
"Connection: close\r\n\r\n"
output.write(responseHeaders.toByteArray())
output.write(bytes)
output.flush()
}
private fun readHttpLine(input: BufferedInputStream): String? {
val builder = StringBuilder()
while (true) {
val next = input.read()
if (next == -1) {
return if (builder.isEmpty()) null else builder.toString()
}
if (next == '\n'.code) {
if (builder.isNotEmpty() && builder.last() == '\r') {
builder.deleteCharAt(builder.length - 1)
}
return builder.toString()
}
builder.append(next.toChar())
}
}
override fun close() {
running.set(false)
serverSocket.close()
executor.shutdownNow()
executor.awaitTermination(3, TimeUnit.SECONDS)
}
}
}

View File

@@ -0,0 +1,359 @@
package space.hackenslacker.kanbn4droid.app.auth
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class SettingsApplyCoordinatorTest {
@Test
fun applyNoChangesReturnsNoChangesWithoutAuthCall() = runTest {
val sessionStore = FakeSessionStore().apply {
saveDraftApiKey("truth-key")
saveApiKey("session-key")
}
val apiClient = FakeKanbnApiClient()
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "truth-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertEquals(SettingsApplyResult.NoChanges, result)
assertEquals(0, apiClient.healthCheckCalls.size)
assertEquals(1, apiKeyStore.getCalls.size)
assertTrue(apiKeyStore.saveCalls.isEmpty())
assertTrue(apiKeyStore.invalidateCalls.isEmpty())
}
@Test
fun applyCredentialChangeSuccessPersistsAndReturnsSuccessCredentialChange() = runTest {
val sessionStore = FakeSessionStore().apply {
saveDraftBaseUrl("https://next.kan.bn")
saveDraftApiKey("next-key")
}
val apiClient = FakeKanbnApiClient(
healthCheckResult = AuthResult.Success,
)
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "existing-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertEquals(SettingsApplyResult.SuccessCredentialChange, result)
assertEquals(1, apiClient.healthCheckCalls.size)
assertEquals("https://next.kan.bn/", apiClient.healthCheckCalls.single().baseUrl)
assertEquals("next-key", apiClient.healthCheckCalls.single().apiKey)
assertEquals("https://next.kan.bn/", sessionStore.getBaseUrl())
assertEquals("next-key", sessionStore.getApiKey())
assertEquals(sessionStore.getBaseUrl(), sessionStore.getDraftBaseUrl())
assertEquals(sessionStore.getApiKey(), sessionStore.getDraftApiKey())
assertEquals("next-key", apiKeyStore.peek("https://next.kan.bn/"))
assertEquals(null, apiKeyStore.peek("https://kan.bn/"))
assertEquals(1, apiKeyStore.saveCalls.size)
assertEquals(1, apiKeyStore.invalidateCalls.size)
}
@Test
fun applyAuthFailureRollsBackCommittedAndDraftValues() = runTest {
val sessionStore = FakeSessionStore().apply {
saveDraftBaseUrl("https://broken.kan.bn")
saveDraftApiKey("bad-key")
}
val apiClient = FakeKanbnApiClient(
healthCheckResult = AuthResult.Failure(
message = "Authentication failed. Check your API key.",
reason = AuthFailureReason.Authentication,
),
)
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "existing-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertEquals(
SettingsApplyResult.AuthError("Authentication failed. Check your API key."),
result,
)
assertEquals("https://kan.bn/", sessionStore.getBaseUrl())
assertEquals("existing-key", sessionStore.getApiKey())
assertEquals("https://kan.bn/", sessionStore.getDraftBaseUrl())
assertEquals("existing-key", sessionStore.getDraftApiKey())
assertEquals("existing-key", apiKeyStore.peek("https://kan.bn/"))
assertEquals(null, apiKeyStore.peek("https://broken.kan.bn/"))
}
@Test
fun applyPersistFailureAfterAuthRollsBack() = runTest {
val sessionStore = FakeSessionStore(
failSyncToCommitted = true,
).apply {
saveDraftBaseUrl("https://next.kan.bn")
saveDraftApiKey("next-key")
}
val apiClient = FakeKanbnApiClient(
healthCheckResult = AuthResult.Success,
)
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "existing-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertTrue(result is SettingsApplyResult.NetworkError)
result as SettingsApplyResult.NetworkError
assertTrue(result.message.contains("apply", ignoreCase = true))
assertEquals("https://kan.bn/", sessionStore.getBaseUrl())
assertEquals("existing-key", sessionStore.getApiKey())
assertEquals("https://kan.bn/", sessionStore.getDraftBaseUrl())
assertEquals("existing-key", sessionStore.getDraftApiKey())
assertEquals("existing-key", apiKeyStore.peek("https://kan.bn/"))
assertEquals(null, apiKeyStore.peek("https://next.kan.bn/"))
}
@Test
fun applyInvalidUrlReturnsValidationError() = runTest {
val sessionStore = FakeSessionStore().apply {
saveDraftBaseUrl("ftp://kan.bn")
}
val apiClient = FakeKanbnApiClient()
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "existing-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertEquals(
SettingsApplyResult.ValidationError(
field = "baseUrl",
message = "Base URL must start with http:// or https://",
),
result,
)
assertEquals(0, apiClient.healthCheckCalls.size)
assertEquals("https://kan.bn/", sessionStore.getDraftBaseUrl())
assertEquals("existing-key", sessionStore.getDraftApiKey())
assertTrue(apiKeyStore.saveCalls.isEmpty())
assertTrue(apiKeyStore.invalidateCalls.isEmpty())
}
@Test
fun applyBlankApiKeyReturnsValidationErrorForApiKey() = runTest {
val sessionStore = FakeSessionStore().apply {
saveDraftApiKey(" ")
}
val apiClient = FakeKanbnApiClient()
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "existing-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertEquals(
SettingsApplyResult.ValidationError(
field = "apiKey",
message = "API key is required",
),
result,
)
assertEquals(0, apiClient.healthCheckCalls.size)
assertEquals("existing-key", sessionStore.getDraftApiKey())
assertTrue(apiKeyStore.saveCalls.isEmpty())
assertTrue(apiKeyStore.invalidateCalls.isEmpty())
}
@Test
fun applyThemeAndCredentialsFailureRollsBackThemeDraft() = runTest {
val sessionStore = FakeSessionStore().apply {
saveDraftThemeMode("dark")
saveDraftBaseUrl("https://next.kan.bn")
saveDraftApiKey("next-key")
}
val apiClient = FakeKanbnApiClient(
healthCheckResult = AuthResult.Failure(
message = "Cannot reach server. Check your connection and URL.",
reason = AuthFailureReason.Connectivity,
),
)
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "existing-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertEquals(
SettingsApplyResult.NetworkError("Cannot reach server. Check your connection and URL."),
result,
)
assertEquals("system", sessionStore.getThemeMode())
assertEquals("system", sessionStore.getDraftThemeMode())
assertEquals("existing-key", apiKeyStore.peek("https://kan.bn/"))
assertEquals(null, apiKeyStore.peek("https://next.kan.bn/"))
}
@Test
fun applyThemeChangeSuccessSignalsThemeMode() = runTest {
val sessionStore = FakeSessionStore().apply {
saveDraftThemeMode("dark")
}
val apiClient = FakeKanbnApiClient()
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "existing-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertEquals(SettingsApplyResult.SuccessNoCredentialChange(themeChanged = true), result)
assertEquals(0, apiClient.healthCheckCalls.size)
assertEquals("dark", sessionStore.getThemeMode())
assertEquals("dark", sessionStore.getDraftThemeMode())
assertTrue(apiKeyStore.saveCalls.isEmpty())
assertTrue(apiKeyStore.invalidateCalls.isEmpty())
}
private class FakeSessionStore(
private val failSyncToCommitted: Boolean = false,
) : SessionStore {
private var committedThemeMode: String = "system"
private var committedBaseUrl: String? = "https://kan.bn/"
private var committedApiKey: String? = "existing-key"
private var draftThemeMode: String = committedThemeMode
private var draftBaseUrl: String? = committedBaseUrl
private var draftApiKey: String? = committedApiKey
override fun getThemeMode(): String = committedThemeMode
override fun saveThemeMode(themeMode: String) {
committedThemeMode = themeMode
}
override fun getBaseUrl(): String? = committedBaseUrl
override fun saveBaseUrl(url: String) {
committedBaseUrl = url
}
override fun getApiKey(): String? = committedApiKey
override fun saveApiKey(apiKey: String) {
committedApiKey = apiKey
}
override fun clearApiKey() {
committedApiKey = null
}
override fun getWorkspaceId(): String? = null
override fun saveWorkspaceId(workspaceId: String) {
}
override fun clearBaseUrl() {
committedBaseUrl = null
}
override fun clearWorkspaceId() {
}
override fun initializeDraftsFromCommitted() {
draftThemeMode = committedThemeMode
draftBaseUrl = committedBaseUrl
draftApiKey = committedApiKey
}
override fun getDraftThemeMode(): String = draftThemeMode
override fun saveDraftThemeMode(themeMode: String) {
draftThemeMode = themeMode
}
override fun getDraftBaseUrl(): String? = draftBaseUrl
override fun saveDraftBaseUrl(url: String) {
draftBaseUrl = url
}
override fun getDraftApiKey(): String? = draftApiKey
override fun saveDraftApiKey(apiKey: String) {
draftApiKey = apiKey
}
override fun resetDraftsFromCommitted() {
initializeDraftsFromCommitted()
}
override fun syncDraftsToCommitted() {
committedThemeMode = draftThemeMode
committedBaseUrl = draftBaseUrl
committedApiKey = draftApiKey
if (failSyncToCommitted) {
throw IllegalStateException("sync failed")
}
}
}
private class FakeKanbnApiClient(
private val healthCheckResult: AuthResult = AuthResult.Success,
) : KanbnApiClient {
val healthCheckCalls = mutableListOf<HealthCheckCall>()
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult {
healthCheckCalls += HealthCheckCall(baseUrl = baseUrl, apiKey = apiKey)
return healthCheckResult
}
}
private data class HealthCheckCall(
val baseUrl: String,
val apiKey: String,
)
private class FakeApiKeyStore : ApiKeyStore {
private val keysByBaseUrl = mutableMapOf<String, String>()
val getCalls = mutableListOf<String>()
val saveCalls = mutableListOf<SaveCall>()
val invalidateCalls = mutableListOf<String>()
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
saveCalls += SaveCall(baseUrl = baseUrl, apiKey = apiKey)
keysByBaseUrl[baseUrl] = apiKey
return Result.success(Unit)
}
override suspend fun getApiKey(baseUrl: String): Result<String?> {
getCalls += baseUrl
return Result.success(keysByBaseUrl[baseUrl])
}
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
invalidateCalls += baseUrl
keysByBaseUrl.remove(baseUrl)
return Result.success(Unit)
}
fun setKey(baseUrl: String, apiKey: String) {
keysByBaseUrl[baseUrl] = apiKey
}
fun peek(baseUrl: String): String? = keysByBaseUrl[baseUrl]
}
private data class SaveCall(
val baseUrl: String,
val apiKey: String,
)
}

View File

@@ -1,10 +1,11 @@
package space.hackenslacker.kanbn4droid.app.boards
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
@@ -112,25 +113,425 @@ class BoardsViewModelTest {
assertFalse(viewModel.uiState.value.isMutating)
}
private fun newViewModel(apiClient: FakeBoardsApiClient): BoardsViewModel {
@Test
fun loadDrawerDataSuccessPopulatesProfileAndWorkspaces() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
workspacesResult = BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
)
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-2")
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
val drawer = viewModel.uiState.value.drawer
assertEquals("Alice", drawer.profile?.displayName)
assertEquals(2, drawer.workspaces.size)
assertEquals("ws-2", drawer.activeWorkspaceId)
assertEquals(DrawerDataErrorCode.NONE, drawer.errorCode)
assertTrue(drawer.isWorkspaceInteractionEnabled)
}
@Test
fun workspaceSwitchIgnoresSecondTapWhileInFlight() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
)
listBoardsResult = BoardsApiResult.Success(emptyList())
blockListBoards = CompletableDeferred()
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
viewModel.onWorkspaceSelected("ws-2")
viewModel.onWorkspaceSelected("ws-1")
advanceUntilIdle()
assertEquals("ws-2", viewModel.uiState.value.drawer.activeWorkspaceId)
assertEquals("ws-2", sessionStore.getWorkspaceId())
assertEquals(1, api.listBoardsCalls)
api.blockListBoards?.complete(Unit)
advanceUntilIdle()
}
@Test
fun drawerUnauthorizedEmitsSignOutEvent() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Failure("Server error: 401")
workspacesResult = BoardsApiResult.Failure("Server error: 401")
}
val viewModel = newViewModel(api)
val eventDeferred = async { viewModel.events.first() }
viewModel.loadDrawerData()
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is BoardsUiEvent.ForceSignOut)
}
@Test
fun loadDrawerDataEmptyWorkspaceListSetsEmptyState() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Success(emptyList())
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-stale")
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
val drawer = viewModel.uiState.value.drawer
assertTrue(drawer.workspaces.isEmpty())
assertEquals(null, drawer.activeWorkspaceId)
assertFalse(drawer.isWorkspaceInteractionEnabled)
}
@Test
fun loadDrawerDataEmptyWorkspaceDisablesWorkspaceInteraction() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Failure("Server error: 500")
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = null)
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.drawer.isWorkspaceInteractionEnabled)
}
@Test
fun loadDrawerDataPartialFailureExposesRetryableState() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Failure("Cannot reach server. Check your connection and URL.")
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
}
val viewModel = newViewModel(api)
viewModel.loadDrawerData()
advanceUntilIdle()
val drawer = viewModel.uiState.value.drawer
assertEquals(DrawerDataErrorCode.NETWORK, drawer.errorCode)
assertTrue(drawer.isRetryable)
}
@Test
fun loadDrawerDataWorkspacesFailureDisablesWorkspaceInteraction() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Failure("Server error: 503")
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
val drawer = viewModel.uiState.value.drawer
assertEquals("ws-1", drawer.activeWorkspaceId)
assertFalse(drawer.isWorkspaceInteractionEnabled)
}
@Test
fun retryDrawerDataAfterFailureSucceeds() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResults.addLast(BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."))
usersMeResults.addLast(BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null)))
workspacesResults.addLast(BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."))
workspacesResults.addLast(BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))))
}
val viewModel = newViewModel(api)
viewModel.loadDrawerData()
advanceUntilIdle()
assertTrue(viewModel.uiState.value.drawer.isRetryable)
viewModel.retryDrawerData()
advanceUntilIdle()
val drawer = viewModel.uiState.value.drawer
assertEquals(DrawerDataErrorCode.NONE, drawer.errorCode)
assertFalse(drawer.isRetryable)
assertEquals("Alice", drawer.profile?.displayName)
}
@Test
fun loadDrawerDataIfStaleSkipsFreshDataAndReloadsWhenStale() = runTest {
var now = 1_000L
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
}
val viewModel = newViewModel(apiClient = api, nowProvider = { now })
viewModel.loadDrawerData()
advanceUntilIdle()
assertEquals(1, api.listWorkspacesCalls)
assertEquals(1, api.getCurrentUserCalls)
now += 10_000L
viewModel.loadDrawerDataIfStale()
advanceUntilIdle()
assertEquals(1, api.listWorkspacesCalls)
assertEquals(1, api.getCurrentUserCalls)
now += 180_000L
viewModel.loadDrawerDataIfStale()
advanceUntilIdle()
assertEquals(2, api.listWorkspacesCalls)
assertEquals(2, api.getCurrentUserCalls)
}
@Test
fun loadDrawerDataIfStaleRefetchesImmediatelyAfterFailedLoad() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResults.addLast(BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."))
usersMeResults.addLast(BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null)))
workspacesResults.addLast(BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."))
workspacesResults.addLast(BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))))
}
val viewModel = newViewModel(apiClient = api, nowProvider = { 1_000L })
viewModel.loadDrawerData()
advanceUntilIdle()
assertEquals(DrawerDataErrorCode.NETWORK, viewModel.uiState.value.drawer.errorCode)
assertEquals(1, api.listWorkspacesCalls)
assertEquals(1, api.getCurrentUserCalls)
viewModel.loadDrawerDataIfStale()
advanceUntilIdle()
assertEquals(DrawerDataErrorCode.NONE, viewModel.uiState.value.drawer.errorCode)
assertEquals(2, api.listWorkspacesCalls)
assertEquals(2, api.getCurrentUserCalls)
}
@Test
fun loadDrawerDataFallbackWorkspacePersistsFirstAndTriggersBoardsRefresh() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
)
listBoardsResult = BoardsApiResult.Success(emptyList())
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-missing")
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
assertEquals("ws-1", sessionStore.getWorkspaceId())
assertEquals(1, api.listBoardsCalls)
}
@Test
fun workspaceSwitchFailureRestoresUiAndPersistedWorkspaceId() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
)
listBoardsResults.addLast(BoardsApiResult.Failure("Server error: 500"))
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
viewModel.onWorkspaceSelected("ws-2")
advanceUntilIdle()
assertEquals("ws-1", viewModel.uiState.value.drawer.activeWorkspaceId)
assertEquals("ws-1", sessionStore.getWorkspaceId())
assertEquals(1, api.listBoardsCalls)
}
@Test
fun workspaceSwitchUnauthorizedEmitsForceSignOut() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
)
listBoardsResults.addLast(BoardsApiResult.Failure("Server error: 401"))
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
val eventDeferred = async { viewModel.events.first() }
viewModel.onWorkspaceSelected("ws-2")
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is BoardsUiEvent.ForceSignOut)
assertEquals("ws-1", sessionStore.getWorkspaceId())
assertEquals(1, api.listBoardsCalls)
}
@Test
fun workspaceSwitchFailureWithNullPreviousRollsBackPersistedWorkspaceId() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Failure("Server error: 500")
listBoardsResults.addLast(BoardsApiResult.Failure("Server error: 500"))
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = null)
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
assertEquals(null, viewModel.uiState.value.drawer.activeWorkspaceId)
assertEquals(null, sessionStore.getWorkspaceId())
viewModel.onWorkspaceSelected("ws-2")
advanceUntilIdle()
assertEquals(null, sessionStore.getWorkspaceId())
assertEquals(null, viewModel.uiState.value.drawer.activeWorkspaceId)
assertEquals(1, api.listBoardsCalls)
}
@Test
fun onSettingsAppliedWithCredentialsChangedRefreshesBoardsAndDrawer() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha")))
}
val viewModel = newViewModel(api)
viewModel.onSettingsApplied(credentialsChanged = true)
advanceUntilIdle()
assertTrue(api.listBoardsCalls >= 1)
assertEquals(1, api.getCurrentUserCalls)
assertEquals(1, api.listWorkspacesCalls)
}
@Test
fun onSettingsAppliedWithThemeOnlySkipsNetworkRefresh() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha")))
}
val viewModel = newViewModel(api)
viewModel.onSettingsApplied(credentialsChanged = false)
advanceUntilIdle()
assertEquals(0, api.listBoardsCalls)
assertEquals(0, api.getCurrentUserCalls)
assertEquals(0, api.listWorkspacesCalls)
}
@Test
fun onSettingsAppliedRefreshFailureKeepsCredentialsAndEmitsRetryableError() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
listBoardsResult = BoardsApiResult.Failure("Cannot reach server. Check your connection and URL.")
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val viewModel = newViewModel(api, sessionStore = sessionStore)
val eventDeferred = async { viewModel.events.first() }
viewModel.onSettingsApplied(credentialsChanged = true)
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is BoardsUiEvent.ShowServerError)
assertEquals("https://kan.bn/", sessionStore.getBaseUrl())
assertEquals("ws-1", sessionStore.getWorkspaceId())
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"),
nowProvider: () -> Long = { System.currentTimeMillis() },
): BoardsViewModel {
val repository = BoardsRepository(
sessionStore = InMemorySessionStore("https://kan.bn/"),
sessionStore = sessionStore,
apiKeyStore = InMemoryApiKeyStore("api"),
apiClient = apiClient,
ioDispatcher = UnconfinedTestDispatcher(),
)
return BoardsViewModel(repository)
return BoardsViewModel(repository, nowProvider = nowProvider)
}
private class InMemorySessionStore(private var baseUrl: String?) : SessionStore {
private class InMemorySessionStore(
private var baseUrl: String?,
private var workspaceId: String? = null,
) : SessionStore {
override fun getBaseUrl(): String? = baseUrl
override fun saveBaseUrl(url: String) {
baseUrl = url
}
override fun getWorkspaceId(): String? = "ws-1"
override fun getWorkspaceId(): String? = workspaceId
override fun saveWorkspaceId(workspaceId: String) {
this.workspaceId = workspaceId
}
override fun clearBaseUrl() {
@@ -138,6 +539,7 @@ class BoardsViewModelTest {
}
override fun clearWorkspaceId() {
workspaceId = null
}
}
@@ -157,20 +559,43 @@ class BoardsViewModelTest {
private class FakeBoardsApiClient : KanbnApiClient {
var listBoardsResult: BoardsApiResult<List<BoardSummary>> = BoardsApiResult.Success(emptyList())
val listBoardsResults = ArrayDeque<BoardsApiResult<List<BoardSummary>>>()
var createBoardResult: BoardsApiResult<BoardSummary> = BoardsApiResult.Success(BoardSummary("new", "New"))
var listTemplatesResult: BoardsApiResult<List<BoardTemplate>> = BoardsApiResult.Success(emptyList())
var deleteBoardResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
var workspacesResult: BoardsApiResult<List<WorkspaceSummary>> = BoardsApiResult.Success(emptyList())
val workspacesResults = ArrayDeque<BoardsApiResult<List<WorkspaceSummary>>>()
var usersMeResult: BoardsApiResult<DrawerProfile> = BoardsApiResult.Success(DrawerProfile("User", null))
val usersMeResults = ArrayDeque<BoardsApiResult<DrawerProfile>>()
var blockListBoards: CompletableDeferred<Unit>? = null
var lastDeletedId: String? = null
var listBoardsCalls: Int = 0
var listWorkspacesCalls: Int = 0
var getCurrentUserCalls: Int = 0
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
getCurrentUserCalls += 1
return usersMeResults.removeFirstOrNull() ?: usersMeResult
}
override suspend fun listWorkspaces(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> {
listWorkspacesCalls += 1
return workspacesResults.removeFirstOrNull() ?: workspacesResult
}
override suspend fun listBoards(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardSummary>> {
return listBoardsResult
listBoardsCalls += 1
blockListBoards?.await()
return listBoardsResults.removeFirstOrNull() ?: listBoardsResult
}
override suspend fun listBoardTemplates(

View File

@@ -0,0 +1,71 @@
# Settings View Implementation Handoff (2026-03-18)
## Task 9 Outcome (Required vs Actual)
Required final quality gate scope:
1. Run verification suite:
- `./gradlew :app:test`
- `./gradlew :app:assembleDebug`
- `./gradlew :app:lintDebug`
2. Check short git status (`git status --short`).
3. Produce handoff note with evidence and deferred items.
Actual outcome:
- **Succeeded:** `:app:test` and `:app:assembleDebug` completed with `BUILD SUCCESSFUL`.
- **Failed (blocking):** `:app:lintDebug` completed with `BUILD FAILED` due to 3 lint errors.
- **Succeeded:** short status captured (`?? .kotlin/`).
- **Succeeded:** handoff note written in this worktree and committed.
## Reproducibility Anchors
- **Run context:** local CLI run in worktree `/home/micost/Documentos/Repos/Kanbn4Droid/.worktrees/settings-side-panel`.
- **Run date:** 2026-03-18.
- **Commands executed exactly:**
- `./gradlew :app:test`
- `./gradlew :app:assembleDebug`
- `./gradlew :app:lintDebug`
- `git status --short`
- **Output source:** all evidence in this note comes from those local command outputs in this same worktree.
## Verification Evidence Summary
- `./gradlew :app:test` -> **PASS** (`BUILD SUCCESSFUL`), including `:app:testDebugUnitTest` and `:app:testReleaseUnitTest`.
- `./gradlew :app:assembleDebug` -> **PASS** (`BUILD SUCCESSFUL`), `:app:assembleDebug` up-to-date.
- `./gradlew :app:lintDebug` -> **FAIL** (`BUILD FAILED`), reported `3 errors, 89 warnings`.
- `git status --short` -> `?? .kotlin/`.
## Lint Blocking Errors (All 3)
1. `app/src/main/java/space/hackenslacker/kanbn4droid/app/settings/SettingsPreferencesFragment.kt:25`
- Error: `RestrictedApi` (`Preference.performClick()` call on `KEY_BASE_URL`).
- Remediation hint: avoid direct `performClick()`; trigger editable preference flow through supported public APIs (for example direct navigation/focus flow or extracting edit logic into app-owned method).
2. `app/src/main/java/space/hackenslacker/kanbn4droid/app/settings/SettingsPreferencesFragment.kt:26`
- Error: `RestrictedApi` (`Preference.performClick()` call on `KEY_API_KEY`).
- Remediation hint: same fix strategy as above; remove restricted API calls and replace with app-side explicit edit action.
3. `app/src/main/res/layout/item_card_activity_timeline.xml:27`
- Error: `UseAppTint` (`android:tint` used).
- Remediation hint: replace `android:tint` with `app:tint` and ensure `xmlns:app` is declared on the root element.
## Deferred Items with Exit Criteria
1. Clear lint blocking errors.
- Exit criteria:
- `./gradlew :app:lintDebug` exits 0.
- lint report shows `0 errors`.
- Suggested next commands:
- `./gradlew :app:lintDebug`
- `./gradlew :app:test`
- `./gradlew :app:assembleDebug`
2. Resolve `.kotlin/` untracked handling policy.
- Recommendation: keep `.kotlin/` out of version control and add/confirm ignore coverage in repo-level `.gitignore` for deterministic clean status.
- Exit criteria:
- `git status --short` no longer reports `.kotlin/` after a fresh local build/test run.
- `.kotlin/` is not tracked in commits.
- Suggested next commands:
- `git status --short`
- `git check-ignore -v .kotlin/`

View File

@@ -16,6 +16,7 @@ lifecycle = "2.8.7"
swiperefreshlayout = "1.1.0"
recyclerview = "1.3.2"
activity = "1.9.3"
preference = "1.2.1"
commonmark = "0.22.0"
[libraries]
@@ -35,6 +36,7 @@ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecyc
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" }
androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
androidx-preference = { group = "androidx.preference", name = "preference-ktx", version.ref = "preference" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
commonmark = { group = "org.commonmark", name = "commonmark", version.ref = "commonmark" }