Compare commits
25 Commits
96e971229a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 028c05c0c8 | |||
| 8b2f14c470 | |||
| ca005a4de7 | |||
| fed1c58ae9 | |||
| 3cc5a3e837 | |||
| 4d46c49a6d | |||
| 81cfbe070d | |||
| ed98520de7 | |||
| 542ec5c181 | |||
| 769b893959 | |||
| f3349f5dee | |||
| 8d847ae4ea | |||
| 24fccc4d7e | |||
| 41bb01e40c | |||
| 67fa02525c | |||
| b199aa62e5 | |||
| 03a04b82c5 | |||
| 3188fc472a | |||
| eeffb3de49 | |||
| 149663662b | |||
| d9d751c461 | |||
| f27aa6969d | |||
| 2daad8e7ac | |||
| 964da060ce | |||
| 717c87122d |
298
AGENTS.md
298
AGENTS.md
@@ -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
190
DESIGN.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
6
app/src/main/res/color/workspace_drawer_row_stroke.xml
Normal file
6
app/src/main/res/color/workspace_drawer_row_stroke.xml
Normal 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>
|
||||
6
app/src/main/res/color/workspace_drawer_row_text.xml
Normal file
6
app/src/main/res/color/workspace_drawer_row_text.xml
Normal 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>
|
||||
@@ -1,6 +1,11 @@
|
||||
<?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">
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
@@ -19,7 +24,8 @@
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
android:layout_height="match_parent"
|
||||
>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/boardsRecyclerView"
|
||||
@@ -56,5 +62,12 @@
|
||||
android:layout_margin="16dp"
|
||||
android:contentDescription="@string/create_board"
|
||||
app:srcCompat="@android:drawable/ic_input_add" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
</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>
|
||||
|
||||
49
app/src/main/res/layout/dialog_settings.xml
Normal file
49
app/src/main/res/layout/dialog_settings.xml
Normal 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>
|
||||
27
app/src/main/res/layout/item_workspace_drawer.xml
Normal file
27
app/src/main/res/layout/item_workspace_drawer.xml
Normal 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>
|
||||
88
app/src/main/res/layout/view_boards_drawer.xml
Normal file
88
app/src/main/res/layout/view_boards_drawer.xml
Normal 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>
|
||||
4
app/src/main/res/values/dimens.xml
Normal file
4
app/src/main/res/values/dimens.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<dimen name="boards_drawer_max_width">360dp</dimen>
|
||||
</resources>
|
||||
@@ -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>
|
||||
|
||||
25
app/src/main/res/xml/settings_preferences.xml
Normal file
25
app/src/main/res/xml/settings_preferences.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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/`
|
||||
@@ -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" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user