mirror of
https://github.com/bitwarden/android.git
synced 2026-05-11 10:54:26 -05:00
Compare commits
242 Commits
pr-6572
...
renovate/l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9a98cd368 | ||
|
|
b11884ca00 | ||
|
|
aae473e0c5 | ||
|
|
eabfaa6934 | ||
|
|
38b725f5e3 | ||
|
|
f37671e724 | ||
|
|
710ee64d11 | ||
|
|
d15744b5e8 | ||
|
|
a52ab665b9 | ||
|
|
fd9618bddb | ||
|
|
a9cc18e4fd | ||
|
|
340c585a99 | ||
|
|
7fd63f7a06 | ||
|
|
aaceaa4a4f | ||
|
|
f5ca914059 | ||
|
|
ed2763d59d | ||
|
|
38c4da23bc | ||
|
|
01859beb06 | ||
|
|
a53260fdf7 | ||
|
|
b9449a7df7 | ||
|
|
d0401b310e | ||
|
|
47ef8914a9 | ||
|
|
3b1ea1e3cd | ||
|
|
6ba5159922 | ||
|
|
574e86b352 | ||
|
|
8223fb3089 | ||
|
|
67182df91b | ||
|
|
7303246945 | ||
|
|
a1cf193f40 | ||
|
|
0fa26acee4 | ||
|
|
e6f0db6918 | ||
|
|
072fc8e5da | ||
|
|
d67d05ebb2 | ||
|
|
cdd682c5aa | ||
|
|
336b13ce31 | ||
|
|
7fbde8b239 | ||
|
|
f4651af841 | ||
|
|
b3848ffdb4 | ||
|
|
3845c1fb13 | ||
|
|
796a4dbcbd | ||
|
|
771090d529 | ||
|
|
7231e14488 | ||
|
|
32b704cfde | ||
|
|
be1dabb9dc | ||
|
|
41142a3d4d | ||
|
|
0586edb592 | ||
|
|
909d999186 | ||
|
|
e3b26be1bf | ||
|
|
a3f32e31cd | ||
|
|
2b4ca430f1 | ||
|
|
bd6be6b851 | ||
|
|
1aba32fa3d | ||
|
|
1a3679fb43 | ||
|
|
ca84284f37 | ||
|
|
325c9837e0 | ||
|
|
cbc9c290bc | ||
|
|
d2092357d5 | ||
|
|
c01fc62658 | ||
|
|
985562eb61 | ||
|
|
72c6310f95 | ||
|
|
83f8fca0d1 | ||
|
|
cfead6e6a7 | ||
|
|
72cd8f5abc | ||
|
|
308a1f98b7 | ||
|
|
73c1a45010 | ||
|
|
88a80cd2be | ||
|
|
39240b3317 | ||
|
|
c1eafbeaeb | ||
|
|
d0aa74cdd6 | ||
|
|
9df39eb7a7 | ||
|
|
6e16daf001 | ||
|
|
75b87e134c | ||
|
|
759898d05d | ||
|
|
853307e941 | ||
|
|
0ab6beef67 | ||
|
|
0de4cf7860 | ||
|
|
c6cbebf73f | ||
|
|
0143f93ef3 | ||
|
|
7d2bfe1395 | ||
|
|
287d8a9f4e | ||
|
|
929232d6db | ||
|
|
2cae3bbbfd | ||
|
|
7d83269ab2 | ||
|
|
9e38e1fb09 | ||
|
|
747d2d58e5 | ||
|
|
954ac5b7d7 | ||
|
|
fb166691e4 | ||
|
|
62ceab5aba | ||
|
|
29e73adef0 | ||
|
|
7d9d65a490 | ||
|
|
67f993ee60 | ||
|
|
de47c507cc | ||
|
|
31b3b0304c | ||
|
|
89491bbb71 | ||
|
|
36366923e6 | ||
|
|
ee2401e717 | ||
|
|
10be562ec0 | ||
|
|
692807e361 | ||
|
|
c38745cb01 | ||
|
|
8c5c145f34 | ||
|
|
8585b9ff2a | ||
|
|
09a2abf6bb | ||
|
|
99fb051000 | ||
|
|
d806988fd0 | ||
|
|
54e74e98b4 | ||
|
|
61955d7cbe | ||
|
|
ab583296aa | ||
|
|
e404477059 | ||
|
|
fb65c3ba51 | ||
|
|
8057171e45 | ||
|
|
9f4ae99c0b | ||
|
|
2402e21b4d | ||
|
|
5d7ea8f27c | ||
|
|
ec860c9acf | ||
|
|
6eeab656d1 | ||
|
|
0871a2a33d | ||
|
|
8263a178ca | ||
|
|
288b2b26cf | ||
|
|
4decce570d | ||
|
|
57c3df8754 | ||
|
|
4ffcac20d8 | ||
|
|
cc7ce34667 | ||
|
|
5cec17a21e | ||
|
|
94e550dbbc | ||
|
|
c38a54c238 | ||
|
|
84f422e209 | ||
|
|
6d0f69b23e | ||
|
|
69a13c91b5 | ||
|
|
d58c82ced4 | ||
|
|
db0cf19780 | ||
|
|
41b7ca2b68 | ||
|
|
d926ce98b5 | ||
|
|
6332081356 | ||
|
|
b4917ceb95 | ||
|
|
2b69753397 | ||
|
|
c786756f5b | ||
|
|
078b4e6f1b | ||
|
|
d2ca13f88b | ||
|
|
2e29ab389d | ||
|
|
6c7348ebd4 | ||
|
|
6cf15fb792 | ||
|
|
988a321944 | ||
|
|
044bfb1bb2 | ||
|
|
eab2720e3e | ||
|
|
4a069e9703 | ||
|
|
12c96de168 | ||
|
|
4375782b09 | ||
|
|
e969a42eff | ||
|
|
68e2fe4dd7 | ||
|
|
37907cbe0c | ||
|
|
c1d1de27f0 | ||
|
|
be8777cb8e | ||
|
|
2b9e142107 | ||
|
|
685493fde0 | ||
|
|
6d04c04929 | ||
|
|
04c3147a56 | ||
|
|
44c22deb3a | ||
|
|
6824af48e1 | ||
|
|
183255cbff | ||
|
|
9d5a82e9ea | ||
|
|
7046029a45 | ||
|
|
4ed731706c | ||
|
|
ec3c9001cf | ||
|
|
7666fb82b8 | ||
|
|
fcfa647806 | ||
|
|
e91797f86c | ||
|
|
ad7dc3fb5d | ||
|
|
43bd83f883 | ||
|
|
0b78fd0018 | ||
|
|
6888e676dc | ||
|
|
c52d5efb46 | ||
|
|
4fb379911d | ||
|
|
8b5793734a | ||
|
|
d17617ee5a | ||
|
|
ae5a14e386 | ||
|
|
193ec12ebd | ||
|
|
53afde1509 | ||
|
|
8707a8db95 | ||
|
|
13c8cc08a6 | ||
|
|
85c3a1deb8 | ||
|
|
870f15418b | ||
|
|
453fc22d57 | ||
|
|
93a3e0af32 | ||
|
|
026a348d12 | ||
|
|
01a137e4e3 | ||
|
|
5b965e7923 | ||
|
|
3904f24f0a | ||
|
|
68880ff5e3 | ||
|
|
d9f8c3d792 | ||
|
|
8455f7f706 | ||
|
|
bb46c3812f | ||
|
|
9068307928 | ||
|
|
04bcd35776 | ||
|
|
55e65480f1 | ||
|
|
5af4af95e4 | ||
|
|
417a14fca2 | ||
|
|
44f5f614b6 | ||
|
|
9e3360e421 | ||
|
|
1b6b46f72e | ||
|
|
6570115d9e | ||
|
|
ee40623911 | ||
|
|
f99eaafc67 | ||
|
|
77d541d033 | ||
|
|
2d7475556f | ||
|
|
e260f1d2a5 | ||
|
|
5bd15a8fca | ||
|
|
fa4347db96 | ||
|
|
d88de04acb | ||
|
|
aeed96e210 | ||
|
|
6473d54f16 | ||
|
|
aa23d5e5dc | ||
|
|
053ac28e38 | ||
|
|
3400d5f875 | ||
|
|
9f274bbffa | ||
|
|
cf1455a45a | ||
|
|
d0dc4200f8 | ||
|
|
8a2b46e81a | ||
|
|
3538ca54ca | ||
|
|
5a61ba96f6 | ||
|
|
836233f4d5 | ||
|
|
3b081faf65 | ||
|
|
61517014a7 | ||
|
|
4a1582b1e4 | ||
|
|
227224b6cb | ||
|
|
60bc6ee0ca | ||
|
|
e509d60af6 | ||
|
|
1f9390a668 | ||
|
|
ed1abcac5b | ||
|
|
209e216213 | ||
|
|
7bde0ce716 | ||
|
|
a517b3f970 | ||
|
|
c7d173cf9a | ||
|
|
38f3d3d720 | ||
|
|
487b163d38 | ||
|
|
52da80e0fc | ||
|
|
1abb640512 | ||
|
|
64a79ff108 | ||
|
|
fd6d32ec09 | ||
|
|
4ca79bb8c7 | ||
|
|
642456f2fe | ||
|
|
7b1b519b0d | ||
|
|
d51d6c7c54 |
@@ -4,13 +4,12 @@ Official Android application for Bitwarden Password Manager and Bitwarden Authen
|
||||
|
||||
## Overview
|
||||
|
||||
### What This Project Does
|
||||
- Multi-module Android application providing secure password management and TOTP code generation
|
||||
- Implements zero-knowledge architecture where encryption/decryption happens client-side
|
||||
- Key entry points: `:app` (Password Manager), `:authenticator` (2FA TOTP generator)
|
||||
- Multi-module Android application: `:app` (Password Manager), `:authenticator` (2FA TOTP generator)
|
||||
- Zero-knowledge architecture: encryption/decryption happens client-side via Bitwarden SDK
|
||||
- Target users: End-users via Google Play Store and F-Droid
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **Zero-Knowledge Architecture**: Server never has access to unencrypted vault data or encryption keys
|
||||
- **Bitwarden SDK**: Rust-based cryptographic SDK handling all encryption/decryption operations
|
||||
- **DataState**: Wrapper for streaming data states (Loading, Loaded, Pending, Error, NoNetwork)
|
||||
@@ -19,9 +18,7 @@ Official Android application for Bitwarden Password Manager and Bitwarden Authen
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Patterns
|
||||
|
||||
### System Architecture
|
||||
## Architecture
|
||||
|
||||
```
|
||||
User Request (UI Action)
|
||||
@@ -40,31 +37,6 @@ User Request (UI Action)
|
||||
DB APIs Rust SDK
|
||||
```
|
||||
|
||||
### Code Organization
|
||||
|
||||
```
|
||||
android/
|
||||
├── app/ # Password Manager application
|
||||
│ └── src/main/kotlin/com/x8bit/bitwarden/
|
||||
│ ├── data/ # Repositories, managers, data sources
|
||||
│ │ ├── auth/ # Authentication domain
|
||||
│ │ ├── vault/ # Vault/cipher domain
|
||||
│ │ ├── platform/ # Platform services
|
||||
│ │ └── tools/ # Generator, export tools
|
||||
│ └── ui/ # ViewModels, Screens, Navigation
|
||||
│ ├── auth/ # Login, registration screens
|
||||
│ ├── vault/ # Vault screens
|
||||
│ └── platform/ # Settings, debug menu
|
||||
├── authenticator/ # Authenticator 2FA application
|
||||
├── core/ # Shared utilities, dispatcher management
|
||||
├── data/ # Shared data layer (disk sources, models)
|
||||
├── network/ # Network layer (Retrofit services, models)
|
||||
├── ui/ # Shared UI components, theming
|
||||
├── authenticatorbridge/ # IPC bridge between apps
|
||||
├── cxf/ # Credential Exchange integration
|
||||
└── annotation/ # Custom annotations for code generation
|
||||
```
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **No Exceptions from Data Layer**: All suspending functions return `Result<T>` or custom sealed classes
|
||||
@@ -78,138 +50,48 @@ android/
|
||||
- **Repository Result Pattern**: Type-safe error handling using custom sealed classes for discrete operations and `DataState<T>` wrapper for streaming data.
|
||||
- **Common Patterns**: Flow collection via `Internal` actions, error handling via `when` branches, `DataState` streaming with `.map { }` and `.stateIn()`.
|
||||
|
||||
> For complete architecture patterns and code templates, see `docs/ARCHITECTURE.md`.
|
||||
> For complete architecture patterns, code templates, and module organization, see `docs/ARCHITECTURE.md`.
|
||||
|
||||
---
|
||||
|
||||
## Development Guide
|
||||
|
||||
### Adding New Feature Screen
|
||||
### Workflow Skills
|
||||
|
||||
Use the `implementing-android-code` skill for Bitwarden-specific patterns, gotchas, and copy-pasteable templates. Follow these steps:
|
||||
> **Quick start**: Use the `bitwarden-tech-lead:bitwarden-tech-lead` agent (or `/plan-android-work <task>`) to refine
|
||||
> requirements and plan,
|
||||
> then the `bitwarden-software-engineer:bitwarden-software-engineer` agent (or `/work-on-android <task>`) for implementation,
|
||||
> then `/review-android <PR#>` to review the result.
|
||||
|
||||
1. **Define State/Event/Action** - `@Parcelize` state, sealed event/action classes with `Internal` subclass
|
||||
2. **Implement ViewModel** - Extend `BaseViewModel<S, E, A>`, persist state via `SavedStateHandle`, map Flow results to internal actions
|
||||
3. **Implement Screen** - Stateless `@Composable`, use `EventsEffect` for navigation, `remember(viewModel)` for action lambdas
|
||||
4. **Define Navigation** - `@Serializable` route, `NavGraphBuilder` extension with `composableWithSlideTransitions`, `NavController` extension
|
||||
5. **Write Tests** - Use the `testing-android-code` skill for comprehensive test patterns and templates
|
||||
## Skills & Commands
|
||||
|
||||
### Code Reviews
|
||||
| Skill | Triggers |
|
||||
|-------|---------|
|
||||
| `build-test-verify` | "build", "run tests", "lint", "format", "verify build" |
|
||||
| `implementing-android-code` | "implement", "write code", "add screen", "create feature" |
|
||||
| `planning-android-implementation` | "plan implementation", "architecture design", "phased task breakdown" |
|
||||
| `refining-android-requirements` | "refine requirements", "analyze ticket", "gap analysis" |
|
||||
| `reviewing-changes` | "review", "code review", "check PR" |
|
||||
| `testing-android-code` | "write tests", "add test coverage", "unit test" |
|
||||
|
||||
Use the `reviewing-changes` skill for structured code review checklists covering MVVM/Compose patterns, security validation, and type-specific review guidance.
|
||||
|
||||
### Codebase Discovery
|
||||
|
||||
```bash
|
||||
# Find existing Bitwarden UI components
|
||||
find ui/src/main/kotlin/com/bitwarden/ui/platform/components/ -name "Bitwarden*.kt" | sort
|
||||
|
||||
# Find all ViewModels
|
||||
grep -rl "BaseViewModel<" app/src/main/kotlin/ --include="*.kt"
|
||||
|
||||
# Find all Navigation files with @Serializable routes
|
||||
find app/src/main/kotlin/ -name "*Navigation.kt" | sort
|
||||
|
||||
# Find all Hilt modules
|
||||
find app/src/main/kotlin/ -name "*Module.kt" -path "*/di/*" | sort
|
||||
|
||||
# Find all repository interfaces
|
||||
find app/src/main/kotlin/ -name "*Repository.kt" -not -name "*Impl.kt" -path "*/repository/*" | sort
|
||||
|
||||
# Find encrypted disk source examples
|
||||
grep -rl "EncryptedPreferences" app/src/main/kotlin/ --include="*.kt"
|
||||
|
||||
# Find Clock injection usage
|
||||
grep -rl "private val clock: Clock" app/src/main/kotlin/ --include="*.kt"
|
||||
|
||||
# Search existing strings before adding new ones
|
||||
grep -n "search_term" ui/src/main/res/values/strings.xml
|
||||
```
|
||||
| Command | Usage |
|
||||
|---------|-------|
|
||||
| `/plan-android-work <task>` | Fetch ticket → refine requirements → design implementation approach |
|
||||
| `/work-on-android <task>` | Full workflow: implement → test → verify → preflight → commit → review → PR |
|
||||
| `/review-android <PR#>` | Full review workflow: PR context gathering → Android checklist → output |
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
Key types used throughout the codebase:
|
||||
|
||||
- **`UserState`** (`data/auth/`) - Active user ID, accounts list, pending account state
|
||||
- **`VaultUnlockData`** (`data/vault/repository/model/`) - User ID and vault unlock status
|
||||
- **`NetworkResult<T>`** (`network/`) - HTTP operation result: Success or Failure
|
||||
- **`BitwardenError`** (`network/`) - Error classification: Http, Network, Other
|
||||
|
||||
---
|
||||
|
||||
## Security & Configuration
|
||||
|
||||
### Security Rules
|
||||
## Security Rules
|
||||
|
||||
**MANDATORY - These rules have no exceptions:**
|
||||
|
||||
1. **Zero-Knowledge Architecture**: Never transmit unencrypted vault data or master passwords to the server. All encryption happens client-side via the Bitwarden SDK.
|
||||
|
||||
2. **No Plaintext Key Storage**: Encryption keys must be stored using Android Keystore (biometric unlock) or encrypted with PIN/master password.
|
||||
|
||||
3. **Sensitive Data Cleanup**: On logout, all sensitive data must be cleared from memory and storage via `UserLogoutManager.logout()`.
|
||||
|
||||
4. **Input Validation**: Validate all user inputs before processing, especially URLs and credentials.
|
||||
|
||||
5. **SDK Isolation**: Use scoped SDK sources (`ScopedVaultSdkSource`) to prevent cross-user crypto context leakage.
|
||||
|
||||
### Security Components
|
||||
|
||||
| Component | Location | Purpose |
|
||||
|-----------|----------|---------|
|
||||
| `BiometricsEncryptionManager` | `data/platform/manager/` | Android Keystore integration for biometric unlock |
|
||||
| `VaultLockManager` | `data/vault/manager/` | Vault lock/unlock operations |
|
||||
| `AuthDiskSource` | `data/auth/datasource/disk/` | Secure token and key storage |
|
||||
| `BaseEncryptedDiskSource` | `data/datasource/disk/` | EncryptedSharedPreferences base class |
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `GITHUB_TOKEN` | Yes (CI) | GitHub Packages authentication for SDK |
|
||||
| Build flavors | - | `standard` (Play Store), `fdroid` (no Google services) |
|
||||
| Build types | - | `debug`, `beta`, `release` |
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- **Login Methods**: Email/password, SSO (OAuth 2.0 + PKCE), trusted device, passwordless auth request
|
||||
- **Vault Unlock**: Master password, PIN, biometric, trusted device key
|
||||
- **Token Management**: JWT access tokens with automatic refresh via `AuthTokenManager`
|
||||
- **Key Derivation**: PBKDF2-SHA256 or Argon2id via `KdfManager`
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Structure
|
||||
|
||||
```
|
||||
app/src/test/ # App unit tests
|
||||
app/src/testFixtures/ # App test utilities
|
||||
core/src/testFixtures/ # Core test utilities (FakeDispatcherManager)
|
||||
data/src/testFixtures/ # Data test utilities (FakeSharedPreferences)
|
||||
network/src/testFixtures/ # Network test utilities (BaseServiceTest)
|
||||
ui/src/testFixtures/ # UI test utilities (BaseViewModelTest, BaseComposeTest)
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
./gradlew test # Run all unit tests
|
||||
./gradlew app:testDebugUnitTest # Run app module tests
|
||||
./gradlew :core:test # Run core module tests
|
||||
./fastlane check # Run full validation (detekt, lint, tests, coverage)
|
||||
```
|
||||
|
||||
### Test Quick Reference
|
||||
|
||||
- **Dispatcher Control**: `FakeDispatcherManager` from `:core:testFixtures`
|
||||
- **MockK**: `mockk<T> { every { } returns }`, `coEvery { }` for suspend
|
||||
- **Flow Testing**: Turbine with `stateEventFlow()` helper from `BaseViewModelTest`
|
||||
- **Time Control**: Inject `Clock` for deterministic time testing
|
||||
|
||||
---
|
||||
|
||||
## Code Style & Standards
|
||||
@@ -217,7 +99,7 @@ ui/src/testFixtures/ # UI test utilities (BaseViewModelTest, BaseCom
|
||||
- **Formatter**: Android Studio with `bitwarden-style.xml` | **Line Limit**: 100 chars | **Detekt**: Enabled
|
||||
- **Naming**: `camelCase` (vars/fns), `PascalCase` (classes), `SCREAMING_SNAKE_CASE` (constants), `...Impl` (implementations)
|
||||
- **KDoc**: Required for all public APIs
|
||||
- **String Resources**: Add new strings to `:ui` module (`ui/src/main/res/values/strings.xml`). Use typographic quotes/apostrophes (`"` `"` `'`) not escaped ASCII (`\"` `\'`)
|
||||
- **String Resources**: Add new strings to `:ui` module (`ui/src/main/res/values/strings.xml`). Use typographic quotes/apostrophes (`"` `"` `'`) not escaped ASCII (`\"` `\'`). Name each resource from its own text content in `snake_case` — not with generic suffixes (`_message`, `_title`). E.g., `one_or_more_email_addresses_are_incorrect`, not `invalid_email_addresses_message`.
|
||||
|
||||
> For complete style rules (imports, formatting, documentation, Compose conventions), see `docs/STYLE_AND_BEST_PRACTICES.md`.
|
||||
|
||||
@@ -228,7 +110,6 @@ ui/src/testFixtures/ # UI test utilities (BaseViewModelTest, BaseCom
|
||||
In addition to the Key Principles above, follow these rules:
|
||||
|
||||
### DO
|
||||
- Use `remember(viewModel)` for lambdas passed to composables
|
||||
- Map async results to internal actions before updating state
|
||||
- Inject `Clock` for time-dependent operations
|
||||
- Return early to reduce nesting
|
||||
@@ -243,92 +124,14 @@ In addition to the Key Principles above, follow these rules:
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
## Quick Reference
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Debug builds
|
||||
./gradlew app:assembleDebug
|
||||
./gradlew authenticator:assembleDebug
|
||||
|
||||
# Release builds (requires signing keys)
|
||||
./gradlew app:assembleStandardRelease
|
||||
./gradlew app:bundleStandardRelease
|
||||
|
||||
# F-Droid builds
|
||||
./gradlew app:assembleFdroidRelease
|
||||
```
|
||||
|
||||
### Versioning
|
||||
|
||||
**Location**: `gradle/libs.versions.toml`
|
||||
```toml
|
||||
appVersionCode = "1"
|
||||
appVersionName = "2025.11.1"
|
||||
```
|
||||
|
||||
Follow semantic versioning pattern: `YEAR.MONTH.PATCH`
|
||||
|
||||
### Publishing
|
||||
|
||||
- **Play Store**: Via GitHub Actions workflow with signed AAB
|
||||
- **F-Droid**: Via dedicated workflow with F-Droid signing keys
|
||||
- **Firebase App Distribution**: For beta testing
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Build fails with SDK dependency error
|
||||
|
||||
**Problem**: Cannot resolve Bitwarden SDK from GitHub Packages
|
||||
|
||||
**Solution**:
|
||||
1. Ensure `GITHUB_TOKEN` is set in `ci.properties` or environment
|
||||
2. Verify token has `read:packages` scope
|
||||
3. Check network connectivity to `maven.pkg.github.com`
|
||||
|
||||
#### Tests fail with dispatcher issues
|
||||
|
||||
**Problem**: Tests hang or fail with "Module with Main dispatcher had failed to initialize"
|
||||
|
||||
**Solution**:
|
||||
1. Extend `BaseViewModelTest` for ViewModel tests
|
||||
2. Use `@RegisterExtension val mainDispatcherExtension = MainDispatcherExtension()`
|
||||
3. Ensure `runTest { }` wraps test body
|
||||
|
||||
#### Compose preview not rendering
|
||||
|
||||
**Problem**: @Preview functions show "Rendering problem"
|
||||
|
||||
**Solution**:
|
||||
1. Check for missing theme wrapper: `BitwardenTheme { YourComposable() }`
|
||||
2. Verify no ViewModel dependency in preview (use state-based preview)
|
||||
3. Clean and rebuild project
|
||||
|
||||
#### ProGuard/R8 stripping required classes
|
||||
|
||||
**Problem**: Release build crashes with missing class errors
|
||||
|
||||
**Solution**:
|
||||
1. Add keep rules to `proguard-rules.pro`
|
||||
2. Check `consumer-rules.pro` in library modules
|
||||
3. Verify kotlinx.serialization rules are present
|
||||
|
||||
### Debug Tips
|
||||
|
||||
- **Timber Logging**: Enabled in debug builds, check Logcat with tag filter
|
||||
- **Debug Menu**: Available in debug builds via Settings > About > Debug Menu
|
||||
- **Network Inspector**: Use Android Studio Network Profiler or Charles Proxy
|
||||
- **SDK Debugging**: Check `BaseSdkSource` for wrapped exceptions
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- `docs/ARCHITECTURE.md` - Architecture patterns, templates, examples
|
||||
- `docs/STYLE_AND_BEST_PRACTICES.md` - Code style, formatting, Compose conventions
|
||||
- [Bitwarden SDK](https://github.com/bitwarden/sdk) | [Jetpack Compose](https://developer.android.com/jetpack/compose) | [Hilt DI](https://dagger.dev/hilt/) | [Turbine](https://github.com/cashapp/turbine) | [MockK](https://mockk.io/)
|
||||
- **Code style**: Full rules: `docs/STYLE_AND_BEST_PRACTICES.md`
|
||||
- **Before writing code**: Use `implementing-android-code` skill for Bitwarden-specific patterns, gotchas, and templates
|
||||
- **Before writing tests**: Use `testing-android-code` skill for test patterns and templates
|
||||
- **Building/testing**: Use `build-test-verify` skill | App tests: `./gradlew app:testStandardDebugUnitTest`
|
||||
- **Before committing**: Use `bitwarden-delivery-tools:perform-preflight` skill, then `bitwarden-delivery-tools:committing-changes` skill for message format
|
||||
- **Code review**: Use `/review-android` for the full review workflow; `reviewing-changes` skill for checklist-only
|
||||
- **Creating PRs**: Use `bitwarden-delivery-tools:creating-pull-request` skill for PR workflow and templates
|
||||
- **Troubleshooting**: See `docs/TROUBLESHOOTING.md`
|
||||
- **Architecture**: `docs/ARCHITECTURE.md` | [Bitwarden SDK](https://github.com/bitwarden/sdk) | [Jetpack Compose](https://developer.android.com/jetpack/compose) | [Hilt DI](https://dagger.dev/hilt/)
|
||||
|
||||
130
.claude/CONTRIBUTING.md
Normal file
130
.claude/CONTRIBUTING.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Contributing Claude Context to This Repo
|
||||
|
||||
Every time you catch Claude making the same mistake twice, explain the same convention in chat, or
|
||||
hand a teammate a mental map they didn't have — that's knowledge worth encoding. This guide covers
|
||||
what belongs in this repo's `.claude/`, where to put it, and how to land it alongside the code it
|
||||
describes.
|
||||
|
||||
## When to contribute here vs. elsewhere
|
||||
|
||||
Ask: **is this knowledge specific to this codebase, or generic enough to work across repos?**
|
||||
|
||||
- **Specific to this codebase** → contribute here, in `.claude/`. Example: "how we add a new module
|
||||
in this codebase," "how our feature-flag system works."
|
||||
- **Generic, reusable across repos** →
|
||||
[`bitwarden/ai-plugins`](https://github.com/bitwarden/ai-plugins) — persona plugins (e.g., a
|
||||
code-review agent), tool integrations, or shared utilities.
|
||||
|
||||
When unsure, keep it here. Promoting up to `ai-plugins` later is easier than pulling it back — see
|
||||
its [CONTRIBUTING.md](https://github.com/bitwarden/ai-plugins/blob/main/CONTRIBUTING.md) when you're
|
||||
ready.
|
||||
|
||||
## Choose scope, then shape
|
||||
|
||||
### 1. Scope — where does it apply?
|
||||
|
||||
Claude loads every `CLAUDE.md` and `CLAUDE.local.md` by
|
||||
[walking up from the working directory](https://code.claude.com/docs/en/memory#how-claude-md-files-load)
|
||||
— looking in each ancestor directly, not in a nested `.claude/` subdirectory. Files below the
|
||||
working directory (including nested `.claude/skills/`) are loaded lazily when Claude reads into that
|
||||
subtree. Use that hierarchy:
|
||||
|
||||
- **Applies everywhere in this repo** → root `CLAUDE.md` or `.claude/skills/`
|
||||
- **Applies only within one app, library, utility, or subtree** → nested `CLAUDE.md` or
|
||||
`.claude/skills/` in that directory
|
||||
|
||||
Push rules as deep as they'll go — keeping app-specific rules local saves context for everyone
|
||||
else's sessions, not just yours.
|
||||
|
||||
For rules that should apply only to certain file types, use
|
||||
[`.claude/rules/<name>.md` with a `paths:` frontmatter glob](https://code.claude.com/docs/en/memory#organize-rules-with-claude/rules/)
|
||||
instead of a nested `CLAUDE.md`.
|
||||
|
||||
### 2. Shape — how should Claude use it?
|
||||
|
||||
| You want to… | Use |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| State a rule Claude must always follow in its scope | `CLAUDE.md` |
|
||||
| State a rule that applies only to certain file globs | `.claude/rules/<name>.md` with `paths:` frontmatter |
|
||||
| Teach a procedure Claude invokes on demand | `.claude/skills/<name>/SKILL.md` |
|
||||
| Give Claude a specialized subagent with its own context | `.claude/agents/<name>.md` (YAML frontmatter; `name` + `description` required) |
|
||||
| Add a user-invocable slash command | `.claude/commands/<name>.md` |
|
||||
| Trigger a shell script on a Claude Code event | _We have them, but no strict project enforcement yet — register yours in `settings.local.json`._ |
|
||||
|
||||
Rule of thumb: **if Claude only needs it sometimes, it's a skill.** Once a `CLAUDE.md` loads, it
|
||||
stays in context for the rest of the session — keep each one lean, especially the root.
|
||||
|
||||
## Security conventions
|
||||
|
||||
Skills and agents that touch vault data, authentication, or cryptography must use Bitwarden's
|
||||
[Core Vocabulary](https://contributing.bitwarden.com/architecture/security/definitions) (Vault Data,
|
||||
Protected Data, Secure Channel, etc.) and re-state the zero-knowledge invariant inline. **Subagents
|
||||
run in a fresh context** and do not inherit this repo's `CLAUDE.md` — include the relevant
|
||||
definitions directly in the agent's system prompt.
|
||||
|
||||
## What good contributions look like
|
||||
|
||||
- **Grounded in the code.** Real files, real patterns, real commands. If it could apply to any repo,
|
||||
it belongs in `ai-plugins`.
|
||||
- **Describes the "what" and "why," not the "who."** Avoid team-persona framing. Describe the domain
|
||||
and its constraints; the team is an implementation detail.
|
||||
- **Short and specific.** 2,000 words of general advice isn't a skill.
|
||||
- **Active voice, direct language.** "Invoke this skill when..." — not "This skill may be invoked
|
||||
when..."
|
||||
- **Reviewed like code.** Teams of domain experts own `.claude/` in their areas — they're the ones
|
||||
shaping how Claude behaves for everyone who works there, so treat changes with the same
|
||||
seriousness as source.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Team-persona agents** ("Team ABC engineer"). If a team's process is unique enough to warrant a
|
||||
persona, that's an SDLC signal to address, not a persona to encode.
|
||||
- **Root-level rules that only matter in one subtree.** If the rule only ever applies to a single
|
||||
subtree, then the rule belongs in a nested `CLAUDE.md` next to that subtree.
|
||||
- **Duplicating `ai-plugins` content.** Check existing plugin skills before writing a new one.
|
||||
- **Generic advice disguised as repo-local knowledge.** "Write good tests" isn't repo-specific. "Our
|
||||
integration tests must hit a real database because…" is.
|
||||
|
||||
## Building a contribution
|
||||
|
||||
The Claude Code ecosystem moves fast — last session's habits may already be out of date. Here's the
|
||||
workflow we follow.
|
||||
|
||||
### 1. Start with the canonical docs
|
||||
|
||||
A quick refresh before you begin goes a long way — the rules shift more often than you'd think:
|
||||
|
||||
- [How Claude Code Works](https://code.claude.com/docs/en/how-claude-code-works) — the mental model.
|
||||
- [Best Practices for Claude Code](https://code.claude.com/docs/en/best-practices) — what Anthropic
|
||||
recommends.
|
||||
- [Extend Claude Code](https://code.claude.com/docs/en/features-overview) — what you can build
|
||||
(skills, agents, commands, hooks).
|
||||
- [The Complete Guide to Building Skills for Claude](https://resources.anthropic.com/hubfs/The-Complete-Guide-to-Building-Skill-for-Claude.pdf) -
|
||||
a must read for skill building
|
||||
|
||||
### 2. Survey the landscape
|
||||
|
||||
A quick skim of both goes a long way:
|
||||
|
||||
- This repo's [`.claude/`](.) tree.
|
||||
- [`bitwarden/ai-plugins`](https://github.com/bitwarden/ai-plugins).
|
||||
|
||||
Try to match the voice you see. "Invoke when the user asks to X" — not "This skill may be invoked
|
||||
when X." Direct, active, specific. Your contribution should read like the neighbors.
|
||||
|
||||
### 3. Build iteratively
|
||||
|
||||
When you're authoring a skill, start with `/skill-creator:skill-creator`. It runs an iterative loop
|
||||
— draft → test against evaluations → review outputs → refine — with benchmark stats and a
|
||||
side-by-side reviewer. You end up with a skill that's been exercised against concrete inputs before
|
||||
you open the PR.
|
||||
|
||||
For agents, commands, hooks, and `CLAUDE.md` entries, start from an existing one in the repo and
|
||||
adapt it. No need to invent a new structure when a neighbor already solves the shape problem.
|
||||
|
||||
### 4. Validate before you push
|
||||
|
||||
- Run a local Bitwarden Claude Code review with `/bitwarden-code-review:code-review-local` — it
|
||||
writes findings to files so you can fix them before pushing, without posting anything to GitHub.
|
||||
- When you raise the PR, apply the `ai-review` label. Our reusable GitHub workflow watches for it
|
||||
and runs a Claude Code review automatically; without the label, the review doesn't fire.
|
||||
119
.claude/commands/plan-android-work.md
Normal file
119
.claude/commands/plan-android-work.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
description: Guided requirements refinement and implementation planning for Bitwarden Android
|
||||
argument-hint: <Jira ticket (PM-12345), Confluence URL, or free-text description>
|
||||
---
|
||||
|
||||
# Android Planning Workflow
|
||||
|
||||
You are guiding the developer through requirements refinement and implementation planning for the Bitwarden Android project. The input to plan from is:
|
||||
|
||||
**Input**: $ARGUMENTS
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Jira/Confluence access**: Fetching tickets and wiki pages requires the `bitwarden-atlassian-tools@bitwarden-marketplace` MCP plugin. If the plugin is not installed, Jira ticket IDs and Confluence URLs cannot be fetched automatically.
|
||||
|
||||
## Workflow Phases
|
||||
|
||||
Work through each phase sequentially. **Confirm with the user before advancing to the next phase.** The user may skip phases that are not applicable. If starting from a partially completed plan, skip to the appropriate phase.
|
||||
|
||||
### Phase 1: Ingest Requirements
|
||||
|
||||
Parse the input to detect and fetch all available sources:
|
||||
|
||||
**Source Detection Rules:**
|
||||
- **Jira tickets** (patterns like `PM-\d+`, `BWA-\d+`, `EC-\d+`): Fetch via `get_issue` and `get_issue_comments`. Also fetch linked issue summaries (parent epic, sub-tasks, blockers) for context.
|
||||
- **Confluence URLs** (containing `atlassian.net/wiki` or confluence page IDs): Extract page ID and fetch via `get_confluence_page`. If the page is a parent page, fetch child pages via `get_child_pages` and ask the user which are relevant.
|
||||
- **Free text**: Treat as direct requirements — no fetching needed.
|
||||
- **Multiple inputs**: All are first-class sources. Fetch each independently and consolidate.
|
||||
- **Tool unavailable**: If `get_issue`, `get_confluence_page`, or other Atlassian tools are not available, inform the user that the `bitwarden-atlassian-tools@bitwarden-marketplace` MCP plugin is required and prompt them to install and configure it. In the meantime, ask the user to paste the relevant content directly. Treat pasted content as free-text input.
|
||||
|
||||
**Present a structured summary:**
|
||||
1. Sources identified and fetched (with links)
|
||||
2. Raw requirements extracted from each source
|
||||
3. Initial scope assessment (small / medium / large)
|
||||
|
||||
**Edge cases:**
|
||||
- Jira ticket with no description → flag as critical gap that Phase 2 must address
|
||||
- Multiple tickets → fetch all, consolidate, flag any contradictions
|
||||
- Ticket + free text → both treated as first-class; free text supplements ticket
|
||||
|
||||
**Gate**: User confirms the summary is complete and may add additional sources or context before proceeding.
|
||||
|
||||
### Phase 2: Refine Requirements
|
||||
|
||||
Invoke the `refining-android-requirements` skill and use it to perform gap analysis on the raw requirements from Phase 1.
|
||||
|
||||
The skill will:
|
||||
1. Consolidate all sources into a working document
|
||||
2. Evaluate requirements against a structured rubric (functional, technical, security, UX, cross-cutting)
|
||||
3. Present categorized gaps as blocking or non-blocking questions
|
||||
4. After user answers, produce a structured specification with numbered IDs
|
||||
|
||||
**Gate**: User approves the refined specification. They may request changes or provide additional answers.
|
||||
|
||||
### Phase 3: Plan Implementation
|
||||
|
||||
Invoke the `planning-android-implementation` skill and use it to design the implementation approach based on the refined spec from Phase 2.
|
||||
|
||||
The skill will:
|
||||
1. Classify the change type
|
||||
2. Explore the codebase for reference implementations and integration points
|
||||
3. Design the architecture with component relationships
|
||||
4. Produce a file inventory and phased implementation plan
|
||||
5. Assess risks and define verification criteria
|
||||
|
||||
**Gate**: User reviews the implementation plan and may request changes to architecture, phasing, or scope.
|
||||
|
||||
### Phase 4: Finalize & Save
|
||||
|
||||
Merge the outputs from Phase 2 (specification) and Phase 3 (implementation plan) into a single design document using this template:
|
||||
|
||||
```markdown
|
||||
# [Feature Name] — Design Document
|
||||
|
||||
**Feature**: [concise description]
|
||||
**Date**: [current date]
|
||||
**Status**: Ready for Implementation
|
||||
**Jira**: [ticket ID if available]
|
||||
**Sources**: [list of all input sources with links]
|
||||
|
||||
---
|
||||
|
||||
## Requirements Specification
|
||||
|
||||
[Full output from Phase 2 — the refined specification with numbered IDs]
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
[Full output from Phase 3 — architecture, file inventory, phases, risks]
|
||||
|
||||
---
|
||||
|
||||
## Executing This Plan
|
||||
|
||||
To implement this plan, run:
|
||||
|
||||
/work-on-android [ticket or feature reference]
|
||||
|
||||
Reference this design document during implementation for architecture decisions,
|
||||
file locations, and phase ordering.
|
||||
```
|
||||
|
||||
**Save the document:**
|
||||
- With ticket: `.claude/outputs/plans/PM-XXXXX-FEATURE-NAME-PLAN.md`
|
||||
- Without ticket: `.claude/outputs/plans/FEATURE-NAME-PLAN.md`
|
||||
- Feature name should be uppercase with hyphens (e.g., `BIOMETRIC-TIMEOUT-CONFIG-PLAN.md`)
|
||||
- Create the output directory if it does not exist
|
||||
|
||||
**On completion**: Present the saved file path and remind the user they can execute the plan with `/work-on-android`.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Be explicit about which phase you are in at all times.
|
||||
- If the user wants to skip a phase, acknowledge and move to the next applicable phase.
|
||||
- When fetching from Jira/Confluence, summarize what was found rather than dumping raw content.
|
||||
- Questions in Phase 2 should be specific and actionable, not generic.
|
||||
- The implementation plan in Phase 3 should reference concrete files in the codebase, not abstract descriptions.
|
||||
72
.claude/commands/review-android.md
Normal file
72
.claude/commands/review-android.md
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
description: Guided Android code review workflow through context gathering, Android-specific review, and output
|
||||
argument-hint: [PR# | PR URL | "local"]
|
||||
---
|
||||
|
||||
# Android Code Review Workflow
|
||||
|
||||
You are guiding the developer through a comprehensive Android code review for the Bitwarden Android project.
|
||||
|
||||
**Input**: $ARGUMENTS
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Jira/Confluence access**: The `bitwarden-atlassian-tools@bitwarden-marketplace` MCP plugin is required to fetch linked Jira tickets. If unavailable, skip ticket context.
|
||||
- **GitHub CLI**: Required for fetching PR metadata. Verify with `gh auth status`.
|
||||
|
||||
## Workflow Phases
|
||||
|
||||
Work through each phase sequentially. **Confirm with the user before advancing to the next phase.** The user may skip phases that are not applicable.
|
||||
|
||||
### Phase 1: Ingest
|
||||
|
||||
Parse the input to determine review context:
|
||||
|
||||
**Source Detection Rules:**
|
||||
- **PR number** (`123`, `PR #123`, `https://github.com/.../pull/123`): Extract the numeric ID. Fetch PR metadata via `gh pr view <N> --json title,body,headRefName,baseRefName,author,files`. Fetch existing review threads to avoid duplicate comments via `gh api graphql` with `reviewThreads(first: 100)`.
|
||||
- **"local"** or no argument: Review current branch changes via `git diff main...HEAD` and `git log main...HEAD --oneline --no-merges`.
|
||||
- **No input**: Ask the user whether to review a PR (provide number/URL) or local branch changes.
|
||||
|
||||
**Additional context:**
|
||||
- Detect Jira ticket references in PR title/body (patterns like `PM-\d+`, `BWA-\d+`). Fetch via `get_issue` if the MCP plugin is available.
|
||||
- Summarize what was fetched rather than dumping raw content.
|
||||
|
||||
**Present a structured summary:**
|
||||
1. What is being reviewed (PR title/number, branch, or local changes description)
|
||||
2. Jira ticket context if found (summary and acceptance criteria)
|
||||
3. Files changed (count and modules affected)
|
||||
4. Existing review thread count (PR reviews only — avoids duplicate comments)
|
||||
|
||||
**Gate**: User confirms the summary is complete before proceeding.
|
||||
|
||||
### Phase 2: Review
|
||||
|
||||
Invoke the `reviewing-changes` skill and use it to perform the Android-specific code review. Use the PR context from Phase 1 (change type, files affected, modules, Jira requirements) to inform the skill's change type detection and checklist selection.
|
||||
|
||||
The skill will:
|
||||
1. Detect the change type based on files and PR context from Phase 1
|
||||
2. Load the appropriate type-specific checklist
|
||||
3. Execute the multi-pass review strategy
|
||||
4. Consult reference materials as needed
|
||||
|
||||
**Before advancing**: Share a summary of key findings (critical issues if any, overall assessment) and confirm the user is ready to output the review.
|
||||
|
||||
### Phase 3: Output
|
||||
|
||||
Write the completed review to local files:
|
||||
|
||||
- `review-summary.md` — Overall assessment (APPROVE / REQUEST CHANGES) plus critical issues list
|
||||
- `review-inline-comments.md` — All inline findings with `<details>` tags
|
||||
|
||||
Follow the exact output format from `.claude/skills/reviewing-changes/examples/review-outputs.md`.
|
||||
|
||||
For PR reviews: offer to post the review to GitHub using `gh pr review <N> --comment -b "$(cat review-summary.md)"` for the summary. For inline comments, use the GitHub API or the `bitwarden-code-review` plugin if installed.
|
||||
|
||||
**Before advancing**: Confirm the files were written successfully and ask if the user wants to post to GitHub (PR reviews only).
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Be explicit about which phase you are in at all times.
|
||||
- Never proceed to another phase without user confirmation.
|
||||
- If the user wants to skip a phase, acknowledge and move to the next applicable phase.
|
||||
- If starting from a partially completed review (e.g., review already written), skip to the appropriate phase.
|
||||
66
.claude/commands/work-on-android.md
Normal file
66
.claude/commands/work-on-android.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
description: Guided Android development workflow through all lifecycle phases
|
||||
argument-hint: <task description, plan, or Jira ticket reference>
|
||||
---
|
||||
|
||||
# Android Development Workflow
|
||||
|
||||
You are guiding the developer through a complete Android development lifecycle for the Bitwarden Android project. The task to work on is:
|
||||
|
||||
**Task**: $ARGUMENTS
|
||||
|
||||
## Workflow Phases
|
||||
|
||||
Work through each phase sequentially. **Confirm with the user before advancing to the next phase.** If a phase fails (tests fail, lint errors, etc.), loop on that phase until resolved before advancing. The user may skip phases that are not applicable.
|
||||
|
||||
### Phase 1: Implement
|
||||
|
||||
Invoke `Skill(implementing-android-code)` to guide your implementation of the task. Understand what needs to be done, explore the relevant code, and write the implementation.
|
||||
|
||||
**Before advancing**: Summarize what was implemented and confirm the user is ready to move to testing.
|
||||
|
||||
### Phase 2: Test
|
||||
|
||||
Invoke `Skill(testing-android-code)` to write tests for the changes made in Phase 1. Follow the project's test patterns and conventions.
|
||||
|
||||
**Before advancing**: Summarize what tests were written and confirm readiness for build verification.
|
||||
|
||||
### Phase 3: Build & Verify
|
||||
|
||||
Invoke `Skill(build-test-verify)` to run tests, lint, and detekt. Ensure everything passes.
|
||||
|
||||
**If failures occur**: Fix the issues and re-run verification. Do not advance until all checks pass.
|
||||
|
||||
**Before advancing**: Report build/test/lint results and confirm readiness for self-review.
|
||||
|
||||
### Phase 4: Self-Review
|
||||
|
||||
Invoke `Skill(bitwarden-delivery-tools:perform-preflight)` to perform a quality gate check on all changes. Address any issues found.
|
||||
|
||||
**Before advancing**: Share the self-review results and confirm readiness to commit.
|
||||
|
||||
### Phase 5: Commit
|
||||
|
||||
Invoke `Skill(bitwarden-delivery-tools:committing-changes)` to stage and commit the changes with a properly formatted commit message.
|
||||
|
||||
**Before advancing**: Confirm the commit was successful and ask if the user wants to proceed to review and PR creation, or stop here.
|
||||
|
||||
### Phase 6: Review
|
||||
|
||||
**Pre-requisites:**
|
||||
- `bitwarden-code-review` from the [Bitwarden Plugin Marketplace](https://github.com/bitwarden/ai-plugins) must be installed in order to perform this phase. If it is not installed prompt the user to install it, or skip the review phase.
|
||||
|
||||
Launch a subagent with the `/bitwarden-code-review:code-review-local` command to perform a **local** code review of the committed diff. Validate and address any issues found before proceeding.
|
||||
|
||||
**Before advancing**: Share review findings and confirm readiness for PR creation.
|
||||
|
||||
### Phase 7: Pull Request
|
||||
|
||||
Prompt the user to invoke `Skill(bitwarden-delivery-tools:creating-pull-request)` to create the pull request with proper description and formatting. **Create as a draft PR by default** unless the user has explicitly requested a ready-for-review PR.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Be explicit about which phase you are in at all times.
|
||||
- Never proceed to another phase without user confirmation.
|
||||
- If the user wants to skip a phase, acknowledge and move to the next applicable phase.
|
||||
- If starting from a partially completed task (e.g., code already written), skip to the appropriate phase.
|
||||
37
.claude/mcp/android-device-server/.gitignore
vendored
Normal file
37
.claude/mcp/android-device-server/.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Build output
|
||||
build/
|
||||
dist/
|
||||
*.js
|
||||
*.js.map
|
||||
*.d.ts
|
||||
*.d.ts.map
|
||||
|
||||
# Keep source TypeScript files
|
||||
!src/**/*.ts
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
34
.claude/mcp/android-device-server/package.json
Normal file
34
.claude/mcp/android-device-server/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@bitwarden/android-device-mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for Android device interaction via ADB — UI hierarchy capture, element finding with obstruction detection, tap, and navigation",
|
||||
"type": "module",
|
||||
"main": "build/index.js",
|
||||
"bin": {
|
||||
"android-device-mcp": "./build/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && chmod +x build/index.js",
|
||||
"watch": "tsc --watch",
|
||||
"dev": "tsc && node build/index.js",
|
||||
"inspector": "npx @modelcontextprotocol/inspector build/index.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": ["mcp", "android", "adb", "model-context-protocol", "ui-testing"],
|
||||
"author": "Bitwarden",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"fast-xml-parser": "^4.5.0",
|
||||
"zod": "3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.19.35",
|
||||
"typescript": "5.8.3",
|
||||
"vitest": "3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
60
.claude/mcp/android-device-server/src/adb/adb.spec.ts
Normal file
60
.claude/mcp/android-device-server/src/adb/adb.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFileSync: vi.fn(() => { throw new Error('not found'); }),
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { findAdb, _resetCache } from './adb.js';
|
||||
|
||||
const mockExistsSync = vi.mocked(existsSync);
|
||||
const mockExecFileSync = vi.mocked(execFileSync);
|
||||
|
||||
describe('findAdb', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_resetCache();
|
||||
// Default: which fails, nothing on disk
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('not found'); });
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('finds adb in PATH via which', () => {
|
||||
mockExecFileSync.mockReturnValue('/usr/local/bin/adb\n' as any);
|
||||
expect(findAdb()).toBe('/usr/local/bin/adb');
|
||||
});
|
||||
|
||||
it('finds adb in Android SDK location', () => {
|
||||
mockExistsSync.mockImplementation((path) =>
|
||||
String(path).includes('Library/Android/sdk'),
|
||||
);
|
||||
expect(findAdb()).toContain('Library/Android/sdk/platform-tools/adb');
|
||||
});
|
||||
|
||||
it('finds adb in /usr/local/bin', () => {
|
||||
mockExistsSync.mockImplementation((path) =>
|
||||
String(path) === '/usr/local/bin/adb',
|
||||
);
|
||||
expect(findAdb()).toBe('/usr/local/bin/adb');
|
||||
});
|
||||
|
||||
it('throws when adb not found anywhere', () => {
|
||||
expect(() => findAdb()).toThrow('ADB not found');
|
||||
});
|
||||
|
||||
it('caches the result after first discovery', () => {
|
||||
mockExistsSync.mockImplementation((path) =>
|
||||
String(path) === '/usr/local/bin/adb',
|
||||
);
|
||||
findAdb();
|
||||
findAdb();
|
||||
// existsSync only called during first discovery, cached after
|
||||
expect(mockExistsSync).toHaveBeenCalledTimes(2); // SDK path + /usr/local/bin
|
||||
});
|
||||
});
|
||||
141
.claude/mcp/android-device-server/src/adb/adb.ts
Normal file
141
.claude/mcp/android-device-server/src/adb/adb.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* ADB client wrapper using child_process.execFile for safe command execution.
|
||||
* Uses execFile (not exec) to prevent shell injection — arguments are passed
|
||||
* as an array, never interpolated into a shell string.
|
||||
*/
|
||||
|
||||
import { execFile as execFileCb, execFileSync } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
let cachedAdbPath: string | null = null;
|
||||
|
||||
/** Clear the cached ADB path (for testing). */
|
||||
export function _resetCache(): void {
|
||||
cachedAdbPath = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover ADB binary location.
|
||||
* Checks: PATH → ~/Library/Android/sdk/platform-tools/adb → /usr/local/bin/adb
|
||||
*/
|
||||
export function findAdb(): string {
|
||||
if (cachedAdbPath) return cachedAdbPath;
|
||||
|
||||
// Check PATH via `which`
|
||||
try {
|
||||
const result = execFileSync('which', ['adb'], { encoding: 'utf-8' }).trim();
|
||||
if (result) {
|
||||
cachedAdbPath = result;
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// Not in PATH, try common locations
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
join(homedir(), 'Library', 'Android', 'sdk', 'platform-tools', 'adb'),
|
||||
'/usr/local/bin/adb',
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
cachedAdbPath = candidate;
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'ADB not found. Install the Android SDK or add platform-tools to PATH.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an ADB command and return stdout.
|
||||
*/
|
||||
export async function exec(args: string[]): Promise<string> {
|
||||
const adb = findAdb();
|
||||
const { stdout } = await execFile(adb, args, {
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB for large dumps
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an ADB shell command.
|
||||
*/
|
||||
export async function shell(command: string): Promise<string> {
|
||||
return exec(['shell', command]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump UI hierarchy to device, then pull to local path.
|
||||
*/
|
||||
export async function dumpHierarchy(outputPath: string): Promise<void> {
|
||||
await shell('uiautomator dump /sdcard/view.xml');
|
||||
await exec(['pull', '/sdcard/view.xml', outputPath]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture screenshot to device, then pull to local path.
|
||||
*/
|
||||
export async function screenshot(outputPath: string): Promise<void> {
|
||||
await shell('screencap -p /sdcard/screen.png');
|
||||
await exec(['pull', '/sdcard/screen.png', outputPath]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tap at screen coordinates.
|
||||
*/
|
||||
export async function tap(x: number, y: number): Promise<void> {
|
||||
await shell(`input tap ${Math.floor(x)} ${Math.floor(y)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a key event.
|
||||
*/
|
||||
export async function keyevent(code: number): Promise<void> {
|
||||
await shell(`input keyevent ${code}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a swipe gesture.
|
||||
*/
|
||||
export async function swipe(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
durationMs: number,
|
||||
): Promise<void> {
|
||||
await shell(`input swipe ${x1} ${y1} ${x2} ${y2} ${durationMs}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get screen dimensions.
|
||||
*/
|
||||
export async function getScreenSize(): Promise<{ width: number; height: number }> {
|
||||
const output = await shell('wm size');
|
||||
const match = output.match(/(\d+)x(\d+)/);
|
||||
if (!match) throw new Error(`Could not parse screen size from: ${output}`);
|
||||
return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw dumpsys window windows output.
|
||||
*/
|
||||
export async function dumpsysWindows(): Promise<string> {
|
||||
return shell('dumpsys window windows');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a specified duration (seconds).
|
||||
*/
|
||||
export function sleep(seconds: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, seconds * 1000));
|
||||
}
|
||||
54
.claude/mcp/android-device-server/src/geometry/bounds.ts
Normal file
54
.claude/mcp/android-device-server/src/geometry/bounds.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Geometric primitives for UI element bounds and point operations.
|
||||
*/
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export function center(r: Rect): Point {
|
||||
return {
|
||||
x: Math.floor((r.left + r.right) / 2),
|
||||
y: Math.floor((r.top + r.bottom) / 2),
|
||||
};
|
||||
}
|
||||
|
||||
export function area(r: Rect): number {
|
||||
const w = r.right - r.left;
|
||||
const h = r.bottom - r.top;
|
||||
return w > 0 && h > 0 ? w * h : 0;
|
||||
}
|
||||
|
||||
export function containsPoint(r: Rect, p: Point): boolean {
|
||||
return p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
|
||||
}
|
||||
|
||||
export function overlaps(a: Rect, b: Rect): boolean {
|
||||
return !(a.left >= b.right || a.right <= b.left || a.top >= b.bottom || a.bottom <= b.top);
|
||||
}
|
||||
|
||||
export function boundsEqual(a: Rect, b: Rect): boolean {
|
||||
return a.left === b.left && a.top === b.top && a.right === b.right && a.bottom === b.bottom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Android bounds string "[left,top][right,bottom]" into a Rect.
|
||||
*/
|
||||
export function parseBounds(bounds: string): Rect | null {
|
||||
const match = bounds.match(/\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
left: parseInt(match[1], 10),
|
||||
top: parseInt(match[2], 10),
|
||||
right: parseInt(match[3], 10),
|
||||
bottom: parseInt(match[4], 10),
|
||||
};
|
||||
}
|
||||
334
.claude/mcp/android-device-server/src/geometry/geometry.spec.ts
Normal file
334
.claude/mcp/android-device-server/src/geometry/geometry.spec.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
center,
|
||||
area,
|
||||
containsPoint,
|
||||
overlaps,
|
||||
boundsEqual,
|
||||
parseBounds,
|
||||
type Rect,
|
||||
} from './bounds.js';
|
||||
import { largestVisibleStrip } from './visible-region.js';
|
||||
import { detectObstruction } from './obstruction.js';
|
||||
import type { UiNode } from '../parsers/xml.js';
|
||||
import type { WindowInfo } from '../parsers/dumpsys.js';
|
||||
|
||||
describe('bounds', () => {
|
||||
const rect: Rect = { left: 100, top: 200, right: 500, bottom: 600 };
|
||||
|
||||
describe('center', () => {
|
||||
it('returns the center point of a rect', () => {
|
||||
expect(center(rect)).toEqual({ x: 300, y: 400 });
|
||||
});
|
||||
|
||||
it('floors fractional centers', () => {
|
||||
expect(center({ left: 0, top: 0, right: 101, bottom: 101 })).toEqual({ x: 50, y: 50 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('area', () => {
|
||||
it('computes area of a valid rect', () => {
|
||||
expect(area(rect)).toBe(400 * 400);
|
||||
});
|
||||
|
||||
it('returns 0 for zero-width rect', () => {
|
||||
expect(area({ left: 100, top: 200, right: 100, bottom: 600 })).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for inverted rect', () => {
|
||||
expect(area({ left: 500, top: 200, right: 100, bottom: 600 })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('containsPoint', () => {
|
||||
it('returns true for point inside rect', () => {
|
||||
expect(containsPoint(rect, { x: 300, y: 400 })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for point on edge', () => {
|
||||
expect(containsPoint(rect, { x: 100, y: 200 })).toBe(true);
|
||||
expect(containsPoint(rect, { x: 500, y: 600 })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for point outside rect', () => {
|
||||
expect(containsPoint(rect, { x: 50, y: 400 })).toBe(false);
|
||||
expect(containsPoint(rect, { x: 300, y: 700 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('overlaps', () => {
|
||||
it('returns true for overlapping rects', () => {
|
||||
expect(overlaps(rect, { left: 400, top: 500, right: 700, bottom: 800 })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-overlapping rects', () => {
|
||||
expect(overlaps(rect, { left: 600, top: 200, right: 800, bottom: 600 })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for adjacent rects (touching edges)', () => {
|
||||
expect(overlaps(rect, { left: 500, top: 200, right: 700, bottom: 600 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundsEqual', () => {
|
||||
it('returns true for identical rects', () => {
|
||||
expect(boundsEqual(rect, { ...rect })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for different rects', () => {
|
||||
expect(boundsEqual(rect, { ...rect, right: 501 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBounds', () => {
|
||||
it('parses Android bounds string', () => {
|
||||
expect(parseBounds('[100,200][500,600]')).toEqual(rect);
|
||||
});
|
||||
|
||||
it('parses zero-origin bounds', () => {
|
||||
expect(parseBounds('[0,0][1080,2400]')).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 1080,
|
||||
bottom: 2400,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses bounds with negative origin (partially off-screen element)', () => {
|
||||
expect(parseBounds('[-40,-20][1040,100]')).toEqual({
|
||||
left: -40,
|
||||
top: -20,
|
||||
right: 1040,
|
||||
bottom: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for invalid format', () => {
|
||||
expect(parseBounds('invalid')).toBeNull();
|
||||
expect(parseBounds('[100,200]')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('visible-region', () => {
|
||||
// Target element: a list row spanning most of the screen width
|
||||
const target: Rect = { left: 42, top: 1855, right: 1038, bottom: 2025 };
|
||||
|
||||
describe('largestVisibleStrip', () => {
|
||||
it('returns null when fully obscured', () => {
|
||||
const obstructor: Rect = { left: 0, top: 1800, right: 1080, bottom: 2100 };
|
||||
expect(largestVisibleStrip(target, obstructor)).toBeNull();
|
||||
});
|
||||
|
||||
it('finds bottom strip when obstructor covers top portion', () => {
|
||||
const obstructor: Rect = { left: 0, top: 1800, right: 1080, bottom: 1940 };
|
||||
const result = largestVisibleStrip(target, obstructor);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.rect.top).toBe(1940);
|
||||
expect(result!.rect.bottom).toBe(2025);
|
||||
});
|
||||
|
||||
it('finds left strip when FAB covers right side', () => {
|
||||
// FAB in bottom-right corner
|
||||
const fab: Rect = { left: 891, top: 1875, right: 1038, bottom: 2022 };
|
||||
const result = largestVisibleStrip(target, fab);
|
||||
expect(result).not.toBeNull();
|
||||
// Left strip should be largest (full height, left portion)
|
||||
expect(result!.rect.left).toBe(42);
|
||||
expect(result!.rect.right).toBe(891);
|
||||
expect(result!.area).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('picks the largest strip among candidates', () => {
|
||||
// Small obstructor in the center — all 4 strips available
|
||||
const small: Rect = { left: 400, top: 1900, right: 600, bottom: 1980 };
|
||||
const result = largestVisibleStrip(target, small);
|
||||
expect(result).not.toBeNull();
|
||||
// Left strip: (400-42) * (2025-1855) = 358 * 170 = 60860
|
||||
// Right strip: (1038-600) * 170 = 438 * 170 = 74460
|
||||
// Right strip should win
|
||||
expect(result!.rect.left).toBe(600);
|
||||
expect(result!.rect.right).toBe(1038);
|
||||
});
|
||||
|
||||
it('returns center point of the visible strip', () => {
|
||||
const fab: Rect = { left: 891, top: 1875, right: 1038, bottom: 2022 };
|
||||
const result = largestVisibleStrip(target, fab);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.center.x).toBe(Math.floor((42 + 891) / 2));
|
||||
expect(result!.center.y).toBe(Math.floor((1855 + 2025) / 2));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('obstruction detection', () => {
|
||||
// Helper to create a minimal UiNode
|
||||
function makeNode(overrides: Partial<UiNode> = {}): UiNode {
|
||||
return {
|
||||
text: '', contentDesc: '', resourceId: '', className: '',
|
||||
packageName: '', bounds: null, clickable: false, focused: false,
|
||||
enabled: true, selected: false, drawingOrder: 0, children: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const archiveRow = makeNode({
|
||||
text: 'Archive',
|
||||
bounds: { left: 42, top: 1855, right: 1038, bottom: 2025 },
|
||||
clickable: true,
|
||||
});
|
||||
|
||||
const fab = makeNode({
|
||||
contentDesc: 'Add Item',
|
||||
bounds: { left: 891, top: 1875, right: 1038, bottom: 2022 },
|
||||
clickable: true,
|
||||
});
|
||||
|
||||
// Hierarchy: root contains archiveRow and fab (fab is later = higher z-order)
|
||||
const hierarchy = makeNode({
|
||||
bounds: { left: 0, top: 0, right: 1080, bottom: 2400 },
|
||||
children: [archiveRow, fab],
|
||||
});
|
||||
|
||||
const noOverlayWindows: WindowInfo[] = [];
|
||||
|
||||
describe('clear path', () => {
|
||||
it('returns not obstructed when target is the topmost clickable', () => {
|
||||
// Tap center of archive row — only archiveRow contains this point, no FAB
|
||||
const result = detectObstruction({
|
||||
hierarchy,
|
||||
windows: noOverlayWindows,
|
||||
targetElement: archiveRow,
|
||||
tapPoint: { x: 200, y: 1940 },
|
||||
searchText: 'Archive',
|
||||
});
|
||||
expect(result.obstructed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FAB obstruction', () => {
|
||||
it('detects FAB overlapping the target center', () => {
|
||||
// Tap at a point where both archive row and FAB overlap — FAB is later in tree
|
||||
const result = detectObstruction({
|
||||
hierarchy,
|
||||
windows: noOverlayWindows,
|
||||
targetElement: archiveRow,
|
||||
tapPoint: { x: 965, y: 1948 },
|
||||
searchText: 'Archive',
|
||||
});
|
||||
expect(result.obstructed).toBe(true);
|
||||
if (result.obstructed) {
|
||||
expect(result.obstructor).toContain('Add Item');
|
||||
expect(result.adjustedPoint).not.toBeNull();
|
||||
expect(result.fullyObscured).toBe(false);
|
||||
// Adjusted point should be in the left strip (away from FAB)
|
||||
expect(result.adjustedPoint!.x).toBeLessThan(891);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('system overlay', () => {
|
||||
it('detects TalkBack FloatingMenu overlay at tap point', () => {
|
||||
const talkbackWindows: WindowInfo[] = [
|
||||
{
|
||||
name: 'FloatingMenu',
|
||||
type: 'NAVIGATION_BAR_PANEL',
|
||||
hasSurface: true,
|
||||
touchableRegion: { left: 891, top: 1875, right: 1038, bottom: 2022 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = detectObstruction({
|
||||
hierarchy,
|
||||
windows: talkbackWindows,
|
||||
targetElement: archiveRow,
|
||||
tapPoint: { x: 965, y: 1948 },
|
||||
searchText: 'Archive',
|
||||
});
|
||||
expect(result.obstructed).toBe(true);
|
||||
if (result.obstructed) {
|
||||
expect(result.obstructor).toContain('FloatingMenu');
|
||||
expect(result.adjustedPoint).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('system overlay takes precedence over in-app elements', () => {
|
||||
// Both a system overlay and FAB at the same point — system overlay detected first
|
||||
const talkbackWindows: WindowInfo[] = [
|
||||
{
|
||||
name: 'FloatingMenu',
|
||||
type: 'NAVIGATION_BAR_PANEL',
|
||||
hasSurface: true,
|
||||
touchableRegion: { left: 891, top: 1875, right: 1038, bottom: 2022 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = detectObstruction({
|
||||
hierarchy,
|
||||
windows: talkbackWindows,
|
||||
targetElement: archiveRow,
|
||||
tapPoint: { x: 965, y: 1948 },
|
||||
searchText: 'Archive',
|
||||
});
|
||||
expect(result.obstructed).toBe(true);
|
||||
if (result.obstructed) {
|
||||
expect(result.obstructor).toContain('system_overlay');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('fully obscured', () => {
|
||||
it('reports fully obscured when obstructor covers entire target', () => {
|
||||
const fullScreenOverlay: WindowInfo[] = [
|
||||
{
|
||||
name: 'SystemDialog',
|
||||
type: 'SYSTEM_ALERT',
|
||||
hasSurface: true,
|
||||
touchableRegion: { left: 0, top: 0, right: 1080, bottom: 2400 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = detectObstruction({
|
||||
hierarchy,
|
||||
windows: fullScreenOverlay,
|
||||
targetElement: archiveRow,
|
||||
tapPoint: { x: 540, y: 1940 },
|
||||
searchText: 'Archive',
|
||||
});
|
||||
expect(result.obstructed).toBe(true);
|
||||
if (result.obstructed) {
|
||||
expect(result.fullyObscured).toBe(true);
|
||||
expect(result.adjustedPoint).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Compose parent wrapper', () => {
|
||||
it('treats identical bounds as parent wrapper, not obstruction', () => {
|
||||
// Compose pattern: clickable parent has same bounds as text child
|
||||
const textChild = makeNode({
|
||||
contentDesc: 'Download now',
|
||||
bounds: { left: 84, top: 553, right: 996, bottom: 679 },
|
||||
clickable: false,
|
||||
});
|
||||
const clickableParent = makeNode({
|
||||
bounds: { left: 84, top: 553, right: 996, bottom: 679 },
|
||||
clickable: true,
|
||||
children: [textChild],
|
||||
});
|
||||
const tree = makeNode({
|
||||
bounds: { left: 0, top: 0, right: 1080, bottom: 2400 },
|
||||
children: [clickableParent],
|
||||
});
|
||||
|
||||
const result = detectObstruction({
|
||||
hierarchy: tree,
|
||||
windows: noOverlayWindows,
|
||||
targetElement: textChild,
|
||||
tapPoint: { x: 540, y: 616 },
|
||||
searchText: 'Download now',
|
||||
});
|
||||
expect(result.obstructed).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
127
.claude/mcp/android-device-server/src/geometry/obstruction.ts
Normal file
127
.claude/mcp/android-device-server/src/geometry/obstruction.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Two-layer obstruction detection for UI elements.
|
||||
*
|
||||
* Layer 1: System overlay windows (TalkBack, PiP, accessibility services)
|
||||
* detected via parsed `dumpsys window windows` output.
|
||||
* Layer 2: In-app elements (FABs, dialogs, bottom sheets) detected via
|
||||
* the UIAutomator XML hierarchy — topmost clickable at tap point.
|
||||
*
|
||||
* When obstruction is found, computes an alternative tap point using the
|
||||
* largest visible strip of the target element not covered by the obstructor.
|
||||
*/
|
||||
|
||||
import { type Point, type Rect, center, boundsEqual } from './bounds.js';
|
||||
import { largestVisibleStrip, type VisibleStrip } from './visible-region.js';
|
||||
import { type UiNode, findTopmostClickableAt } from '../parsers/xml.js';
|
||||
import { type WindowInfo, findOverlayAtPoint } from '../parsers/dumpsys.js';
|
||||
|
||||
export type ObstructionResult =
|
||||
| { obstructed: false }
|
||||
| {
|
||||
obstructed: true;
|
||||
obstructor: string;
|
||||
obstructorBounds: Rect;
|
||||
adjustedPoint: Point | null;
|
||||
visibleRegion: VisibleStrip | null;
|
||||
fullyObscured: boolean;
|
||||
};
|
||||
|
||||
export interface DetectObstructionParams {
|
||||
hierarchy: UiNode;
|
||||
windows: WindowInfo[];
|
||||
targetElement: UiNode;
|
||||
tapPoint: Point;
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the tap point is obstructed by a system overlay or in-app element.
|
||||
*/
|
||||
export function detectObstruction(params: DetectObstructionParams): ObstructionResult {
|
||||
const { hierarchy, windows, targetElement, tapPoint, searchText } = params;
|
||||
|
||||
// Layer 1: System overlays (TalkBack, PiP, accessibility services)
|
||||
const overlay = findOverlayAtPoint(windows, tapPoint);
|
||||
if (overlay) {
|
||||
return buildResult(
|
||||
`system_overlay window=${overlay.name} type=${overlay.type}`,
|
||||
overlay.touchableRegion!,
|
||||
targetElement,
|
||||
);
|
||||
}
|
||||
|
||||
// Layer 2: In-app elements (FABs, dialogs, bottom sheets)
|
||||
const topmost = findTopmostClickableAt(hierarchy, tapPoint);
|
||||
if (topmost && topmost.bounds) {
|
||||
// Check if topmost IS the target (no obstruction)
|
||||
if (isTargetMatch(topmost, targetElement, searchText)) {
|
||||
return { obstructed: false };
|
||||
}
|
||||
|
||||
return buildResult(
|
||||
formatElementId(topmost),
|
||||
topmost.bounds,
|
||||
targetElement,
|
||||
);
|
||||
}
|
||||
|
||||
return { obstructed: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the topmost clickable element matches the target.
|
||||
*
|
||||
* Match criteria:
|
||||
* - Search text appears in topmost's text or contentDesc
|
||||
* - Bounds are identical (Compose parent wrapper pattern)
|
||||
*/
|
||||
function isTargetMatch(topmost: UiNode, target: UiNode, searchText: string): boolean {
|
||||
const lower = searchText.toLowerCase();
|
||||
|
||||
// Text/content-desc match
|
||||
if (topmost.text.toLowerCase().includes(lower)) return true;
|
||||
if (topmost.contentDesc.toLowerCase().includes(lower)) return true;
|
||||
|
||||
// Bounds equality (Compose parent wrapper)
|
||||
if (target.bounds && topmost.bounds && boundsEqual(target.bounds, topmost.bounds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildResult(
|
||||
obstructorId: string,
|
||||
obstructorBounds: Rect,
|
||||
target: UiNode,
|
||||
): ObstructionResult {
|
||||
if (!target.bounds) {
|
||||
return {
|
||||
obstructed: true,
|
||||
obstructor: obstructorId,
|
||||
obstructorBounds,
|
||||
adjustedPoint: null,
|
||||
visibleRegion: null,
|
||||
fullyObscured: true,
|
||||
};
|
||||
}
|
||||
|
||||
const strip = largestVisibleStrip(target.bounds, obstructorBounds);
|
||||
|
||||
return {
|
||||
obstructed: true,
|
||||
obstructor: obstructorId,
|
||||
obstructorBounds,
|
||||
adjustedPoint: strip?.center ?? null,
|
||||
visibleRegion: strip ?? null,
|
||||
fullyObscured: strip === null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatElementId(node: UiNode): string {
|
||||
if (node.text) return `text="${node.text}"`;
|
||||
if (node.contentDesc) return `desc="${node.contentDesc}"`;
|
||||
if (node.resourceId) return `id="${node.resourceId}"`;
|
||||
if (node.bounds) return `bounds=[${node.bounds.left},${node.bounds.top}][${node.bounds.right},${node.bounds.bottom}]`;
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Visible region computation for partially obstructed UI elements.
|
||||
*
|
||||
* When a target element is partially covered by an obstructor (FAB, PiP, dialog),
|
||||
* this module finds the largest unobstructed rectangular strip and returns its
|
||||
* center as an alternative tap point.
|
||||
*/
|
||||
|
||||
import { type Rect, type Point, area, center } from './bounds.js';
|
||||
|
||||
export interface VisibleStrip {
|
||||
rect: Rect;
|
||||
center: Point;
|
||||
area: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the largest visible rectangular strip of the target not covered by the obstructor.
|
||||
*
|
||||
* Evaluates 4 candidate strips:
|
||||
* - Top: above the obstructor, full target width
|
||||
* - Bottom: below the obstructor, full target width
|
||||
* - Left: left of the obstructor, full target height
|
||||
* - Right: right of the obstructor, full target height
|
||||
*
|
||||
* Returns the strip with the largest area, or null if fully obscured.
|
||||
*/
|
||||
export function largestVisibleStrip(target: Rect, obstructor: Rect): VisibleStrip | null {
|
||||
const candidates: Rect[] = [];
|
||||
|
||||
// Top strip: above obstructor, full target width
|
||||
if (obstructor.top > target.top) {
|
||||
candidates.push({
|
||||
left: target.left,
|
||||
top: target.top,
|
||||
right: target.right,
|
||||
bottom: obstructor.top,
|
||||
});
|
||||
}
|
||||
|
||||
// Bottom strip: below obstructor, full target width
|
||||
if (obstructor.bottom < target.bottom) {
|
||||
candidates.push({
|
||||
left: target.left,
|
||||
top: obstructor.bottom,
|
||||
right: target.right,
|
||||
bottom: target.bottom,
|
||||
});
|
||||
}
|
||||
|
||||
// Left strip: left of obstructor, full target height
|
||||
if (obstructor.left > target.left) {
|
||||
candidates.push({
|
||||
left: target.left,
|
||||
top: target.top,
|
||||
right: obstructor.left,
|
||||
bottom: target.bottom,
|
||||
});
|
||||
}
|
||||
|
||||
// Right strip: right of obstructor, full target height
|
||||
if (obstructor.right < target.right) {
|
||||
candidates.push({
|
||||
left: obstructor.right,
|
||||
top: target.top,
|
||||
right: target.right,
|
||||
bottom: target.bottom,
|
||||
});
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
let best: Rect = candidates[0];
|
||||
let bestArea = area(candidates[0]);
|
||||
|
||||
for (let i = 1; i < candidates.length; i++) {
|
||||
const a = area(candidates[i]);
|
||||
if (a > bestArea) {
|
||||
best = candidates[i];
|
||||
bestArea = a;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestArea <= 0) return null;
|
||||
|
||||
return {
|
||||
rect: best,
|
||||
center: center(best),
|
||||
area: bestArea,
|
||||
};
|
||||
}
|
||||
64
.claude/mcp/android-device-server/src/index.ts
Normal file
64
.claude/mcp/android-device-server/src/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Android Device MCP Server
|
||||
* MCP server for Android device interaction via ADB — UI hierarchy capture,
|
||||
* element finding with obstruction detection, tap, and navigation.
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { ToolDefinition } from './utils/validation.js';
|
||||
import capture from './tools/capture.js';
|
||||
import findElement from './tools/find-element.js';
|
||||
import tapAt from './tools/tap-at.js';
|
||||
import tapElement from './tools/tap-element.js';
|
||||
import navigate from './tools/navigate.js';
|
||||
import inputText from './tools/input-text.js';
|
||||
|
||||
const tools: ToolDefinition[] = [capture, findElement, tapAt, tapElement, navigate, inputText];
|
||||
|
||||
async function main() {
|
||||
const server = new Server(
|
||||
{ name: 'android-device-mcp', version: '1.0.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: tools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
})),
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
const tool = tools.find(t => t.name === name);
|
||||
|
||||
if (!tool) {
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.handler(args || {});
|
||||
return { content: [{ type: 'text', text: result }] };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Tool error (${name}):`, message);
|
||||
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
|
||||
}
|
||||
});
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,630 @@
|
||||
WINDOW MANAGER WINDOWS (dumpsys window windows)
|
||||
Window #0 Window{ba7e323 u0 ScreenDecorOverlayBottom}:
|
||||
mDisplayId=0 mSession=Session{1e8a728 2463:u0a10210} mClient=android.os.BinderProxy@b7880dd
|
||||
mOwnerUid=10210 showForAllUsers=true package=com.android.systemui appop=NONE
|
||||
mAttrs={(0,0)(fillxwrap) gr=BOTTOM CENTER_VERTICAL sim={adjust=pan} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR_PANEL fmt=TRANSLUCENT
|
||||
fl=NOT_FOCUSABLE NOT_TOUCHABLE NOT_TOUCH_MODAL LAYOUT_IN_SCREEN FLAG_SLIPPERY
|
||||
pfl=SHOW_FOR_ALL_USERS NO_MOVE_ANIMATION IS_ROUNDED_CORNERS_OVERLAY COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED TRUSTED_OVERLAY
|
||||
vsysui=LAYOUT_STABLE
|
||||
bhv=DEFAULT
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
Requested w=1080 h=74 mLayoutSeq=18196
|
||||
mBaseLayer=251000 mSubLayer=0 mToken=WindowToken{1806452 type=2024 android.os.BinderProxy@b7880dd}
|
||||
mViewVisibility=0x0 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0]
|
||||
mTouchableInsets=3 mGivenInsetsPending=false
|
||||
touchable region=SkRegion()
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mHasSurface=true isReadyForDisplay()=true mWindowRemovalAllowed=false
|
||||
Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,2326][1080,2400] last=[0,2326][1080,2400] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
WindowStateAnimator{10dcf18 ScreenDecorOverlayBottom}:
|
||||
mSurface=Surface(name=ScreenDecorOverlayBottom#71)/@0xc8aad71
|
||||
Surface: shown=true mDrawState=HAS_DRAWN mLastHidden=false
|
||||
mEnterAnimationPending=false
|
||||
isOnScreen=true
|
||||
isVisible=true
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
Window #1 Window{7f4a38f u0 ScreenDecorOverlay}:
|
||||
mDisplayId=0 mSession=Session{1e8a728 2463:u0a10210} mClient=android.os.BinderProxy@ad30ea2
|
||||
mOwnerUid=10210 showForAllUsers=true package=com.android.systemui appop=NONE
|
||||
mAttrs={(0,0)(fillxwrap) gr=TOP CENTER_VERTICAL sim={adjust=pan} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR_PANEL fmt=TRANSLUCENT
|
||||
fl=NOT_FOCUSABLE NOT_TOUCHABLE NOT_TOUCH_MODAL LAYOUT_IN_SCREEN FLAG_SLIPPERY
|
||||
pfl=SHOW_FOR_ALL_USERS NO_MOVE_ANIMATION IS_ROUNDED_CORNERS_OVERLAY COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED TRUSTED_OVERLAY
|
||||
vsysui=LAYOUT_STABLE
|
||||
bhv=DEFAULT
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
Requested w=1080 h=128 mLayoutSeq=18196
|
||||
mBaseLayer=251000 mSubLayer=0 mToken=WindowToken{2167869 type=2024 android.os.BinderProxy@ad30ea2}
|
||||
mViewVisibility=0x0 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0]
|
||||
mTouchableInsets=3 mGivenInsetsPending=false
|
||||
touchable region=SkRegion((492,0,610,128))
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mHasSurface=true isReadyForDisplay()=true mWindowRemovalAllowed=false
|
||||
Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,128] last=[0,0][1080,128] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
WindowStateAnimator{e4a7756 ScreenDecorOverlay}:
|
||||
mSurface=Surface(name=ScreenDecorOverlay#70)/@0x89533d7
|
||||
Surface: shown=true mDrawState=HAS_DRAWN mLastHidden=false
|
||||
mEnterAnimationPending=false
|
||||
isOnScreen=true
|
||||
isVisible=true
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
Window #2 Window{cc49e92 u0 FloatingMenu}:
|
||||
mDisplayId=0 mSession=Session{1e8a728 2463:u0a10210} mClient=android.os.BinderProxy@cbefcf4
|
||||
mOwnerUid=10210 showForAllUsers=true package=com.android.systemui appop=NONE
|
||||
mAttrs={(0,0)(fillxfill) sim={adjust=resize} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR_PANEL fmt=TRANSLUCENT wanim=0x1030003 receive insets ignoring z-order
|
||||
fl=NOT_FOCUSABLE HARDWARE_ACCELERATED
|
||||
pfl=SHOW_FOR_ALL_USERS UNRESTRICTED_GESTURE_EXCLUSION EXCLUDE_FROM_SCREEN_MAGNIFICATION FIT_INSETS_CONTROLLED
|
||||
bhv=DEFAULT
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
Requested w=1080 h=2400 mLayoutSeq=18196
|
||||
mBaseLayer=251000 mSubLayer=0 mToken=WindowToken{71c3c1d type=2024 android.os.BinderProxy@cbefcf4}
|
||||
mViewVisibility=0x0 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0]
|
||||
mTouchableInsets=3 mGivenInsetsPending=false
|
||||
touchable region=SkRegion((953,297,1080,424))
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mHasSurface=true isReadyForDisplay()=true mWindowRemovalAllowed=false
|
||||
Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
WindowStateAnimator{c9cc4c4 FloatingMenu}:
|
||||
mAnimationIsEntrance=true mSurface=Surface(name=FloatingMenu#25467)/@0x64bafad
|
||||
Surface: shown=true mDrawState=HAS_DRAWN mLastHidden=false
|
||||
mEnterAnimationPending=false
|
||||
isOnScreen=true
|
||||
isVisible=true
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
Window #3 Window{5cac0da u0 Taskbar}:
|
||||
mDisplayId=0 mSession=Session{41a0116 2805:u0a10196} mClient=android.os.BinderProxy@b45f3fc
|
||||
mOwnerUid=10196 showForAllUsers=true package=com.google.android.apps.nexuslauncher appop=NONE
|
||||
mAttrs={(0,0)(fillx126) gr=BOTTOM CENTER_VERTICAL sim={adjust=nothing} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR fmt=TRANSLUCENT receive insets ignoring z-order
|
||||
fl=NOT_FOCUSABLE NOT_TOUCH_MODAL WATCH_OUTSIDE_TOUCH FLAG_SLIPPERY
|
||||
pfl=NO_MOVE_ANIMATION
|
||||
bhv=DEFAULT
|
||||
providedInsets:
|
||||
InsetsFrameProvider: {id=#d3210001, index=0, type=navigationBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=126}}
|
||||
InsetsFrameProvider: {id=#d3210006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=126}, insetsSizeOverrides=[TypedInsetsSize: {windowType=VOICE_INTERACTION, insetsSize=Insets{left=0, top=0, right=0, bottom=126}}]}
|
||||
InsetsFrameProvider: {id=#d3210005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=126}}
|
||||
InsetsFrameProvider: {id=#d3210004, index=0, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#d3210024, index=1, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}}
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true
|
||||
paramsForRotation:
|
||||
ROTATION_0={(0,0)(fillx126) gr=BOTTOM CENTER_VERTICAL sim={adjust=nothing} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR fmt=TRANSLUCENT receive insets ignoring z-order
|
||||
fl=NOT_FOCUSABLE NOT_TOUCH_MODAL WATCH_OUTSIDE_TOUCH FLAG_SLIPPERY
|
||||
pfl=NO_MOVE_ANIMATION
|
||||
bhv=DEFAULT
|
||||
providedInsets:
|
||||
InsetsFrameProvider: {id=#3fa90001, index=0, type=navigationBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=126}}
|
||||
InsetsFrameProvider: {id=#3fa90006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=126}, insetsSizeOverrides=[TypedInsetsSize: {windowType=VOICE_INTERACTION, insetsSize=Insets{left=0, top=0, right=0, bottom=126}}]}
|
||||
InsetsFrameProvider: {id=#3fa90005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=126}}
|
||||
InsetsFrameProvider: {id=#3fa90004, index=0, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#3fa90024, index=1, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}}
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
ROTATION_90={(0,0)(126xfill) gr=END CENTER_HORIZONTAL sim={adjust=nothing} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR fmt=TRANSLUCENT receive insets ignoring z-order
|
||||
fl=NOT_FOCUSABLE NOT_TOUCH_MODAL WATCH_OUTSIDE_TOUCH FLAG_SLIPPERY
|
||||
pfl=NO_MOVE_ANIMATION
|
||||
bhv=DEFAULT
|
||||
providedInsets:
|
||||
InsetsFrameProvider: {id=#3fa90001, index=0, type=navigationBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=126, bottom=0}}
|
||||
InsetsFrameProvider: {id=#3fa90006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=126, bottom=0}, insetsSizeOverrides=[TypedInsetsSize: {windowType=VOICE_INTERACTION, insetsSize=Insets{left=0, top=0, right=126, bottom=0}}]}
|
||||
InsetsFrameProvider: {id=#3fa90005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=126, bottom=0}}
|
||||
InsetsFrameProvider: {id=#3fa90004, index=0, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#3fa90024, index=1, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}}
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
ROTATION_180={(0,0)(fillx126) gr=BOTTOM CENTER_VERTICAL sim={adjust=nothing} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR fmt=TRANSLUCENT receive insets ignoring z-order
|
||||
fl=NOT_FOCUSABLE NOT_TOUCH_MODAL WATCH_OUTSIDE_TOUCH FLAG_SLIPPERY
|
||||
pfl=NO_MOVE_ANIMATION
|
||||
bhv=DEFAULT
|
||||
providedInsets:
|
||||
InsetsFrameProvider: {id=#3fa90001, index=0, type=navigationBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=128}}
|
||||
InsetsFrameProvider: {id=#3fa90006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=126}, insetsSizeOverrides=[TypedInsetsSize: {windowType=VOICE_INTERACTION, insetsSize=Insets{left=0, top=0, right=0, bottom=126}}]}
|
||||
InsetsFrameProvider: {id=#3fa90005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=128}}
|
||||
InsetsFrameProvider: {id=#3fa90004, index=0, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#3fa90024, index=1, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}}
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
ROTATION_270={(0,0)(126xfill) gr=START CENTER_HORIZONTAL sim={adjust=nothing} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR fmt=TRANSLUCENT receive insets ignoring z-order
|
||||
fl=NOT_FOCUSABLE NOT_TOUCH_MODAL WATCH_OUTSIDE_TOUCH FLAG_SLIPPERY
|
||||
pfl=NO_MOVE_ANIMATION
|
||||
bhv=DEFAULT
|
||||
providedInsets:
|
||||
InsetsFrameProvider: {id=#3fa90001, index=0, type=navigationBars, source=FRAME, flags=[], insetsSize=Insets{left=126, top=0, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#3fa90006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=126, top=0, right=0, bottom=0}, insetsSizeOverrides=[TypedInsetsSize: {windowType=VOICE_INTERACTION, insetsSize=Insets{left=126, top=0, right=0, bottom=0}}]}
|
||||
InsetsFrameProvider: {id=#3fa90005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=126, top=0, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#3fa90004, index=0, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#3fa90024, index=1, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}}
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}}
|
||||
Requested w=1080 h=126 mLayoutSeq=18196
|
||||
mBaseLayer=241000 mSubLayer=0 mToken=WindowToken{9fc53e8 type=2019 android.os.BinderProxy@a5e4338}
|
||||
mViewVisibility=0x0 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0]
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mHasSurface=true isReadyForDisplay()=true mWindowRemovalAllowed=false
|
||||
Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,2274][1080,2400] last=[0,2274][1080,2400] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
ContainerAnimator:
|
||||
mLeash=Surface(name=Surface(name=5cac0da Taskbar#73)/@0x536c694 - animation-leash of insets_animation#25499)/@0xa8eb2e2 mAnimationType=insets_animation
|
||||
Animation: com.android.server.wm.InsetsSourceProvider$ControlAdapter@60f3673
|
||||
ControlAdapter mCapturedLeash=Surface(name=Surface(name=5cac0da Taskbar#73)/@0x536c694 - animation-leash of insets_animation#25499)/@0xa8eb2e2
|
||||
WindowStateAnimator{7036930 Taskbar}:
|
||||
mSurface=Surface(name=Taskbar#75)/@0x1a099a9
|
||||
Surface: shown=true mDrawState=HAS_DRAWN mLastHidden=false
|
||||
mEnterAnimationPending=false
|
||||
isOnScreen=true
|
||||
isVisible=true
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
Window #4 Window{b5c1512 u0 NotificationShade}:
|
||||
mDisplayId=0 mSession=Session{1e8a728 2463:u0a10210} mClient=android.os.BinderProxy@11aef74
|
||||
mOwnerUid=10210 showForAllUsers=true package=com.android.systemui appop=NONE
|
||||
mAttrs={(0,0)(fillxfill) gr=TOP CENTER_VERTICAL sim={adjust=pan} layoutInDisplayCutoutMode=always ty=NOTIFICATION_SHADE fmt=TRANSLUCENT
|
||||
fl=NOT_FOCUSABLE TOUCHABLE_WHEN_WAKING WATCH_OUTSIDE_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS
|
||||
pfl=OPTIMIZE_MEASURE COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED
|
||||
bhv=SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
Requested w=1080 h=2400 mLayoutSeq=18146
|
||||
mBaseLayer=171000 mSubLayer=0 mToken=WindowToken{b50b90c type=2040 android.os.BinderProxy@8bb9b47}
|
||||
mViewVisibility=0x4 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0]
|
||||
mTouchableInsets=3 mGivenInsetsPending=false
|
||||
touchable region=SkRegion((0,0,1080,128)(492,128,610,160))
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false
|
||||
Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
WindowStateAnimator{1e91b2e NotificationShade}:
|
||||
mDrawState=NO_SURFACE mLastHidden=false
|
||||
mEnterAnimationPending=false
|
||||
isOnScreen=false
|
||||
isVisible=false
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
Window #5 Window{92afd17 u0 StatusBar}:
|
||||
mDisplayId=0 mSession=Session{1e8a728 2463:u0a10210} mClient=android.os.BinderProxy@a484b1
|
||||
mOwnerUid=10210 showForAllUsers=true package=com.android.systemui appop=NONE
|
||||
mAttrs={(0,0)(fillx128) gr=TOP CENTER_VERTICAL sim={adjust=pan} layoutInDisplayCutoutMode=always ty=STATUS_BAR fmt=TRANSLUCENT
|
||||
fl=NOT_FOCUSABLE HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS
|
||||
pfl=COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED
|
||||
bhv=DEFAULT
|
||||
providedInsets:
|
||||
InsetsFrameProvider: {id=#25730000, index=0, type=statusBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=128, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#25730006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=128, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#25730005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], mMinimalInsetsSizeInDisplayCutoutSafe=Insets{left=0, top=32, right=0, bottom=0}}
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true
|
||||
paramsForRotation:
|
||||
ROTATION_0={(0,0)(fillx128) gr=TOP CENTER_VERTICAL layoutInDisplayCutoutMode=always ty=STATUS_BAR fmt=TRANSLUCENT
|
||||
fl=NOT_FOCUSABLE DRAWS_SYSTEM_BAR_BACKGROUNDS
|
||||
pfl=COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED
|
||||
bhv=DEFAULT
|
||||
providedInsets:
|
||||
InsetsFrameProvider: {id=#25730000, index=0, type=statusBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=128, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#25730006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=128, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#25730005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=128, right=0, bottom=0}, mMinimalInsetsSizeInDisplayCutoutSafe=Insets{left=0, top=32, right=0, bottom=0}}
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
ROTATION_90={(0,0)(fillx74) gr=TOP CENTER_VERTICAL layoutInDisplayCutoutMode=always ty=STATUS_BAR fmt=TRANSLUCENT
|
||||
fl=NOT_FOCUSABLE DRAWS_SYSTEM_BAR_BACKGROUNDS
|
||||
pfl=COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED
|
||||
bhv=DEFAULT
|
||||
providedInsets:
|
||||
InsetsFrameProvider: {id=#25730000, index=0, type=statusBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#25730006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#25730005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}, mMinimalInsetsSizeInDisplayCutoutSafe=Insets{left=0, top=32, right=0, bottom=0}}
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
ROTATION_180={(0,0)(fillx74) gr=TOP CENTER_VERTICAL layoutInDisplayCutoutMode=always ty=STATUS_BAR fmt=TRANSLUCENT
|
||||
fl=NOT_FOCUSABLE DRAWS_SYSTEM_BAR_BACKGROUNDS
|
||||
pfl=COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED
|
||||
bhv=DEFAULT
|
||||
providedInsets:
|
||||
InsetsFrameProvider: {id=#25730000, index=0, type=statusBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#25730006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#25730005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}, mMinimalInsetsSizeInDisplayCutoutSafe=Insets{left=0, top=32, right=0, bottom=0}}
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
ROTATION_270={(0,0)(fillx74) gr=TOP CENTER_VERTICAL layoutInDisplayCutoutMode=always ty=STATUS_BAR fmt=TRANSLUCENT
|
||||
fl=NOT_FOCUSABLE DRAWS_SYSTEM_BAR_BACKGROUNDS
|
||||
pfl=COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED
|
||||
bhv=DEFAULT
|
||||
providedInsets:
|
||||
InsetsFrameProvider: {id=#25730000, index=0, type=statusBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#25730006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}}
|
||||
InsetsFrameProvider: {id=#25730005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}, mMinimalInsetsSizeInDisplayCutoutSafe=Insets{left=0, top=32, right=0, bottom=0}}
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}}
|
||||
Requested w=1080 h=128 mLayoutSeq=18196
|
||||
mBaseLayer=151000 mSubLayer=0 mToken=WindowToken{8609d96 type=2000 android.os.BinderProxy@5177b58}
|
||||
mViewVisibility=0x0 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0]
|
||||
mTouchableInsets=3 mGivenInsetsPending=false
|
||||
touchable region=SkRegion((0,0,1080,128))
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mHasSurface=true isReadyForDisplay()=true mWindowRemovalAllowed=false
|
||||
Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,128] last=[0,0][1080,128] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
ContainerAnimator:
|
||||
mLeash=Surface(name=Surface(name=92afd17 StatusBar#78)/@0x47a6386 - animation-leash of insets_animation#25498)/@0xa1cc6cf mAnimationType=insets_animation
|
||||
Animation: com.android.server.wm.InsetsSourceProvider$ControlAdapter@5ede85c
|
||||
ControlAdapter mCapturedLeash=Surface(name=Surface(name=92afd17 StatusBar#78)/@0x47a6386 - animation-leash of insets_animation#25498)/@0xa1cc6cf
|
||||
WindowStateAnimator{6402765 StatusBar}:
|
||||
mSurface=Surface(name=StatusBar#83)/@0x26fbc3a
|
||||
Surface: shown=true mDrawState=HAS_DRAWN mLastHidden=false
|
||||
mEnterAnimationPending=false
|
||||
isOnScreen=true
|
||||
isVisible=true
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
Window #6 Window{175a4d2 u0 ShellDropTarget}:
|
||||
mDisplayId=0 mSession=Session{1e8a728 2463:u0a10210} mClient=android.os.BinderProxy@32704a0
|
||||
mOwnerUid=10210 showForAllUsers=true package=com.android.systemui appop=SYSTEM_ALERT_WINDOW
|
||||
mAttrs={(0,0)(fillxfill) sim={adjust=pan} layoutInDisplayCutoutMode=always ty=APPLICATION_OVERLAY fmt=TRANSLUCENT
|
||||
fl=NOT_FOCUSABLE HARDWARE_ACCELERATED
|
||||
pfl=SHOW_FOR_ALL_USERS NO_MOVE_ANIMATION FIT_INSETS_CONTROLLED INTERCEPT_GLOBAL_DRAG_AND_DROP
|
||||
bhv=DEFAULT
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
Requested w=1080 h=2400 mLayoutSeq=5
|
||||
mBaseLayer=111000 mSubLayer=0 mToken=WindowToken{ec5e859 type=2038 android.os.BinderProxy@620a66c}
|
||||
mViewVisibility=0x4 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0]
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={0.0 ?mcc0mnc ?localeList ?layoutDir ?swdp ?wdp ?hdp ?density ?lsize ?long ?round ?ldr ?wideColorGamut ?orien ?uimode ?night ?touch ?keyb/?/? ?nav/? winConfig={ mBounds=Rect(0, 0 - 0, 0) mAppBounds=null mMaxBounds=Rect(0, 0 - 0, 0) mDisplayRotation=undefined mWindowingMode=undefined mActivityType=undefined mAlwaysOnTop=undefined mRotation=undefined} ?fontWeightAdjustment}
|
||||
mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false
|
||||
Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
WindowStateAnimator{3c900eb ShellDropTarget}:
|
||||
mDrawState=NO_SURFACE mLastHidden=false
|
||||
mEnterAnimationPending=false
|
||||
mShownAlpha=0.0 mAlpha=1.0 mLastAlpha=0.0
|
||||
isOnScreen=false
|
||||
isVisible=false
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
Window #7 Window{d056a34 u0 InputMethod}:
|
||||
mDisplayId=0 mSession=Session{ed481a5 10035:u0a10168} mClient=android.os.BinderProxy@a88e346
|
||||
mOwnerUid=10168 showForAllUsers=false package=com.google.android.inputmethod.latin appop=NONE
|
||||
mAttrs={(0,0)(fillxfill) gr=BOTTOM CENTER_VERTICAL sim={adjust=pan} ty=INPUT_METHOD fmt=TRANSPARENT wanim=0x1030056 receive insets ignoring z-order
|
||||
fl=NOT_FOCUSABLE LAYOUT_IN_SCREEN SPLIT_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS
|
||||
pfl=EDGE_TO_EDGE_ENFORCED FIT_INSETS_CONTROLLED
|
||||
bhv=DEFAULT
|
||||
fitTypes=statusBars navigationBars
|
||||
fitSides=LEFT TOP RIGHT
|
||||
dvrrWindowFrameRateHint=true}
|
||||
Requested w=1080 h=2272 mLayoutSeq=18191
|
||||
mIsImWindow=true mIsWallpaper=false mIsFloatingLayer=true
|
||||
mBaseLayer=131000 mSubLayer=0 mToken=WindowToken{4aa0ed3 type=2011 android.os.Binder@fa630c2}
|
||||
mViewVisibility=0x8 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,2272][0,0] mGivenVisibleInsets=[0,2272][0,0]
|
||||
mTouchableInsets=3 mGivenInsetsPending=false
|
||||
touchable region=SkRegion()
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false
|
||||
Frames: parent=[0,128][1080,2400] display=[0,128][1080,2400] frame=[0,128][1080,2400] last=[0,128][1080,2400] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
ContainerAnimator:
|
||||
mLeash=Surface(name=Surface(name=d056a34 InputMethod#20167)/@0x9dfedd2 - animation-leash of insets_animation#25500)/@0xb9f2e48 mAnimationType=insets_animation
|
||||
Animation: com.android.server.wm.InsetsSourceProvider$ControlAdapter@690d4e1
|
||||
ControlAdapter mCapturedLeash=Surface(name=Surface(name=d056a34 InputMethod#20167)/@0x9dfedd2 - animation-leash of insets_animation#25500)/@0xb9f2e48
|
||||
WindowStateAnimator{38d6206 InputMethod}:
|
||||
mDrawState=NO_SURFACE mLastHidden=false
|
||||
mEnterAnimationPending=false
|
||||
isOnScreen=false
|
||||
isVisible=false
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
Window #8 Window{37bdea2 u0 com.x8bit.bitwarden.dev/com.x8bit.bitwarden.MainActivity}:
|
||||
mDisplayId=0 taskId=1857 mSession=Session{e78c8d8 7233:u0a10379} mClient=android.os.BinderProxy@c7326d
|
||||
mOwnerUid=10379 showForAllUsers=false package=com.x8bit.bitwarden.dev appop=NONE
|
||||
mAttrs={(0,0)(fillxfill) sim={adjust=resize} layoutInDisplayCutoutMode=always ty=BASE_APPLICATION fmt=TRANSPARENT wanim=0x103030d
|
||||
fl=LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SPLIT_TOUCH DRAWS_SYSTEM_BAR_BACKGROUNDS
|
||||
pfl=NO_MOVE_ANIMATION EDGE_TO_EDGE_ENFORCED FORCE_DRAW_STATUS_BAR_BACKGROUND FIT_INSETS_CONTROLLED
|
||||
bhv=DEFAULT
|
||||
fitSides=
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
Requested w=1080 h=2400 mLayoutSeq=18196
|
||||
mBaseLayer=21000 mSubLayer=0 mToken=ActivityRecord{49392223 u0 com.x8bit.bitwarden.dev/com.x8bit.bitwarden.MainActivity t1857}
|
||||
mActivityRecord=ActivityRecord{49392223 u0 com.x8bit.bitwarden.dev/com.x8bit.bitwarden.MainActivity t1857}
|
||||
drawnStateEvaluated=true mightAffectAllDrawn=true
|
||||
mViewVisibility=0x0 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0]
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0}
|
||||
mLastReportedActivityWindowInfo=ActivityWindowInfo{isEmbedded=false, taskBounds=Rect(0, 0 - 1080, 2400), taskFragmentBounds=Rect(0, 0 - 1080, 2400)}
|
||||
mHasSurface=true isReadyForDisplay()=true mWindowRemovalAllowed=false
|
||||
Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
WindowStateAnimator{db4c0c7 com.x8bit.bitwarden.dev/com.x8bit.bitwarden.MainActivity}:
|
||||
mSurface=Surface(name=com.x8bit.bitwarden.dev/com.x8bit.bitwarden.MainActivity#25485)/@0x99ce6f4
|
||||
Surface: shown=true mDrawState=HAS_DRAWN mLastHidden=false
|
||||
mEnterAnimationPending=false
|
||||
isOnScreen=true
|
||||
isVisible=true
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
Window #9 Window{cb57263 u0 com.google.android.apps.nexuslauncher/com.google.android.apps.nexuslauncher.NexusLauncherActivity}:
|
||||
mDisplayId=0 taskId=5 mSession=Session{41a0116 2805:u0a10196} mClient=android.os.BinderProxy@399a892
|
||||
mOwnerUid=10196 showForAllUsers=false package=com.google.android.apps.nexuslauncher appop=NONE
|
||||
mAttrs={(0,0)(fillxfill) sim={adjust=nothing} layoutInDisplayCutoutMode=always ty=BASE_APPLICATION fmt=TRANSPARENT wanim=0x1030301
|
||||
fl=LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SHOW_WALLPAPER SPLIT_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS
|
||||
pfl=NO_MOVE_ANIMATION OPTIMIZE_MEASURE EDGE_TO_EDGE_ENFORCED FORCE_DRAW_STATUS_BAR_BACKGROUND FIT_INSETS_CONTROLLED
|
||||
vsysui=LAYOUT_STABLE LAYOUT_HIDE_NAVIGATION LAYOUT_FULLSCREEN
|
||||
bhv=DEFAULT
|
||||
fitSides=
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
Requested w=1080 h=2400 mLayoutSeq=18155
|
||||
mBaseLayer=21000 mSubLayer=0 mToken=ActivityRecord{62739071 u0 com.google.android.apps.nexuslauncher/.NexusLauncherActivity t5}
|
||||
mActivityRecord=ActivityRecord{62739071 u0 com.google.android.apps.nexuslauncher/.NexusLauncherActivity t5}
|
||||
drawnStateEvaluated=true mightAffectAllDrawn=true
|
||||
mViewVisibility=0x8 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0]
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=home mAlwaysOnTop=undefined mRotation=ROTATION_0} s.24 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=home mAlwaysOnTop=undefined mRotation=ROTATION_0} s.24 fontWeightAdjustment=0}
|
||||
mLastReportedActivityWindowInfo=ActivityWindowInfo{isEmbedded=false, taskBounds=Rect(0, 0 - 1080, 2400), taskFragmentBounds=Rect(0, 0 - 1080, 2400)}
|
||||
mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false
|
||||
Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
WindowStateAnimator{ae4de1d com.google.android.apps.nexuslauncher/com.google.android.apps.nexuslauncher.NexusLauncherActivity}:
|
||||
mDrawState=NO_SURFACE mLastHidden=true
|
||||
mEnterAnimationPending=false
|
||||
mWallpaperX=0.0 mWallpaperY=0.5
|
||||
mWallpaperXStep=0.33333334 mWallpaperYStep=1.0
|
||||
mWallpaperZoomOut=0.32999983
|
||||
isOnScreen=false
|
||||
isVisible=false
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
Window #10 Window{67f1aa4 u0 com.bitwarden.authenticator/com.bitwarden.authenticator.MainActivity}:
|
||||
mDisplayId=0 taskId=1864 mSession=Session{47e596a 7150:u0a10327} mClient=android.os.BinderProxy@295b537
|
||||
mOwnerUid=10327 showForAllUsers=false package=com.bitwarden.authenticator appop=NONE
|
||||
mAttrs={(0,0)(fillxfill) sim={adjust=resize forwardNavigation} layoutInDisplayCutoutMode=always ty=BASE_APPLICATION fmt=TRANSPARENT wanim=0x103030d
|
||||
fl=LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SPLIT_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS
|
||||
pfl=NO_MOVE_ANIMATION EDGE_TO_EDGE_ENFORCED FORCE_DRAW_STATUS_BAR_BACKGROUND FIT_INSETS_CONTROLLED
|
||||
bhv=DEFAULT
|
||||
fitSides=
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
Requested w=1080 h=2400 mLayoutSeq=18152
|
||||
mBaseLayer=21000 mSubLayer=0 mToken=ActivityRecord{27882677 u0 com.bitwarden.authenticator/.MainActivity t1864}
|
||||
mActivityRecord=ActivityRecord{27882677 u0 com.bitwarden.authenticator/.MainActivity t1864}
|
||||
drawnStateEvaluated=true mightAffectAllDrawn=true
|
||||
mViewVisibility=0x8 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0]
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0}
|
||||
mLastReportedActivityWindowInfo=ActivityWindowInfo{isEmbedded=false, taskBounds=Rect(0, 0 - 1080, 2400), taskFragmentBounds=Rect(0, 0 - 1080, 2400)}
|
||||
mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false
|
||||
Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
WindowStateAnimator{68d9892 com.bitwarden.authenticator/com.bitwarden.authenticator.MainActivity}:
|
||||
mDrawState=NO_SURFACE mLastHidden=true
|
||||
mEnterAnimationPending=false
|
||||
isOnScreen=false
|
||||
isVisible=false
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
Window #11 Window{369ab3f u0 com.android.chrome/com.google.android.apps.chrome.Main}:
|
||||
mDisplayId=0 taskId=1863 mSession=Session{732339d 12069:u0a10152} mClient=android.os.BinderProxy@7e20b5e
|
||||
mOwnerUid=10152 showForAllUsers=false package=com.android.chrome appop=NONE
|
||||
mAttrs={(0,0)(fillxfill) sim={state=always_hidden adjust=resize} layoutInDisplayCutoutMode=always ty=BASE_APPLICATION fmt=TRANSLUCENT wanim=0x103030d sysuil=true
|
||||
fl=LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SPLIT_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS
|
||||
pfl=NO_MOVE_ANIMATION EDGE_TO_EDGE_ENFORCED FORCE_DRAW_STATUS_BAR_BACKGROUND FIT_INSETS_CONTROLLED
|
||||
bhv=DEFAULT
|
||||
fitSides=
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
Requested w=1080 h=2400 mLayoutSeq=18046
|
||||
mBaseLayer=21000 mSubLayer=0 mToken=ActivityRecord{30085890 u0 com.android.chrome/com.google.android.apps.chrome.Main t1863}
|
||||
mActivityRecord=ActivityRecord{30085890 u0 com.android.chrome/com.google.android.apps.chrome.Main t1863}
|
||||
drawnStateEvaluated=true mightAffectAllDrawn=true
|
||||
mViewVisibility=0x8 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0]
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0}
|
||||
mLastReportedActivityWindowInfo=ActivityWindowInfo{isEmbedded=false, taskBounds=Rect(0, 0 - 1080, 2400), taskFragmentBounds=Rect(0, 0 - 1080, 2400)}
|
||||
mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false
|
||||
Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
WindowStateAnimator{1b2a263 com.android.chrome/com.google.android.apps.chrome.Main}:
|
||||
mDrawState=NO_SURFACE mLastHidden=true
|
||||
mEnterAnimationPending=false
|
||||
isOnScreen=false
|
||||
isVisible=false
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
Window #12 Window{94a5865 u0 com.google.android.apps.weather/com.google.android.apps.weather.home.HomeActivity}:
|
||||
mDisplayId=0 taskId=1860 mSession=Session{5b870df 8279:u0a10259} mClient=android.os.BinderProxy@cee3d5c
|
||||
mOwnerUid=10259 showForAllUsers=false package=com.google.android.apps.weather appop=NONE
|
||||
mAttrs={(0,0)(fillxfill) sim={adjust=resize} layoutInDisplayCutoutMode=always ty=BASE_APPLICATION wanim=0x103030d
|
||||
fl=LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SPLIT_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS
|
||||
pfl=NO_MOVE_ANIMATION EDGE_TO_EDGE_ENFORCED FORCE_DRAW_STATUS_BAR_BACKGROUND FIT_INSETS_CONTROLLED
|
||||
bhv=DEFAULT
|
||||
fitSides=
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
Requested w=1080 h=2400 mLayoutSeq=17742
|
||||
mBaseLayer=21000 mSubLayer=0 mToken=ActivityRecord{191055369 u0 com.google.android.apps.weather/.home.HomeActivity t1860}
|
||||
mActivityRecord=ActivityRecord{191055369 u0 com.google.android.apps.weather/.home.HomeActivity t1860}
|
||||
drawnStateEvaluated=true mightAffectAllDrawn=true
|
||||
mViewVisibility=0x8 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0]
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0}
|
||||
mLastReportedActivityWindowInfo=ActivityWindowInfo{isEmbedded=false, taskBounds=Rect(0, 0 - 1080, 2400), taskFragmentBounds=Rect(0, 0 - 1080, 2400)}
|
||||
mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false
|
||||
Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
WindowStateAnimator{21b7e60 com.google.android.apps.weather/com.google.android.apps.weather.home.HomeActivity}:
|
||||
mDrawState=NO_SURFACE mLastHidden=true
|
||||
mEnterAnimationPending=false
|
||||
isOnScreen=false
|
||||
isVisible=false
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
Window #13 Window{2783d3c u0 com.android.settings/com.android.settings.homepage.SettingsHomepageActivity}:
|
||||
mDisplayId=0 taskId=1858 mSession=Session{3c7c9ff 2864:1000} mClient=android.os.BinderProxy@e79e32f
|
||||
mOwnerUid=1000 showForAllUsers=false package=com.android.settings appop=NONE
|
||||
mAttrs={(0,0)(fillxfill) sim={adjust=pan} layoutInDisplayCutoutMode=always ty=BASE_APPLICATION wanim=0x1030301
|
||||
fl=LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SPLIT_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS
|
||||
pfl=NO_MOVE_ANIMATION EDGE_TO_EDGE_ENFORCED FORCE_DRAW_STATUS_BAR_BACKGROUND FIT_INSETS_CONTROLLED
|
||||
bhv=DEFAULT
|
||||
fitSides=
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
Requested w=1080 h=2400 mLayoutSeq=17559
|
||||
mBaseLayer=21000 mSubLayer=0 mToken=ActivityRecord{13824966 u0 com.android.settings/.homepage.SettingsHomepageActivity t1858}
|
||||
mActivityRecord=ActivityRecord{13824966 u0 com.android.settings/.homepage.SettingsHomepageActivity t1858}
|
||||
drawnStateEvaluated=true mightAffectAllDrawn=true
|
||||
mViewVisibility=0x8 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0]
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.1 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.1 fontWeightAdjustment=0}
|
||||
mLastReportedActivityWindowInfo=ActivityWindowInfo{isEmbedded=false, taskBounds=Rect(0, 0 - 1080, 2400), taskFragmentBounds=Rect(0, 0 - 1080, 2400)}
|
||||
mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false
|
||||
Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
WindowStateAnimator{4763f19 com.android.settings/com.android.settings.homepage.SettingsHomepageActivity}:
|
||||
mDrawState=NO_SURFACE mLastHidden=true
|
||||
mEnterAnimationPending=false
|
||||
isOnScreen=false
|
||||
isVisible=false
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
Window #14 Window{dae7553 u0 com.google.android.youtube/com.google.android.youtube.app.honeycomb.Shell$HomeActivity}:
|
||||
mDisplayId=0 taskId=1861 mSession=Session{a444b7 32472:u0a10162} mClient=android.os.BinderProxy@afa2142
|
||||
mOwnerUid=10162 showForAllUsers=false package=com.google.android.youtube appop=NONE
|
||||
mAttrs={(0,0)(fillxfill) sim={adjust=resize} layoutInDisplayCutoutMode=always ty=BASE_APPLICATION fmt=TRANSLUCENT wanim=0x103030d sysuil=true
|
||||
fl=LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SPLIT_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS
|
||||
pfl=NO_MOVE_ANIMATION EDGE_TO_EDGE_ENFORCED FORCE_DRAW_STATUS_BAR_BACKGROUND FIT_INSETS_CONTROLLED
|
||||
vsysui=LAYOUT_STABLE LAYOUT_HIDE_NAVIGATION LAYOUT_FULLSCREEN IMMERSIVE_STICKY
|
||||
bhv=SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
fitSides=
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
Requested w=598 h=336 mLayoutSeq=18064
|
||||
mBaseLayer=21000 mSubLayer=0 mToken=ActivityRecord{212995835 u0 com.google.android.youtube/.app.honeycomb.Shell$HomeActivity t1861}
|
||||
mActivityRecord=ActivityRecord{212995835 u0 com.google.android.youtube/.app.honeycomb.Shell$HomeActivity t1861}
|
||||
drawnStateEvaluated=true mightAffectAllDrawn=true
|
||||
mViewVisibility=0x8 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0]
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.7 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw128dp w228dp h128dp 420dpi smll hdr widecg land night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(440, 202 - 1038, 538) mAppBounds=Rect(440, 202 - 1038, 538) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=pinned mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.6 fontWeightAdjustment=0}
|
||||
mLastReportedActivityWindowInfo=ActivityWindowInfo{isEmbedded=false, taskBounds=Rect(440, 202 - 1038, 538), taskFragmentBounds=Rect(440, 202 - 1038, 538)}
|
||||
mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false
|
||||
Frames: parent=[440,202][1038,538] display=[440,202][1038,538] frame=[440,202][1038,538] last=[440,202][1038,538] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
WindowStateAnimator{3e8abde com.google.android.youtube/com.google.android.youtube.app.honeycomb.Shell$HomeActivity}:
|
||||
mDrawState=NO_SURFACE mLastHidden=true
|
||||
mEnterAnimationPending=false
|
||||
isOnScreen=false
|
||||
isVisible=false
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
Window #15 Window{ee012ae u0 com.android.systemui.wallpapers.ImageWallpaper}:
|
||||
mDisplayId=0 mSession=Session{1e8a728 2463:u0a10210} mClient=android.os.BinderProxy@fc2bf29
|
||||
mOwnerUid=10210 showForAllUsers=true package=com.android.systemui appop=NONE
|
||||
mAttrs={(0,0)(1080x2400) gr=TOP START CENTER layoutInDisplayCutoutMode=always ty=WALLPAPER fmt=RGBX_8888 wanim=0x103031d
|
||||
fl=NOT_FOCUSABLE NOT_TOUCHABLE LAYOUT_IN_SCREEN LAYOUT_NO_LIMITS SCALED LAYOUT_INSET_DECOR
|
||||
pfl=WANTS_OFFSET_NOTIFICATIONS SHOW_FOR_ALL_USERS
|
||||
bhv=DEFAULT
|
||||
frameRateBoostOnTouch=true
|
||||
dvrrWindowFrameRateHint=true}
|
||||
Requested w=1080 h=2400 mLayoutSeq=18162
|
||||
mIsImWindow=false mIsWallpaper=true mIsFloatingLayer=true
|
||||
mBaseLayer=11000 mSubLayer=0 mToken=WallpaperWindowToken{f41295f showWhenLocked=true}
|
||||
mViewVisibility=0x0 mHaveFrame=true mObscured=false
|
||||
mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0]
|
||||
mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mHasSurface=true isReadyForDisplay()=false mWindowRemovalAllowed=false
|
||||
Frames: parent=[0,0][1080,2400] display=[-100000,-100000][100000,100000] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false
|
||||
surface=[0,0][0,0]
|
||||
WindowStateAnimator{a2301bf com.android.systemui.wallpapers.ImageWallpaper}:
|
||||
mSurface=Surface(name=com.android.systemui.wallpapers.ImageWallpaper#63)/@0x42a208c
|
||||
Surface: shown=false mDrawState=HAS_DRAWN mLastHidden=true
|
||||
mEnterAnimationPending=false
|
||||
mWallpaperX=0.0 mWallpaperY=0.5
|
||||
mWallpaperXStep=0.33333334 mWallpaperYStep=1.0
|
||||
mWallpaperZoomOut=0.32999983
|
||||
isOnScreen=false
|
||||
isVisible=false
|
||||
keepClearAreas: restricted=[], unrestricted=[]
|
||||
mPrepareSyncSeqId=0
|
||||
mBufferSeqId=0
|
||||
|
||||
mGlobalConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0}
|
||||
mHasPermanentDpad=false
|
||||
mTopFocusedDisplayId=0
|
||||
Minimum task size of display#0 220
|
||||
Minimum task size of display#589 220
|
||||
mBlurEnabled=true
|
||||
mDisableSecureWindows=false
|
||||
mHighResSnapshotScale=0.8
|
||||
mSnapshotEnabled=true
|
||||
SnapshotCache Task
|
||||
Entry token=1864
|
||||
topApp=ActivityRecord{27882677 u0 com.bitwarden.authenticator/.MainActivity t1864}
|
||||
snapshot=TaskSnapshot{ mId=1775056698479 mCaptureTime=2306525709626021 mTopActivityComponent=com.bitwarden.authenticator/.MainActivity mSnapshot=android.hardware.HardwareBuffer@422b3d5 (864x1920) mColorSpace=sRGB IEC61966-2.1 (id=0, model=RGB) mOrientation=1 mRotation=0 mTaskSize=Point(1080, 2400) mContentInsets=[0,128][0,126] mLetterboxInsets=[0,0][0,0] mIsLowResolution=false mIsRealSnapshot=true mWindowingMode=1 mAppearance=0 mIsTranslucent=true mHasImeSurface=false mInternalReferences=2 mWriteToParcelCount=0 mUiMode=21 mDensityDpi=420
|
||||
Entry token=1863
|
||||
topApp=ActivityRecord{30085890 u0 com.android.chrome/com.google.android.apps.chrome.Main t1863}
|
||||
snapshot=TaskSnapshot{ mId=1774987059375 mCaptureTime=2236884117126274 mTopActivityComponent=com.android.chrome/org.chromium.chrome.browser.ChromeTabbedActivity mSnapshot=android.hardware.HardwareBuffer@14fa7ea (864x1920) mColorSpace=sRGB IEC61966-2.1 (id=0, model=RGB) mOrientation=1 mRotation=0 mTaskSize=Point(1080, 2400) mContentInsets=[0,128][0,126] mLetterboxInsets=[0,0][0,0] mIsLowResolution=false mIsRealSnapshot=true mWindowingMode=1 mAppearance=0 mIsTranslucent=true mHasImeSurface=false mInternalReferences=2 mWriteToParcelCount=0 mUiMode=21 mDensityDpi=420
|
||||
Entry token=1861
|
||||
topApp=ActivityRecord{212995835 u0 com.google.android.youtube/.app.honeycomb.Shell$HomeActivity t1861}
|
||||
snapshot=TaskSnapshot{ mId=1774986206569 mCaptureTime=2236031308978926 mTopActivityComponent=com.google.android.youtube/com.google.android.apps.youtube.app.watchwhile.MainActivity mSnapshot=android.hardware.HardwareBuffer@d0ffadb (864x1920) mColorSpace=sRGB IEC61966-2.1 (id=0, model=RGB) mOrientation=1 mRotation=0 mTaskSize=Point(1080, 2400) mContentInsets=[0,128][0,126] mLetterboxInsets=[0,0][0,0] mIsLowResolution=false mIsRealSnapshot=true mWindowingMode=1 mAppearance=0 mIsTranslucent=true mHasImeSurface=false mInternalReferences=2 mWriteToParcelCount=0 mUiMode=21 mDensityDpi=420
|
||||
Entry token=1860
|
||||
topApp=ActivityRecord{191055369 u0 com.google.android.apps.weather/.home.HomeActivity t1860}
|
||||
snapshot=TaskSnapshot{ mId=1774985600020 mCaptureTime=2235424763889762 mTopActivityComponent=com.google.android.apps.weather/.home.HomeActivity mSnapshot=android.hardware.HardwareBuffer@e9eb978 (864x1920) mColorSpace=sRGB IEC61966-2.1 (id=0, model=RGB) mOrientation=1 mRotation=0 mTaskSize=Point(1080, 2400) mContentInsets=[0,128][0,126] mLetterboxInsets=[0,0][0,0] mIsLowResolution=false mIsRealSnapshot=true mWindowingMode=1 mAppearance=0 mIsTranslucent=false mHasImeSurface=false mInternalReferences=2 mWriteToParcelCount=0 mUiMode=21 mDensityDpi=420
|
||||
Entry token=1858
|
||||
topApp=ActivityRecord{13824966 u0 com.android.settings/.homepage.SettingsHomepageActivity t1858}
|
||||
snapshot=TaskSnapshot{ mId=1774980521946 mCaptureTime=2230346684630203 mTopActivityComponent=com.android.settings/.homepage.SettingsHomepageActivity mSnapshot=android.hardware.HardwareBuffer@e7b851 (864x1920) mColorSpace=sRGB IEC61966-2.1 (id=0, model=RGB) mOrientation=1 mRotation=0 mTaskSize=Point(1080, 2400) mContentInsets=[0,128][0,126] mLetterboxInsets=[0,0][0,0] mIsLowResolution=false mIsRealSnapshot=true mWindowingMode=1 mAppearance=0 mIsTranslucent=false mHasImeSurface=false mInternalReferences=2 mWriteToParcelCount=0 mUiMode=21 mDensityDpi=420
|
||||
mHighResSnapshotScale=0.6
|
||||
mSnapshotEnabled=true
|
||||
SnapshotCache Activity
|
||||
UserSavedFile userId=0
|
||||
mInputMethodWindow=Window{d056a34 u0 InputMethod}
|
||||
mTraversalScheduled=false
|
||||
mSystemBooted=true mDisplayEnabled=true
|
||||
mTransactionSequence=47123
|
||||
mRotation=0
|
||||
mLastOrientation=-1
|
||||
mWaitingForConfig=false
|
||||
mWindowsInsetsChanged=0
|
||||
mDisplayRotationWatchers: [ 2000->0 10196->0 10210->0]
|
||||
Animation settings: disabled=false window=1.0 transition=1.0 animator=1.0
|
||||
File diff suppressed because one or more lines are too long
122
.claude/mcp/android-device-server/src/parsers/dumpsys.spec.ts
Normal file
122
.claude/mcp/android-device-server/src/parsers/dumpsys.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDumpsysWindows, findOverlayAtPoint } from './dumpsys.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const fixtureOutput = readFileSync(
|
||||
join(__dirname, '__fixtures__', 'dumpsys-windows.txt'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
describe('parseDumpsysWindows', () => {
|
||||
it('parses all windows from real dumpsys output', () => {
|
||||
const windows = parseDumpsysWindows(fixtureOutput);
|
||||
expect(windows.length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it('extracts window names', () => {
|
||||
const windows = parseDumpsysWindows(fixtureOutput);
|
||||
const names = windows.map(w => w.name);
|
||||
expect(names).toContain('FloatingMenu');
|
||||
expect(names).toContain('StatusBar');
|
||||
});
|
||||
|
||||
it('extracts window types from mAttrs line', () => {
|
||||
const windows = parseDumpsysWindows(fixtureOutput);
|
||||
const floatingMenu = windows.find(w => w.name === 'FloatingMenu');
|
||||
expect(floatingMenu).toBeDefined();
|
||||
expect(floatingMenu!.type).toBe('NAVIGATION_BAR_PANEL');
|
||||
});
|
||||
|
||||
it('does not match ty= in ROTATION_ lines or mViewVisibility', () => {
|
||||
const windows = parseDumpsysWindows(fixtureOutput);
|
||||
// Taskbar has ROTATION_ lines with ty= — should only capture the mAttrs ty=
|
||||
const taskbar = windows.find(w => w.name === 'Taskbar');
|
||||
expect(taskbar).toBeDefined();
|
||||
expect(taskbar!.type).toBe('NAVIGATION_BAR');
|
||||
});
|
||||
|
||||
it('extracts surface visibility', () => {
|
||||
const windows = parseDumpsysWindows(fixtureOutput);
|
||||
const floatingMenu = windows.find(w => w.name === 'FloatingMenu');
|
||||
expect(floatingMenu!.hasSurface).toBe(true);
|
||||
|
||||
const notificationShade = windows.find(w => w.name === 'NotificationShade');
|
||||
expect(notificationShade!.hasSurface).toBe(false);
|
||||
});
|
||||
|
||||
it('extracts touchable region with coordinates', () => {
|
||||
const windows = parseDumpsysWindows(fixtureOutput);
|
||||
const floatingMenu = windows.find(w => w.name === 'FloatingMenu');
|
||||
expect(floatingMenu!.touchableRegion).not.toBeNull();
|
||||
expect(floatingMenu!.touchableRegion!.left).toBeGreaterThanOrEqual(0);
|
||||
expect(floatingMenu!.touchableRegion!.right).toBeLessThanOrEqual(1080);
|
||||
});
|
||||
|
||||
it('handles empty SkRegion() as null touchable region', () => {
|
||||
const windows = parseDumpsysWindows(fixtureOutput);
|
||||
const screenDecorBottom = windows.find(w => w.name === 'ScreenDecorOverlayBottom');
|
||||
expect(screenDecorBottom).toBeDefined();
|
||||
expect(screenDecorBottom!.touchableRegion).toBeNull();
|
||||
});
|
||||
|
||||
it('parses app window as BASE_APPLICATION type', () => {
|
||||
const windows = parseDumpsysWindows(fixtureOutput);
|
||||
const appWindow = windows.find(w => w.name.includes('bitwarden'));
|
||||
expect(appWindow).toBeDefined();
|
||||
expect(appWindow!.type).toBe('BASE_APPLICATION');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOverlayAtPoint', () => {
|
||||
it('finds FloatingMenu overlay at its touchable region', () => {
|
||||
const windows = parseDumpsysWindows(fixtureOutput);
|
||||
const floatingMenu = windows.find(w => w.name === 'FloatingMenu');
|
||||
expect(floatingMenu?.touchableRegion).not.toBeNull();
|
||||
|
||||
const region = floatingMenu!.touchableRegion!;
|
||||
const center = {
|
||||
x: Math.floor((region.left + region.right) / 2),
|
||||
y: Math.floor((region.top + region.bottom) / 2),
|
||||
};
|
||||
|
||||
const overlay = findOverlayAtPoint(windows, center);
|
||||
// Should find some overlay at this point (FloatingMenu or ScreenDecorOverlay)
|
||||
expect(overlay).not.toBeNull();
|
||||
expect(overlay!.type).not.toBe('BASE_APPLICATION');
|
||||
});
|
||||
|
||||
it('returns null for point with no overlays', () => {
|
||||
const windows = parseDumpsysWindows(fixtureOutput);
|
||||
// Point in the middle of the screen — unlikely to have overlay touchable regions
|
||||
const overlay = findOverlayAtPoint(windows, { x: 540, y: 1000 });
|
||||
expect(overlay).toBeNull();
|
||||
});
|
||||
|
||||
it('excludes BASE_APPLICATION windows', () => {
|
||||
const windows = parseDumpsysWindows(fixtureOutput);
|
||||
// The app window covers the whole screen but should never be returned
|
||||
const overlay = findOverlayAtPoint(windows, { x: 540, y: 1200 });
|
||||
if (overlay) {
|
||||
expect(overlay.type).not.toBe('BASE_APPLICATION');
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes windows without visible surface', () => {
|
||||
const windows = parseDumpsysWindows(fixtureOutput);
|
||||
// NotificationShade has touchable region but mHasSurface=false
|
||||
const shadeRegion = windows.find(w => w.name === 'NotificationShade')?.touchableRegion;
|
||||
if (shadeRegion) {
|
||||
const overlay = findOverlayAtPoint(windows, {
|
||||
x: Math.floor((shadeRegion.left + shadeRegion.right) / 2),
|
||||
y: Math.floor((shadeRegion.top + shadeRegion.bottom) / 2),
|
||||
});
|
||||
// Should not return NotificationShade since its surface is not visible
|
||||
if (overlay) {
|
||||
expect(overlay.name).not.toBe('NotificationShade');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
105
.claude/mcp/android-device-server/src/parsers/dumpsys.ts
Normal file
105
.claude/mcp/android-device-server/src/parsers/dumpsys.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Structured parser for `adb shell dumpsys window windows` output.
|
||||
*
|
||||
* Extracts window name, type, surface visibility, and touchable region
|
||||
* from the multi-line per-window blocks. Replaces the fragile awk
|
||||
* state machine from the shell scripts.
|
||||
*/
|
||||
|
||||
import { type Rect, type Point, containsPoint } from '../geometry/bounds.js';
|
||||
|
||||
export interface WindowInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
hasSurface: boolean;
|
||||
touchableRegion: Rect | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `dumpsys window windows` output into structured window objects.
|
||||
*/
|
||||
export function parseDumpsysWindows(output: string): WindowInfo[] {
|
||||
const windows: WindowInfo[] = [];
|
||||
let current: Partial<WindowInfo> | null = null;
|
||||
|
||||
for (const line of output.split('\n')) {
|
||||
// New window block: " Window #N Window{hash u0 NAME}:"
|
||||
const windowMatch = line.match(/Window #\d+ Window\{[0-9a-f]+ \S+ (.+)\}:/);
|
||||
if (windowMatch) {
|
||||
if (current?.name) {
|
||||
windows.push(finalizeWindow(current));
|
||||
}
|
||||
current = { name: windowMatch[1], type: '', hasSurface: false, touchableRegion: null };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current) continue;
|
||||
|
||||
// Window type: " ty=TYPE " (leading space to avoid matching mViewVisibility=0x0)
|
||||
// Only match on the mAttrs line, not ROTATION_ lines
|
||||
if (!current.type && line.includes('mAttrs=') && line.includes(' ty=')) {
|
||||
const tyMatch = line.match(/ ty=(\S+)/);
|
||||
if (tyMatch) {
|
||||
current.type = tyMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Surface visibility
|
||||
if (line.includes('mHasSurface=true')) {
|
||||
current.hasSurface = true;
|
||||
}
|
||||
|
||||
// Touchable region: SkRegion((l,t,r,b)) or SkRegion((l,t,r,b)(l2,t2,r2,b2))
|
||||
// We take the first rect if multiple. Empty SkRegion() means no touchable area.
|
||||
if (line.includes('touchable region=SkRegion(')) {
|
||||
const regionMatch = line.match(/SkRegion\(\((\d+),(\d+),(\d+),(\d+)\)/);
|
||||
if (regionMatch) {
|
||||
current.touchableRegion = {
|
||||
left: parseInt(regionMatch[1], 10),
|
||||
top: parseInt(regionMatch[2], 10),
|
||||
right: parseInt(regionMatch[3], 10),
|
||||
bottom: parseInt(regionMatch[4], 10),
|
||||
};
|
||||
}
|
||||
// SkRegion() with no coords = no touchable area, leave as null
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last window
|
||||
if (current?.name) {
|
||||
windows.push(finalizeWindow(current));
|
||||
}
|
||||
|
||||
return windows;
|
||||
}
|
||||
|
||||
function finalizeWindow(partial: Partial<WindowInfo>): WindowInfo {
|
||||
return {
|
||||
name: partial.name ?? '',
|
||||
type: partial.type ?? '',
|
||||
hasSurface: partial.hasSurface ?? false,
|
||||
touchableRegion: partial.touchableRegion ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first overlay window whose touchable region contains the given point.
|
||||
*
|
||||
* Filters out BASE_APPLICATION windows (the app itself) and windows without
|
||||
* a visible surface or touchable region. Only windows that actually intercept
|
||||
* taps are considered.
|
||||
*/
|
||||
export function findOverlayAtPoint(windows: WindowInfo[], point: Point): WindowInfo | null {
|
||||
for (const win of windows) {
|
||||
if (
|
||||
win.hasSurface &&
|
||||
win.type !== 'BASE_APPLICATION' &&
|
||||
win.type !== '' &&
|
||||
win.touchableRegion &&
|
||||
containsPoint(win.touchableRegion, point)
|
||||
) {
|
||||
return win;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
120
.claude/mcp/android-device-server/src/parsers/xml.spec.ts
Normal file
120
.claude/mcp/android-device-server/src/parsers/xml.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseHierarchy, findElementByText, findTopmostClickableAt } from './xml.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const fixtureXml = readFileSync(join(__dirname, '__fixtures__', 'view.xml'), 'utf-8');
|
||||
|
||||
describe('parseHierarchy', () => {
|
||||
it('parses real UIAutomator XML into a node tree', () => {
|
||||
const root = parseHierarchy(fixtureXml);
|
||||
expect(root.className).toBe('android.widget.FrameLayout');
|
||||
expect(root.packageName).toBe('com.x8bit.bitwarden.dev');
|
||||
expect(root.bounds).toEqual({ left: 0, top: 0, right: 1080, bottom: 2400 });
|
||||
});
|
||||
|
||||
it('preserves the full tree depth with children', () => {
|
||||
const root = parseHierarchy(fixtureXml);
|
||||
expect(root.children.length).toBeGreaterThan(0);
|
||||
// Should have deeply nested children
|
||||
let depth = 0;
|
||||
let node = root;
|
||||
while (node.children.length > 0) {
|
||||
node = node.children[0];
|
||||
depth++;
|
||||
}
|
||||
expect(depth).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it('parses boolean attributes correctly', () => {
|
||||
const root = parseHierarchy(fixtureXml);
|
||||
// Root FrameLayout is not clickable
|
||||
expect(root.clickable).toBe(false);
|
||||
expect(root.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('throws on invalid XML', () => {
|
||||
expect(() => parseHierarchy('<invalid>')).toThrow();
|
||||
});
|
||||
|
||||
it('throws on XML without hierarchy root', () => {
|
||||
expect(() => parseHierarchy('<?xml version="1.0"?><other/>')).toThrow('missing <hierarchy>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findElementByText', () => {
|
||||
it('finds element by text attribute', () => {
|
||||
const root = parseHierarchy(fixtureXml);
|
||||
const el = findElementByText(root, 'Login');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.text).toBe('Login');
|
||||
});
|
||||
|
||||
it('finds element by content-desc', () => {
|
||||
const root = parseHierarchy(fixtureXml);
|
||||
const el = findElementByText(root, 'Add Item');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.contentDesc).toBe('Add Item');
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
const root = parseHierarchy(fixtureXml);
|
||||
const el = findElementByText(root, 'login');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.text).toBe('Login');
|
||||
});
|
||||
|
||||
it('returns null for non-existent text', () => {
|
||||
const root = parseHierarchy(fixtureXml);
|
||||
expect(findElementByText(root, 'NONEXISTENT_TEXT_12345')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns element with parsed bounds', () => {
|
||||
const root = parseHierarchy(fixtureXml);
|
||||
const el = findElementByText(root, 'Settings');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.bounds).not.toBeNull();
|
||||
expect(el!.bounds!.left).toBeGreaterThanOrEqual(0);
|
||||
expect(el!.bounds!.right).toBeLessThanOrEqual(1080);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findTopmostClickableAt', () => {
|
||||
it('finds the topmost clickable element at a point', () => {
|
||||
const root = parseHierarchy(fixtureXml);
|
||||
// Point in the center of the screen — should find something clickable
|
||||
const el = findTopmostClickableAt(root, { x: 540, y: 1200 });
|
||||
// May or may not find something depending on layout, but shouldn't crash
|
||||
if (el) {
|
||||
expect(el.clickable).toBe(true);
|
||||
expect(el.bounds).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null for a point with no clickable elements', () => {
|
||||
const root = parseHierarchy(fixtureXml);
|
||||
// Point in the status bar area — unlikely to have clickable app elements
|
||||
const el = findTopmostClickableAt(root, { x: 540, y: 50 });
|
||||
// Could be null or a system element — just verify no crash
|
||||
expect(el === null || el.clickable === true).toBe(true);
|
||||
});
|
||||
|
||||
it('returns the LAST clickable in document order (highest z-order)', () => {
|
||||
const root = parseHierarchy(fixtureXml);
|
||||
// Find the "Add Item" FAB element to get its center
|
||||
const fab = findElementByText(root, 'Add Item');
|
||||
if (fab?.bounds) {
|
||||
const fabCenter = {
|
||||
x: Math.floor((fab.bounds.left + fab.bounds.right) / 2),
|
||||
y: Math.floor((fab.bounds.top + fab.bounds.bottom) / 2),
|
||||
};
|
||||
const topmost = findTopmostClickableAt(root, fabCenter);
|
||||
expect(topmost).not.toBeNull();
|
||||
// The topmost clickable at the FAB's center should be the FAB itself
|
||||
// or its clickable parent (bounds should overlap)
|
||||
expect(topmost!.bounds).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
121
.claude/mcp/android-device-server/src/parsers/xml.ts
Normal file
121
.claude/mcp/android-device-server/src/parsers/xml.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* UIAutomator XML hierarchy parser.
|
||||
*
|
||||
* Converts Android's single-line UIAutomator XML dump into a typed, traversable
|
||||
* node tree. Replaces the fragile grep/awk approach from the shell scripts.
|
||||
*/
|
||||
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { type Rect, type Point, parseBounds, containsPoint } from '../geometry/bounds.js';
|
||||
|
||||
export interface UiNode {
|
||||
text: string;
|
||||
contentDesc: string;
|
||||
resourceId: string;
|
||||
className: string;
|
||||
packageName: string;
|
||||
bounds: Rect | null;
|
||||
clickable: boolean;
|
||||
focused: boolean;
|
||||
enabled: boolean;
|
||||
selected: boolean;
|
||||
drawingOrder: number;
|
||||
children: UiNode[];
|
||||
}
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '',
|
||||
// Ensure 'node' is always an array even when there's only one child
|
||||
isArray: (name) => name === 'node',
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse a UIAutomator XML dump into a typed node tree.
|
||||
*/
|
||||
export function parseHierarchy(xml: string): UiNode {
|
||||
const parsed = parser.parse(xml);
|
||||
const hierarchy = parsed?.hierarchy;
|
||||
if (!hierarchy) {
|
||||
throw new Error('Invalid UIAutomator XML: missing <hierarchy> root');
|
||||
}
|
||||
|
||||
const rootNodes = hierarchy.node;
|
||||
if (!rootNodes || !Array.isArray(rootNodes) || rootNodes.length === 0) {
|
||||
throw new Error('Invalid UIAutomator XML: no nodes found');
|
||||
}
|
||||
|
||||
return convertNode(rootNodes[0]);
|
||||
}
|
||||
|
||||
function convertNode(raw: any): UiNode {
|
||||
const children: UiNode[] = [];
|
||||
if (raw.node) {
|
||||
const childNodes = Array.isArray(raw.node) ? raw.node : [raw.node];
|
||||
for (const child of childNodes) {
|
||||
children.push(convertNode(child));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: raw.text ?? '',
|
||||
contentDesc: raw['content-desc'] ?? '',
|
||||
resourceId: raw['resource-id'] ?? '',
|
||||
className: raw.class ?? '',
|
||||
packageName: raw.package ?? '',
|
||||
bounds: parseBounds(raw.bounds ?? ''),
|
||||
clickable: raw.clickable === 'true',
|
||||
focused: raw.focused === 'true',
|
||||
enabled: raw.enabled === 'true',
|
||||
selected: raw.selected === 'true',
|
||||
drawingOrder: parseInt(raw['drawing-order'] ?? '0', 10),
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first element matching search text in text or content-desc.
|
||||
* Searches depth-first.
|
||||
*/
|
||||
export function findElementByText(root: UiNode, searchText: string): UiNode | null {
|
||||
const lower = searchText.toLowerCase();
|
||||
|
||||
function search(node: UiNode): UiNode | null {
|
||||
if (
|
||||
node.text.toLowerCase().includes(lower) ||
|
||||
node.contentDesc.toLowerCase().includes(lower)
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
for (const child of node.children) {
|
||||
const found = search(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return search(root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the topmost clickable element at a given point.
|
||||
*
|
||||
* In UIAutomator's depth-first XML, the LAST clickable element whose bounds
|
||||
* contain the point is the one that receives the tap (highest z-order at that
|
||||
* point). This traverses the full tree and returns the last match.
|
||||
*/
|
||||
export function findTopmostClickableAt(root: UiNode, point: Point): UiNode | null {
|
||||
let result: UiNode | null = null;
|
||||
|
||||
function traverse(node: UiNode): void {
|
||||
if (node.clickable && node.bounds && containsPoint(node.bounds, point)) {
|
||||
result = node;
|
||||
}
|
||||
for (const child of node.children) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
|
||||
traverse(root);
|
||||
return result;
|
||||
}
|
||||
52
.claude/mcp/android-device-server/src/tools/capture.ts
Normal file
52
.claude/mcp/android-device-server/src/tools/capture.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Capture tool — dump UI hierarchy XML and/or screenshot from the connected device.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolDefinition } from '../utils/validation.js';
|
||||
import { validateInput } from '../utils/validation.js';
|
||||
import * as adb from '../adb/adb.js';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const CaptureSchema = z.object({
|
||||
xml: z.boolean().optional().default(true),
|
||||
screenshot: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
const capture: ToolDefinition = {
|
||||
name: 'capture',
|
||||
description:
|
||||
'Capture current Android device state. Dumps UI hierarchy XML and/or takes a screenshot. ' +
|
||||
'Files are saved to the current working directory as view.xml and screen.png.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
xml: { type: 'boolean', description: 'Capture UI hierarchy XML (default: true)' },
|
||||
screenshot: { type: 'boolean', description: 'Capture screenshot (default: true)' },
|
||||
},
|
||||
},
|
||||
async handler(input: unknown): Promise<string> {
|
||||
const { xml, screenshot } = validateInput(CaptureSchema, input);
|
||||
const results: string[] = [];
|
||||
|
||||
if (xml) {
|
||||
const xmlPath = resolve('view.xml');
|
||||
await adb.dumpHierarchy(xmlPath);
|
||||
results.push(`UI hierarchy saved to: ${xmlPath}`);
|
||||
}
|
||||
|
||||
if (screenshot) {
|
||||
const pngPath = resolve('screen.png');
|
||||
await adb.screenshot(pngPath);
|
||||
results.push(`Screenshot saved to: ${pngPath}`);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return 'Nothing captured. Set xml and/or screenshot to true.';
|
||||
}
|
||||
|
||||
return results.join('\n');
|
||||
},
|
||||
};
|
||||
|
||||
export default capture;
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Shared pipeline for finding a UI element with obstruction detection.
|
||||
* Used by both find_element and tap_element tools.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import * as adb from '../adb/adb.js';
|
||||
import { type Point, center } from '../geometry/bounds.js';
|
||||
import { detectObstruction, type ObstructionResult } from '../geometry/obstruction.js';
|
||||
import { parseHierarchy, findElementByText, type UiNode } from '../parsers/xml.js';
|
||||
import { parseDumpsysWindows } from '../parsers/dumpsys.js';
|
||||
|
||||
export interface FindElementResult {
|
||||
target: UiNode;
|
||||
tapPoint: Point;
|
||||
effectivePoint: Point;
|
||||
obstruction: ObstructionResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump hierarchy, find element by text, run obstruction detection.
|
||||
* Returns null with an error message if element not found.
|
||||
*/
|
||||
export async function findElementWithObstruction(
|
||||
text: string,
|
||||
): Promise<{ result: FindElementResult } | { error: string }> {
|
||||
const xmlPath = resolve('view.xml');
|
||||
await adb.dumpHierarchy(xmlPath);
|
||||
const xml = readFileSync(xmlPath, 'utf-8');
|
||||
const hierarchy = parseHierarchy(xml);
|
||||
|
||||
const target = findElementByText(hierarchy, text);
|
||||
if (!target) {
|
||||
return { error: `Element not found: "${text}"\n\nNo element with matching text or content-desc was found in the UI hierarchy.` };
|
||||
}
|
||||
|
||||
if (!target.bounds) {
|
||||
return { error: `Element found but has no bounds: "${text}"` };
|
||||
}
|
||||
|
||||
const tapPoint = center(target.bounds);
|
||||
|
||||
let dumpsysOutput: string;
|
||||
try {
|
||||
dumpsysOutput = await adb.dumpsysWindows();
|
||||
} catch {
|
||||
dumpsysOutput = '';
|
||||
}
|
||||
const windows = parseDumpsysWindows(dumpsysOutput);
|
||||
|
||||
const obstruction = detectObstruction({
|
||||
hierarchy,
|
||||
windows,
|
||||
targetElement: target,
|
||||
tapPoint,
|
||||
searchText: text,
|
||||
});
|
||||
|
||||
const effectivePoint = obstruction.obstructed && obstruction.adjustedPoint
|
||||
? obstruction.adjustedPoint
|
||||
: tapPoint;
|
||||
|
||||
return { result: { target, tapPoint, effectivePoint, obstruction } };
|
||||
}
|
||||
77
.claude/mcp/android-device-server/src/tools/find-element.ts
Normal file
77
.claude/mcp/android-device-server/src/tools/find-element.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Find element tool — locate a UI element by text/content-desc with obstruction detection.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolDefinition } from '../utils/validation.js';
|
||||
import { validateInput } from '../utils/validation.js';
|
||||
import { findElementWithObstruction } from './find-element-pipeline.js';
|
||||
|
||||
const FindElementSchema = z.object({
|
||||
text: z.string().min(1),
|
||||
});
|
||||
|
||||
const findElement: ToolDefinition = {
|
||||
name: 'find_element',
|
||||
description:
|
||||
'Find a UI element by text or content-desc and return tap coordinates. ' +
|
||||
'Includes two-layer obstruction detection: system overlays (TalkBack, PiP) via dumpsys, ' +
|
||||
'and in-app elements (FABs, dialogs) via the UI hierarchy. When obstructed, returns ' +
|
||||
'adjusted coordinates targeting the largest visible region of the element.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string', description: 'Text or content-desc to search for' },
|
||||
},
|
||||
required: ['text'],
|
||||
},
|
||||
async handler(input: unknown): Promise<string> {
|
||||
const { text } = validateInput(FindElementSchema, input);
|
||||
|
||||
const outcome = await findElementWithObstruction(text);
|
||||
if ('error' in outcome) return outcome.error;
|
||||
|
||||
const { target, tapPoint, effectivePoint, obstruction } = outcome.result;
|
||||
const lines: string[] = [];
|
||||
|
||||
if (!obstruction.obstructed) {
|
||||
lines.push(`Element found: "${target.text || target.contentDesc}"`);
|
||||
lines.push(`Coordinates: (${effectivePoint.x}, ${effectivePoint.y})`);
|
||||
lines.push('Status: CLEAR');
|
||||
} else {
|
||||
lines.push(`Element found: "${target.text || target.contentDesc}"`);
|
||||
lines.push(`Status: OBSTRUCTED by ${obstruction.obstructor}`);
|
||||
if (obstruction.fullyObscured) {
|
||||
lines.push(`Coordinates: (${effectivePoint.x}, ${effectivePoint.y}) — FULLY OBSCURED, original center used`);
|
||||
} else {
|
||||
lines.push(`Adjusted coordinates: (${effectivePoint.x}, ${effectivePoint.y}) — center of largest visible strip`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
found: true,
|
||||
text: target.text,
|
||||
contentDesc: target.contentDesc,
|
||||
resourceId: target.resourceId,
|
||||
bounds: target.bounds,
|
||||
center: tapPoint,
|
||||
effectivePoint,
|
||||
obstructed: obstruction.obstructed,
|
||||
...(obstruction.obstructed ? {
|
||||
obstructor: obstruction.obstructor,
|
||||
obstructorBounds: obstruction.obstructorBounds,
|
||||
fullyObscured: obstruction.fullyObscured,
|
||||
visibleRegion: obstruction.visibleRegion?.rect ?? null,
|
||||
} : {}),
|
||||
};
|
||||
|
||||
lines.push('');
|
||||
lines.push('```json');
|
||||
lines.push(JSON.stringify(result, null, 2));
|
||||
lines.push('```');
|
||||
|
||||
return lines.join('\n');
|
||||
},
|
||||
};
|
||||
|
||||
export default findElement;
|
||||
70
.claude/mcp/android-device-server/src/tools/input-text.ts
Normal file
70
.claude/mcp/android-device-server/src/tools/input-text.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Input text tool — type text into the focused field, with optional clearing.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolDefinition } from '../utils/validation.js';
|
||||
import { validateInput } from '../utils/validation.js';
|
||||
import * as adb from '../adb/adb.js';
|
||||
|
||||
const KEYCODE_MOVE_END = 123;
|
||||
const KEYCODE_DEL = 67;
|
||||
|
||||
const InputTextSchema = z.object({
|
||||
text: z.string().min(1),
|
||||
clear: z.boolean().default(false),
|
||||
});
|
||||
|
||||
/**
|
||||
* Clear the currently focused text field by moving to the end and
|
||||
* sending enough delete key events to remove all characters.
|
||||
* Uses a generous count to ensure complete clearing.
|
||||
*/
|
||||
async function clearField(): Promise<void> {
|
||||
await adb.keyevent(KEYCODE_MOVE_END);
|
||||
// Send 50 deletes — more than enough for any reasonable field length.
|
||||
// ADB processes them almost instantly and extras on an empty field are no-ops.
|
||||
const deletes = Array(50).fill(String(KEYCODE_DEL)).join(' ');
|
||||
await adb.shell(`input keyevent ${deletes}`);
|
||||
}
|
||||
|
||||
const inputText: ToolDefinition = {
|
||||
name: 'input_text',
|
||||
description:
|
||||
'Type text into the currently focused input field. Optionally clear existing content first. ' +
|
||||
'The field must already be focused (tap it first if needed).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string', description: 'Text to type into the focused field' },
|
||||
clear: {
|
||||
type: 'boolean',
|
||||
description: 'Clear existing field content before typing (default: false)',
|
||||
},
|
||||
},
|
||||
required: ['text'],
|
||||
},
|
||||
async handler(input: unknown): Promise<string> {
|
||||
const { text, clear } = validateInput(InputTextSchema, input);
|
||||
|
||||
if (clear) {
|
||||
await clearField();
|
||||
}
|
||||
|
||||
// Escape characters that the Android shell interprets inside double quotes:
|
||||
// " $ ` \ are all special in sh double-quoted strings.
|
||||
const escaped = text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\$/g, '\\$')
|
||||
.replace(/`/g, '\\`');
|
||||
await adb.shell(`input text "${escaped}"`);
|
||||
|
||||
const lines: string[] = [];
|
||||
if (clear) lines.push('Cleared existing content');
|
||||
lines.push(`Typed: "${text}"`);
|
||||
return lines.join('\n');
|
||||
},
|
||||
};
|
||||
|
||||
export default inputText;
|
||||
62
.claude/mcp/android-device-server/src/tools/navigate.ts
Normal file
62
.claude/mcp/android-device-server/src/tools/navigate.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Navigate tool — perform common navigation actions on the device.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { resolve } from 'node:path';
|
||||
import type { ToolDefinition } from '../utils/validation.js';
|
||||
import { validateInput } from '../utils/validation.js';
|
||||
import * as adb from '../adb/adb.js';
|
||||
|
||||
const NavigateSchema = z.object({
|
||||
action: z.enum(['home', 'back', 'app-drawer']),
|
||||
waitSeconds: z.number().min(0).default(1),
|
||||
});
|
||||
|
||||
const navigate: ToolDefinition = {
|
||||
name: 'navigate',
|
||||
description:
|
||||
'Perform a navigation action on the Android device: go home, press back, or open the app drawer. ' +
|
||||
'Captures a screenshot after the action completes.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['home', 'back', 'app-drawer'],
|
||||
description: 'Navigation action to perform',
|
||||
},
|
||||
waitSeconds: { type: 'number', description: 'Seconds to wait after action before capture (default: 1)' },
|
||||
},
|
||||
required: ['action'],
|
||||
},
|
||||
async handler(input: unknown): Promise<string> {
|
||||
const { action, waitSeconds } = validateInput(NavigateSchema, input);
|
||||
|
||||
switch (action) {
|
||||
case 'home':
|
||||
await adb.keyevent(3);
|
||||
break;
|
||||
case 'back':
|
||||
await adb.keyevent(4);
|
||||
break;
|
||||
case 'app-drawer': {
|
||||
const screen = await adb.getScreenSize();
|
||||
const cx = Math.floor(screen.width / 2);
|
||||
const fromY = Math.floor(screen.height * 0.93);
|
||||
const toY = Math.floor(screen.height * 0.17);
|
||||
await adb.swipe(cx, fromY, cx, toY, 1000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await adb.sleep(waitSeconds ?? 1);
|
||||
|
||||
const pngPath = resolve('screen.png');
|
||||
await adb.screenshot(pngPath);
|
||||
|
||||
return `Navigated: ${action}\nScreenshot saved to: ${pngPath}`;
|
||||
},
|
||||
};
|
||||
|
||||
export default navigate;
|
||||
44
.claude/mcp/android-device-server/src/tools/tap-at.ts
Normal file
44
.claude/mcp/android-device-server/src/tools/tap-at.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Tap at coordinates tool — tap a specific screen location, wait, and capture screenshot.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { resolve } from 'node:path';
|
||||
import type { ToolDefinition } from '../utils/validation.js';
|
||||
import { validateInput } from '../utils/validation.js';
|
||||
import * as adb from '../adb/adb.js';
|
||||
|
||||
const TapAtSchema = z.object({
|
||||
x: z.number().int().nonnegative(),
|
||||
y: z.number().int().nonnegative(),
|
||||
waitSeconds: z.number().min(0).default(2),
|
||||
});
|
||||
|
||||
const tapAt: ToolDefinition = {
|
||||
name: 'tap_at',
|
||||
description:
|
||||
'Tap at specific screen coordinates, wait for the UI to settle, and capture a screenshot. ' +
|
||||
'Returns the path to the captured screenshot.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
x: { type: 'number', description: 'X coordinate to tap' },
|
||||
y: { type: 'number', description: 'Y coordinate to tap' },
|
||||
waitSeconds: { type: 'number', description: 'Seconds to wait after tap before capture (default: 2)' },
|
||||
},
|
||||
required: ['x', 'y'],
|
||||
},
|
||||
async handler(input: unknown): Promise<string> {
|
||||
const { x, y, waitSeconds } = validateInput(TapAtSchema, input);
|
||||
|
||||
await adb.tap(x, y);
|
||||
await adb.sleep(waitSeconds ?? 2);
|
||||
|
||||
const pngPath = resolve('screen.png');
|
||||
await adb.screenshot(pngPath);
|
||||
|
||||
return `Tapped at (${x}, ${y}), waited ${waitSeconds}s\nScreenshot saved to: ${pngPath}`;
|
||||
},
|
||||
};
|
||||
|
||||
export default tapAt;
|
||||
65
.claude/mcp/android-device-server/src/tools/tap-element.ts
Normal file
65
.claude/mcp/android-device-server/src/tools/tap-element.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Tap element tool — find an element by text, tap it, and capture screenshot.
|
||||
* Uses the shared find-element pipeline for obstruction detection.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { resolve } from 'node:path';
|
||||
import type { ToolDefinition } from '../utils/validation.js';
|
||||
import { validateInput } from '../utils/validation.js';
|
||||
import * as adb from '../adb/adb.js';
|
||||
import { findElementWithObstruction } from './find-element-pipeline.js';
|
||||
|
||||
const TapElementSchema = z.object({
|
||||
text: z.string().min(1),
|
||||
waitSeconds: z.number().min(0).default(2),
|
||||
});
|
||||
|
||||
const tapElement: ToolDefinition = {
|
||||
name: 'tap_element',
|
||||
description:
|
||||
'Find a UI element by text or content-desc, tap it, and capture a screenshot. ' +
|
||||
'Automatically detects obstructions and adjusts tap coordinates to the largest visible region. ' +
|
||||
'Returns element info, tap coordinates, obstruction status, and screenshot path.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string', description: 'Text or content-desc of the element to tap' },
|
||||
waitSeconds: { type: 'number', description: 'Seconds to wait after tap before capture (default: 2)' },
|
||||
},
|
||||
required: ['text'],
|
||||
},
|
||||
async handler(input: unknown): Promise<string> {
|
||||
const { text, waitSeconds } = validateInput(TapElementSchema, input);
|
||||
|
||||
const outcome = await findElementWithObstruction(text);
|
||||
if ('error' in outcome) return `Error: ${outcome.error}`;
|
||||
|
||||
const { target, effectivePoint, obstruction } = outcome.result;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`Element found: "${target.text || target.contentDesc}"`);
|
||||
|
||||
if (obstruction.obstructed) {
|
||||
lines.push(`WARNING: Obstructed by ${obstruction.obstructor}`);
|
||||
if (obstruction.fullyObscured) {
|
||||
lines.push('FULLY OBSCURED — tapping original center as best effort');
|
||||
} else {
|
||||
lines.push(`Using adjusted coordinates: (${effectivePoint.x}, ${effectivePoint.y})`);
|
||||
}
|
||||
}
|
||||
|
||||
await adb.tap(effectivePoint.x, effectivePoint.y);
|
||||
await adb.sleep(waitSeconds ?? 2);
|
||||
|
||||
const pngPath = resolve('screen.png');
|
||||
await adb.screenshot(pngPath);
|
||||
|
||||
lines.push(`Tapped at (${effectivePoint.x}, ${effectivePoint.y})`);
|
||||
lines.push(`Screenshot saved to: ${pngPath}`);
|
||||
|
||||
return lines.join('\n');
|
||||
},
|
||||
};
|
||||
|
||||
export default tapElement;
|
||||
32
.claude/mcp/android-device-server/src/utils/validation.ts
Normal file
32
.claude/mcp/android-device-server/src/utils/validation.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Input validation and tool definition types.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Shape of a tool module's default export.
|
||||
* Each tool file exports a ToolDefinition with metadata and a handler function.
|
||||
*/
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: any;
|
||||
handler: (input: any) => Promise<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate input against a Zod schema.
|
||||
* @throws {Error} with formatted validation messages on failure
|
||||
*/
|
||||
export function validateInput<T>(schema: z.ZodSchema<T>, input: unknown): T {
|
||||
try {
|
||||
return schema.parse(input);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`);
|
||||
throw new Error(`Validation failed: ${messages.join(', ')}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
19
.claude/mcp/android-device-server/tsconfig.json
Normal file
19
.claude/mcp/android-device-server/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "build", "src/**/*.spec.ts"]
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"attribution": {
|
||||
"commit": "",
|
||||
"pr": ""
|
||||
},
|
||||
"extraKnownMarketplaces": {
|
||||
"bitwarden-marketplace": {
|
||||
"source": {
|
||||
|
||||
163
.claude/skills/build-test-verify/SKILL.md
Normal file
163
.claude/skills/build-test-verify/SKILL.md
Normal file
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: build-test-verify
|
||||
version: 0.1.0
|
||||
description: Build, test, lint, and deploy commands for the Bitwarden Android project. Use when running tests, building APKs/AABs, running lint/detekt, deploying, using fastlane, or discovering codebase structure. Triggered by "run tests", "build", "gradle", "lint", "detekt", "deploy", "fastlane", "assemble", "verify", "coverage".
|
||||
---
|
||||
|
||||
# Build, Test & Verify
|
||||
|
||||
## Environment Setup
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `GITHUB_TOKEN` | Yes (CI) | GitHub Packages auth for SDK (`read:packages` scope) |
|
||||
| Build flavors | - | `standard` (Play Store), `fdroid` (no Google services) |
|
||||
| Build types | - | `debug`, `beta`, `release` |
|
||||
|
||||
If builds fail resolving the Bitwarden SDK, verify `GITHUB_TOKEN` in `user.properties` or environment and check connectivity to `maven.pkg.github.com`.
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Debug builds
|
||||
./gradlew app:assembleDebug
|
||||
./gradlew authenticator:assembleDebug
|
||||
|
||||
# Release builds (requires signing keys)
|
||||
./gradlew app:assembleStandardRelease
|
||||
./gradlew app:bundleStandardRelease
|
||||
|
||||
# F-Droid builds
|
||||
./gradlew app:assembleFdroidRelease
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
**IMPORTANT**: The app module uses the `standard` flavor. Always use `testStandardDebugUnitTest`, NOT `testDebugUnitTest`.
|
||||
|
||||
**IMPORTANT**: Always pipe test output through a filter that captures failures on the first run. Gradle suppresses detailed failure output by default, so use `2>&1 | grep -E "FAILED|BUILD|expected:|actual:|AssertionError|failures" | head -30` to see pass/fail results and assertion details without needing a second run.
|
||||
|
||||
```bash
|
||||
# App module tests (correct flavor!)
|
||||
./gradlew app:testStandardDebugUnitTest 2>&1 | grep -E "FAILED|BUILD|expected:|actual:|AssertionError|failures" | head -30
|
||||
|
||||
# Run specific test classes
|
||||
./gradlew app:testStandardDebugUnitTest --tests "com.x8bit.bitwarden.SomeTest" 2>&1 | grep -E "FAILED|BUILD|expected:|actual:|AssertionError|failures" | head -30
|
||||
|
||||
# Run all unit tests across all modules
|
||||
./gradlew test
|
||||
|
||||
# Individual shared modules (no flavor needed)
|
||||
./gradlew :core:test
|
||||
./gradlew :data:test
|
||||
./gradlew :network:test
|
||||
./gradlew :ui:test
|
||||
|
||||
# Authenticator module
|
||||
./gradlew authenticator:testStandardDebugUnitTest
|
||||
```
|
||||
|
||||
### Reading Test Reports
|
||||
|
||||
If you need full failure details beyond what grep captures, check the HTML test report:
|
||||
|
||||
```bash
|
||||
# After a test run, open the report at:
|
||||
# app/build/reports/tests/testStandardDebugUnitTest/index.html
|
||||
# Or read individual failure XML:
|
||||
find app/build/test-results -name "*.xml" -exec grep -l "failure" {} \;
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
```
|
||||
app/src/test/ # App unit tests
|
||||
app/src/testFixtures/ # App test utilities
|
||||
core/src/testFixtures/ # Core test utilities (FakeDispatcherManager)
|
||||
data/src/testFixtures/ # Data test utilities (FakeSharedPreferences)
|
||||
network/src/testFixtures/ # Network test utilities (BaseServiceTest)
|
||||
ui/src/testFixtures/ # UI test utilities (BaseViewModelTest, BaseComposeTest)
|
||||
```
|
||||
|
||||
### Test Quick Reference
|
||||
|
||||
- **Dispatcher Control**: `FakeDispatcherManager` from `:core:testFixtures`
|
||||
- **MockK**: `mockk<T> { every { } returns }`, `coEvery { }` for suspend
|
||||
- **Flow Testing**: Turbine with `stateEventFlow()` helper from `BaseViewModelTest`
|
||||
- **Time Control**: Inject `Clock` for deterministic time testing
|
||||
|
||||
---
|
||||
|
||||
## Lint & Static Analysis
|
||||
|
||||
**IMPORTANT**: Prefer running detekt on modified files only — a full project scan is slow and unnecessary during development. The project supports a `-Pprecommit=true` flag that limits detekt to staged files.
|
||||
|
||||
**IMPORTANT**: Always pipe detekt output through a filter to capture errors on the first run. Detekt prints violation details to stderr/stdout but Gradle can obscure them. Use the grep pattern below to see violations immediately.
|
||||
|
||||
```bash
|
||||
# Detekt on staged files only (preferred during development)
|
||||
git add -u && ./gradlew -Pprecommit=true detekt 2>&1 | grep -E "FAILED|BUILD|Line |Rule |Signature|detekt" | head -40
|
||||
|
||||
# Detekt on all files (full scan, use sparingly)
|
||||
./gradlew detekt 2>&1 | grep -E "FAILED|BUILD|Line |Rule |Signature|detekt" | head -40
|
||||
|
||||
# Android Lint
|
||||
./gradlew lint
|
||||
|
||||
# Full validation suite (detekt + lint + tests + coverage)
|
||||
./fastlane check
|
||||
```
|
||||
|
||||
### How `-Pprecommit=true` Works
|
||||
|
||||
The root `build.gradle.kts` configures detekt tasks to use `git diff --name-only --cached` when this property is set, limiting analysis to staged files only. This is the same mechanism used by the project's pre-commit hook. Stage your changes with `git add` before running.
|
||||
|
||||
---
|
||||
|
||||
## Codebase Discovery
|
||||
|
||||
```bash
|
||||
# Find existing Bitwarden UI components
|
||||
find ui/src/main/kotlin/com/bitwarden/ui/platform/components/ -name "Bitwarden*.kt" | sort
|
||||
|
||||
# Find all ViewModels
|
||||
grep -rl "BaseViewModel<" app/src/main/kotlin/ --include="*.kt"
|
||||
|
||||
# Find all Navigation files with @Serializable routes
|
||||
find app/src/main/kotlin/ -name "*Navigation.kt" | sort
|
||||
|
||||
# Find all Hilt modules
|
||||
find app/src/main/kotlin/ -name "*Module.kt" -path "*/di/*" | sort
|
||||
|
||||
# Find all repository interfaces
|
||||
find app/src/main/kotlin/ -name "*Repository.kt" -not -name "*Impl.kt" -path "*/repository/*" | sort
|
||||
|
||||
# Find encrypted disk source examples
|
||||
grep -rl "EncryptedPreferences" app/src/main/kotlin/ --include="*.kt"
|
||||
|
||||
# Find Clock injection usage
|
||||
grep -rl "private val clock: Clock" app/src/main/kotlin/ --include="*.kt"
|
||||
|
||||
# Search existing strings before adding new ones
|
||||
grep -n "search_term" ui/src/main/res/values/strings.xml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment & Versioning
|
||||
|
||||
**Version location**: `gradle/libs.versions.toml`
|
||||
```toml
|
||||
appVersionCode = "1"
|
||||
appVersionName = "2025.11.1"
|
||||
```
|
||||
Pattern: `YEAR.MONTH.PATCH`
|
||||
|
||||
**Publishing channels**:
|
||||
- **Play Store**: GitHub Actions workflow with signed AAB
|
||||
- **F-Droid**: Dedicated workflow with F-Droid signing keys
|
||||
- **Firebase App Distribution**: Beta testing
|
||||
@@ -1,23 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the `implementing-android-code` skill will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.0] - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- Bitwarden Android implementation patterns covering:
|
||||
- ViewModel State-Action-Event (SAE) pattern with `BaseViewModel`
|
||||
- Type-safe navigation with `@Serializable` routes and `composableWithSlideTransitions`
|
||||
- Screen/Compose implementation with `EventsEffect` and stateless composables
|
||||
- Data layer patterns: repositories, data sources, `DataState<T>`, error handling
|
||||
- UI component library usage and string resource conventions
|
||||
- Security patterns: zero-knowledge architecture, encrypted storage, SDK isolation
|
||||
- Testing quick reference for ViewModels, repositories, compose, and data sources
|
||||
- Clock/time injection patterns for deterministic operations
|
||||
- Anti-patterns and common gotchas
|
||||
- Copy-pasteable code templates (templates.md) for all layer types
|
||||
- README.md, CHANGELOG.md, CONTRIBUTING.md for marketplace preparation
|
||||
@@ -1,44 +0,0 @@
|
||||
# Contributing to implementing-android-code
|
||||
|
||||
## Development
|
||||
|
||||
This skill provides Bitwarden Android implementation patterns, gotchas, and code templates for Claude Code. It consists of two content files:
|
||||
|
||||
- **SKILL.md** - Quick reference for patterns, anti-patterns, and gotchas
|
||||
- **templates.md** - Copy-pasteable code templates for all layer types
|
||||
|
||||
## Making Changes
|
||||
|
||||
This skill follows [Semantic Versioning](https://semver.org/):
|
||||
|
||||
- **Patch** (0.1.x): Typo fixes, minor clarifications, template corrections
|
||||
- **Minor** (0.x.0): New patterns, new templates, expanded coverage areas
|
||||
- **Major** (x.0.0): Structural changes, pattern overhauls, breaking reorganizations
|
||||
|
||||
When making changes:
|
||||
|
||||
1. Update the relevant content in `SKILL.md` and/or `templates.md`
|
||||
2. Bump the `version` field in the SKILL.md YAML frontmatter
|
||||
3. Add an entry to `CHANGELOG.md` under the appropriate version heading
|
||||
|
||||
## Testing Locally
|
||||
|
||||
To test the skill locally with Claude Code:
|
||||
|
||||
```bash
|
||||
# From the repository root, invoke Claude Code and trigger the skill
|
||||
claude "How do I implement a ViewModel?"
|
||||
```
|
||||
|
||||
Verify that:
|
||||
- The skill triggers on expected phrases
|
||||
- Templates render correctly
|
||||
- Pattern references are accurate against the current codebase
|
||||
|
||||
## Pull Requests
|
||||
|
||||
All pull requests that modify skill content must include:
|
||||
|
||||
1. A version bump in the SKILL.md frontmatter
|
||||
2. A corresponding CHANGELOG.md entry
|
||||
3. Verification that templates compile against the current codebase patterns
|
||||
@@ -1,77 +0,0 @@
|
||||
# implementing-android-code
|
||||
|
||||
Bitwarden Android implementation patterns skill for Claude Code. Provides critical patterns, gotchas, anti-patterns, and copy-pasteable templates unique to the Bitwarden Android codebase.
|
||||
|
||||
## Features
|
||||
|
||||
- **ViewModel SAE Pattern** - State-Action-Event with `BaseViewModel`, `SavedStateHandle` persistence, process death recovery
|
||||
- **Type-Safe Navigation** - `@Serializable` routes, `composableWithSlideTransitions`, `NavGraphBuilder`/`NavController` extensions
|
||||
- **Screen/Compose** - Stateless composables, `EventsEffect`, `remember(viewModel)` lambda patterns
|
||||
- **Data Layer** - Repository pattern, `DataState<T>` streaming, `Result` sealed classes, Flow collection via Internal actions
|
||||
- **UI Components** - Bitwarden component library usage, theming, string resources
|
||||
- **Security Patterns** - Zero-knowledge architecture, encrypted storage, SDK isolation
|
||||
- **Testing Patterns** - ViewModel, repository, compose, and data source test structure
|
||||
- **Clock/Time Handling** - `Clock` injection for deterministic time operations
|
||||
|
||||
## Skill Structure
|
||||
|
||||
```
|
||||
implementing-android-code/
|
||||
├── SKILL.md # Quick reference for patterns, gotchas, and anti-patterns
|
||||
├── templates.md # Copy-pasteable code templates for all layer types
|
||||
├── README.md # This file
|
||||
├── CHANGELOG.md # Version history
|
||||
└── CONTRIBUTING.md # Contribution guidelines
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Claude triggers this skill automatically when conversations involve implementing features, screens, ViewModels, data sources, or navigation in the Bitwarden Android app.
|
||||
|
||||
**Example trigger phrases:**
|
||||
- "How do I implement a ViewModel?"
|
||||
- "Create a new screen"
|
||||
- "Add navigation"
|
||||
- "Write a repository"
|
||||
- "BaseViewModel pattern"
|
||||
- "State-Action-Event"
|
||||
- "type-safe navigation"
|
||||
- "Clock injection"
|
||||
|
||||
## Content Summary
|
||||
|
||||
| Section | Description |
|
||||
|---------|-------------|
|
||||
| A. ViewModel Implementation | SAE pattern, `handleAction`, `sendAction`, `SavedStateHandle` |
|
||||
| B. Type-Safe Navigation | `@Serializable` routes, transitions, `NavGraphBuilder` extensions |
|
||||
| C. Screen Implementation | Stateless composables, `EventsEffect`, action lambdas |
|
||||
| D. Data Layer | Repositories, data sources, `DataState`, error handling |
|
||||
| E. UI Components | Bitwarden component library, theming, string resources |
|
||||
| F. Security Patterns | Zero-knowledge, encrypted storage, SDK isolation |
|
||||
| G. Testing Quick Reference | ViewModel, repository, compose, data source tests |
|
||||
| H. Clock/Time Patterns | `Clock` injection, deterministic time testing |
|
||||
|
||||
## References
|
||||
|
||||
- [`docs/ARCHITECTURE.md`](../../../docs/ARCHITECTURE.md) - Comprehensive architecture patterns and examples
|
||||
- [`docs/STYLE_AND_BEST_PRACTICES.md`](../../../docs/STYLE_AND_BEST_PRACTICES.md) - Code style, formatting, Compose conventions
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines, versioning, and pull request requirements.
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
||||
|
||||
## License
|
||||
|
||||
This skill is part of the [Bitwarden Android](https://github.com/bitwarden/android) project and follows its licensing terms.
|
||||
|
||||
## Maintainers
|
||||
|
||||
- Bitwarden Android team
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions, open an issue in the [bitwarden/android](https://github.com/bitwarden/android) repository.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: implementing-android-code
|
||||
version: 0.1.0
|
||||
version: 0.1.3
|
||||
description: This skill should be used when implementing Android code in Bitwarden. Covers critical patterns, gotchas, and anti-patterns unique to this codebase. Triggered by "How do I implement a ViewModel?", "Create a new screen", "Add navigation", "Write a repository", "BaseViewModel pattern", "State-Action-Event", "type-safe navigation", "@Serializable route", "SavedStateHandle persistence", "process death recovery", "handleAction", "sendAction", "Hilt module", "Repository pattern", "implementing a screen", "adding a data source", "handling navigation", "encrypted storage", "security patterns", "Clock injection", "DataState", or any questions about implementing features, screens, ViewModels, data sources, or navigation in the Bitwarden Android app.
|
||||
---
|
||||
|
||||
@@ -33,7 +33,7 @@ class ExampleViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val repository: ExampleRepository,
|
||||
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: ExampleState()
|
||||
initialState = savedStateHandle[KEY_STATE] ?: ExampleState(),
|
||||
) {
|
||||
init {
|
||||
stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope)
|
||||
@@ -97,7 +97,11 @@ fun SavedStateHandle.toExampleArgs(): ExampleArgs {
|
||||
return ExampleArgs(userId = route.userId, isEditMode = route.isEditMode)
|
||||
}
|
||||
|
||||
fun NavController.navigateToExample(userId: String, isEditMode: Boolean = false, navOptions: NavOptions? = null) {
|
||||
fun NavController.navigateToExample(
|
||||
userId: String,
|
||||
isEditMode: Boolean = false,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(route = ExampleRoute(userId, isEditMode), navOptions = navOptions)
|
||||
}
|
||||
|
||||
@@ -144,9 +148,7 @@ fun ExampleScreen(
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(R.string.title),
|
||||
navigationIcon = rememberVectorPainter(BitwardenDrawable.ic_back),
|
||||
onNavigationIconClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
},
|
||||
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -161,7 +163,6 @@ fun ExampleScreen(
|
||||
- ✅ Use `hiltViewModel()` for dependency injection
|
||||
- ✅ Use `collectAsStateWithLifecycle()` for state (not `collectAsState()`)
|
||||
- ✅ Use `EventsEffect(viewModel)` for one-shot events
|
||||
- ✅ Use `remember(viewModel) { }` for stable callbacks to prevent recomposition
|
||||
- ✅ Use `Bitwarden*` prefixed components from `:ui` module
|
||||
|
||||
**State Hoisting Rules:**
|
||||
@@ -255,7 +256,7 @@ The `:ui` module provides reusable `Bitwarden*` prefixed components. Search befo
|
||||
- `BitwardenLoadingDialog` - Loading indicators
|
||||
|
||||
**Component Discovery:**
|
||||
Search `ui/src/main/kotlin/com/bitwarden/ui/platform/components/` for existing `Bitwarden*` components. See **Codebase Discovery** in `CLAUDE.md` for search commands.
|
||||
Search `ui/src/main/kotlin/com/bitwarden/ui/platform/components/` for existing `Bitwarden*` components. For build, test, and codebase discovery commands, use the **`build-test-verify`** skill.
|
||||
|
||||
**When to Create New Reusable Components:**
|
||||
- Component used in 3+ places
|
||||
@@ -273,7 +274,7 @@ Search `ui/src/main/kotlin/com/bitwarden/ui/platform/components/` for existing `
|
||||
|
||||
New strings belong in the `:ui` module: `ui/src/main/res/values/strings.xml`
|
||||
|
||||
- Use typographic apostrophes and quotes to avoid escape characters: `you'll` not `you\'ll`, `“word”` not `\"word\"`
|
||||
- Use typographic apostrophes and quotes to avoid escape characters: `you’ll` not `you\'ll`, `“word”` not `\"word\"`
|
||||
- Reference strings via generated `BitwardenString` resource IDs
|
||||
- Do not add strings to other modules unless explicitly instructed
|
||||
|
||||
@@ -288,8 +289,8 @@ New strings belong in the `:ui` module: `ui/src/main/res/values/strings.xml`
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
class ExampleDiskSourceImpl(
|
||||
encryptedSharedPreferences: SharedPreferences,
|
||||
sharedPreferences: SharedPreferences,
|
||||
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
) : BaseEncryptedDiskSource(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
@@ -421,7 +422,7 @@ fun State.getTimestamp(clock: Clock): Instant =
|
||||
// Test with fixed clock
|
||||
val FIXED_CLOCK = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
```
|
||||
|
||||
@@ -436,6 +437,42 @@ val FIXED_CLOCK = Clock.fixed(
|
||||
|
||||
---
|
||||
|
||||
### I. Kotlin Style Rules
|
||||
|
||||
Project-specific style conventions enforced in code review. These supplement (not replace) `docs/STYLE_AND_BEST_PRACTICES.md`.
|
||||
|
||||
**`when` branches with wrapped right-hand side require curly braces.**
|
||||
|
||||
When a `when` branch's expression is too long to fit on the same line as the arrow and is wrapped to the next line, wrap the body in `{ }`. A bare `->` followed by an indented expression on its own line is rejected in review.
|
||||
|
||||
❌ **Wrong** — wrapped body without braces:
|
||||
```kotlin
|
||||
when (type) {
|
||||
VaultItemCipherType.LOGIN -> VaultAddEditState.ViewState.Content.ItemType.Login()
|
||||
VaultItemCipherType.BANK_ACCOUNT ->
|
||||
VaultAddEditState.ViewState.Content.ItemType.BankAccount()
|
||||
VaultItemCipherType.DRIVERS_LICENSE ->
|
||||
VaultAddEditState.ViewState.Content.ItemType.DriversLicense()
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Right** — wrapped body with braces:
|
||||
```kotlin
|
||||
when (type) {
|
||||
VaultItemCipherType.LOGIN -> VaultAddEditState.ViewState.Content.ItemType.Login()
|
||||
VaultItemCipherType.BANK_ACCOUNT -> {
|
||||
VaultAddEditState.ViewState.Content.ItemType.BankAccount()
|
||||
}
|
||||
VaultItemCipherType.DRIVERS_LICENSE -> {
|
||||
VaultAddEditState.ViewState.Content.ItemType.DriversLicense()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Single-line branches (body fits on the same line as `->`) do **not** need braces.
|
||||
|
||||
---
|
||||
|
||||
## Bitwarden-Specific Anti-Patterns
|
||||
|
||||
**General anti-patterns are documented in CLAUDE.md.** This section covers violations specific to Bitwarden's State-Action-Event, navigation, and data layer patterns:
|
||||
@@ -471,27 +508,9 @@ val FIXED_CLOCK = Clock.fixed(
|
||||
|
||||
## Quick Reference
|
||||
|
||||
For build, test, and codebase discovery commands, see the **Codebase Discovery**, **Testing**, and **Deployment** sections in `CLAUDE.md`.
|
||||
For build, test, and codebase discovery commands, use the **`build-test-verify`** skill.
|
||||
|
||||
**File Reference Format:**
|
||||
When pointing to specific code, use: `file_path:line_number`
|
||||
|
||||
Example: `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` (see `handleAction` method)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
This skill captures **Bitwarden-specific patterns** that distinguish this codebase:
|
||||
|
||||
1. **State-Action-Event ViewModel pattern** - Synchronous state updates via `handleAction()`
|
||||
2. **Type-safe navigation** - No strings, `@Serializable` routes
|
||||
3. **No-throw error handling** - `Result<T>` and sealed classes
|
||||
4. **Interface/Impl separation** - Testability and DI safety
|
||||
5. **SavedStateHandle persistence** - Process death recovery
|
||||
6. **Security patterns** - Encrypted storage, Keystore, input validation
|
||||
7. **Clock injection** - Deterministic time handling via injected `Clock`
|
||||
|
||||
For comprehensive details on architecture, module organization, and complete code style rules, always consult:
|
||||
- `docs/ARCHITECTURE.md`
|
||||
- `docs/STYLE_AND_BEST_PRACTICES.md`
|
||||
|
||||
@@ -342,9 +342,7 @@ fun ExampleScreen(
|
||||
// Dialogs
|
||||
ExampleDialogs(
|
||||
dialogState = state.dialogState,
|
||||
onDismissRequest = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.ErrorDialogDismiss) }
|
||||
},
|
||||
onDismissRequest = { viewModel.trySendAction(ExampleAction.ErrorDialogDismiss) },
|
||||
)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
@@ -357,20 +355,14 @@ fun ExampleScreen(
|
||||
title = stringResource(id = BitwardenString.example),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
onNavigationIconClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
},
|
||||
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
|
||||
)
|
||||
},
|
||||
) {
|
||||
ExampleScreenContent(
|
||||
state = state,
|
||||
onInputChanged = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.InputChanged(it)) }
|
||||
},
|
||||
onSubmitClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.SubmitClick) }
|
||||
},
|
||||
onInputChanged = { viewModel.trySendAction(ExampleAction.InputChanged(it)) },
|
||||
onSubmitClick = { viewModel.trySendAction(ExampleAction.SubmitClick) },
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
|
||||
134
.claude/skills/interacting-with-android-device/SKILL.md
Normal file
134
.claude/skills/interacting-with-android-device/SKILL.md
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
name: interacting-with-android-device
|
||||
description: Instructions for capturing UI state, comparing with mocks, and interacting with an Android device using MCP tools backed by ADB.
|
||||
allowed-tools: mcp__android-device__capture, mcp__android-device__find_element, mcp__android-device__tap_at, mcp__android-device__tap_element, mcp__android-device__navigate, mcp__android-device__input_text, Bash(adb:*), Bash(sleep:*), Bash(./gradlew install*:*), Read, Glob
|
||||
---
|
||||
|
||||
# Interacting with Android Device
|
||||
|
||||
## Quick Start: MCP Tools
|
||||
|
||||
The `android-device` MCP server provides 6 tools for device interaction. These replace the previous shell scripts with proper XML parsing, structured dumpsys parsing, and native obstruction detection.
|
||||
|
||||
**Available tools:**
|
||||
- `capture` — Capture UI hierarchy XML and/or screenshot. Params: `{ xml?: boolean, screenshot?: boolean }`. Default: both.
|
||||
- `find_element` — Find element by `text` or `content-desc`, return coordinates with **obstruction detection**. Params: `{ text: string }`. Returns JSON with coordinates, bounds, and obstruction status.
|
||||
- `tap_at` — Tap at specific coordinates, wait, capture screenshot. Params: `{ x, y, waitSeconds? }`.
|
||||
- `tap_element` — Find, tap, and capture in one call (recommended). Params: `{ text, waitSeconds? }`. Auto-adjusts coordinates when obstructed.
|
||||
- `navigate` — Navigation actions: home, back, app-drawer. Params: `{ action, waitSeconds? }`. Captures screenshot after action.
|
||||
- `input_text` — Type text into the focused field. Params: `{ text, clear? }`. Set `clear: true` to erase existing content first.
|
||||
|
||||
**Use these MCP tools instead of raw ADB commands** to save tokens, get structured results, and benefit from automatic obstruction detection.
|
||||
|
||||
## 1. Capturing Current State
|
||||
To understand what is currently on the device, use the `capture` tool:
|
||||
* It saves `view.xml` (UI hierarchy) and `screen.png` (screenshot) to the working directory
|
||||
* Read `view.xml` to find coordinates (`bounds`) and properties (like `text` or `resource-id`) of UI elements
|
||||
* Use `screen.png` for visual verification against design mocks
|
||||
|
||||
## 2. Interacting with the Device
|
||||
|
||||
### Using MCP Tools (Recommended)
|
||||
|
||||
* **Find and tap an element by text** — use `tap_element`:
|
||||
This finds the element, detects obstructions, taps (with adjusted coordinates if needed), and captures a screenshot — all in one call.
|
||||
|
||||
* **Tap at specific coordinates** — use `tap_at`:
|
||||
When you already have coordinates from `find_element` or manual inspection.
|
||||
|
||||
* **Navigate (home, back, app-drawer)** — use `navigate`:
|
||||
Performs the action and captures a screenshot.
|
||||
|
||||
* **Find element without tapping** — use `find_element`:
|
||||
Returns coordinates and full element info. Useful when you need to inspect before acting.
|
||||
|
||||
* **Type text into a field** — use `input_text`:
|
||||
Types text into the currently focused field. Set `clear: true` to erase existing content first. Tap the field before calling this if it isn't already focused.
|
||||
|
||||
### Raw ADB Commands (When MCP Tools Aren't Sufficient)
|
||||
* **Key Events**:
|
||||
* Back: `adb shell input keyevent 4`
|
||||
* Home: `adb shell input keyevent 3`
|
||||
* Enter: `adb shell input keyevent 66`
|
||||
* **Scrolling/Swiping**: Use `adb shell input swipe <x1> <y1> <x2> <y2> <duration_ms>` where:
|
||||
* `(x1, y1)` = starting point
|
||||
* `(x2, y2)` = ending point
|
||||
* `duration_ms` = duration in milliseconds (1000ms is typical; adjust for speed/distance)
|
||||
* **Note**: For expanding containers/drawers, use large distances (e.g., 2400->300 for a 2992px tall screen)
|
||||
|
||||
## 3. Obstruction Detection
|
||||
|
||||
The `find_element` and `tap_element` tools automatically detect when another element would intercept the tap. This catches:
|
||||
* **System overlays** (Layer 1): TalkBack floating menu, PiP windows, accessibility services — detected via `dumpsys window windows` touchable regions
|
||||
* **In-app elements** (Layer 2): FABs, dialogs, bottom sheets, snackbars — detected by finding the topmost clickable element at the tap point in the UI hierarchy
|
||||
|
||||
When obstruction is detected:
|
||||
* Coordinates are **auto-adjusted** to the center of the largest unobstructed strip (top/bottom/left/right of the obstructor)
|
||||
* The response includes the obstructor identity, bounds, and visible region info
|
||||
* If fully obscured (no visible region), the original center is returned as best-effort
|
||||
* **Compose parent wrapper** pattern (identical bounds) is recognized as non-obstruction
|
||||
|
||||
## 4. Verification Workflow
|
||||
Follow these steps for a complete UI test:
|
||||
1. **Build and Install**: Ensure the latest version of the app is running: `./gradlew installDebug`.
|
||||
2. **Inspect**: Use `capture` to dump the UI hierarchy and take a screenshot.
|
||||
3. **Compare**: Check the current UI against any mock image files in the project.
|
||||
4. **Interact**: Use `tap_element` to tap a UI element by text. The tool handles coordinate calculation and obstruction detection automatically.
|
||||
5. **Verify**: Use `capture` again to confirm the UI has updated as expected (e.g., a new screen is shown, or a success message appeared).
|
||||
|
||||
## 5. Examples
|
||||
|
||||
### Example: Navigate to Settings and Check for Updates
|
||||
```
|
||||
# Go to home screen
|
||||
navigate({ action: "home" })
|
||||
|
||||
# Open app drawer
|
||||
navigate({ action: "app-drawer" })
|
||||
|
||||
# Find and tap through settings
|
||||
tap_element({ text: "Settings", waitSeconds: 2 })
|
||||
tap_element({ text: "System", waitSeconds: 2 })
|
||||
tap_element({ text: "Software updates", waitSeconds: 2 })
|
||||
tap_element({ text: "Check for update", waitSeconds: 5 })
|
||||
```
|
||||
|
||||
### Example: Swiping
|
||||
For swipe gestures not covered by the navigate tool, use raw ADB:
|
||||
```bash
|
||||
adb shell input swipe 672 2800 672 500 1000 && sleep 1 && adb shell screencap -p /sdcard/screen.png && adb pull /sdcard/screen.png .
|
||||
```
|
||||
|
||||
## 6. Best Practices
|
||||
|
||||
### Coordinate Calculation
|
||||
* Prefer `find_element` or `tap_element` over manual coordinate calculation — they handle bounds parsing, center computation, and obstruction detection automatically
|
||||
* When multiple instances of an element exist (e.g., in prediction row and full list), check the `find_element` response to verify you're targeting the correct one
|
||||
|
||||
### Navigation and State Evaluation
|
||||
* **Verify after each interaction**: Don't assume an action succeeded — use `capture` after interactions to confirm the UI changed as expected
|
||||
* **Check both visual and structural state**: Use screenshot for visual verification, XML dump for structural confirmation (element presence, text content, state changes)
|
||||
* **Identify navigation failures early**: If a tap opened the wrong screen, use `navigate({ action: "back" })` to recover immediately
|
||||
|
||||
### Interaction Patterns
|
||||
* **Scrolling before interaction**: When looking for an element, check if it's visible on screen first. If not, scroll using swipe gestures to reveal it
|
||||
* **Use consistent scroll direction**: For vertical scrolling in lists/settings, use downward swipes (higher Y -> lower Y) to scroll down
|
||||
* **Handle app crashes gracefully**: Don't retry the same action — use back button and try an alternative approach
|
||||
* **Check Accessibility**: Use the `content-desc` and `text` properties in the UI hierarchy to ensure the UI is accessible for screen readers
|
||||
|
||||
## 7. Troubleshooting
|
||||
|
||||
### Device Not Connected
|
||||
If tools report ADB errors:
|
||||
* Check USB connection or emulator status
|
||||
* Enable USB debugging on the device (Settings > Developer Options > USB Debugging)
|
||||
* Accept the RSA key prompt on the device if asked
|
||||
* Restart the device or disconnect/reconnect the USB cable
|
||||
* Run `adb devices` to verify the device is visible
|
||||
|
||||
### MCP Server Not Available
|
||||
If tools are not listed in `/mcp`:
|
||||
* Ensure Node.js 18+ is installed
|
||||
* The server auto-builds on first use via `.mcp.json` at the project root
|
||||
* Check `.claude/mcp/android-device-server/` exists with `package.json`
|
||||
* Try manual build: `cd .claude/mcp/android-device-server && npm install && npm run build`
|
||||
191
.claude/skills/planning-android-implementation/SKILL.md
Normal file
191
.claude/skills/planning-android-implementation/SKILL.md
Normal file
@@ -0,0 +1,191 @@
|
||||
---
|
||||
name: planning-android-implementation
|
||||
version: 0.1.0
|
||||
description: Architecture design and phased implementation planning for Bitwarden Android. Use when planning implementation, designing architecture, creating file inventories, or breaking features into phases. Triggered by "plan implementation", "architecture design", "implementation plan", "break this into phases", "what files do I need", "design the architecture".
|
||||
---
|
||||
|
||||
# Implementation Planning
|
||||
|
||||
This skill takes a refined specification (ideally from the `refining-android-requirements` skill) and produces a phased implementation plan with architecture design, file inventory, and risk assessment.
|
||||
|
||||
**Prerequisite**: A clear set of requirements. If requirements are vague or incomplete, invoke the `refining-android-requirements` skill first.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Classify Change
|
||||
|
||||
Determine the change type to guide scope and planning depth:
|
||||
|
||||
| Type | Description | Typical Scope |
|
||||
|------|-------------|---------------|
|
||||
| **New Feature** | Entirely new functionality, screens, or flows | New files + modifications, multi-phase |
|
||||
| **Enhancement** | Extending existing feature with new capabilities | Mostly modifications, 1-2 phases |
|
||||
| **Bug Fix** | Correcting incorrect behavior | Targeted modifications, single phase |
|
||||
| **Refactoring** | Restructuring without behavior change | Modifications only, migration-aware |
|
||||
| **Infrastructure** | Build, CI, tooling, or dependency changes | Config files, minimal code changes |
|
||||
|
||||
State the classification and rationale before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Codebase Exploration
|
||||
|
||||
Search the codebase to find reference implementations and integration points. Use the discovery commands from the `build-test-verify` skill as needed.
|
||||
|
||||
### Find Pattern Anchors
|
||||
|
||||
Identify 2-3 existing files that serve as templates for the planned work:
|
||||
|
||||
```
|
||||
**Pattern Anchors:**
|
||||
1. [file path] — [why this is a good reference]
|
||||
2. [file path] — [why this is a good reference]
|
||||
3. [file path] — [why this is a good reference]
|
||||
```
|
||||
|
||||
### Map Integration Points
|
||||
|
||||
Identify files that must be modified to integrate the new work:
|
||||
|
||||
- **Navigation**: Nav graph registrations, route definitions
|
||||
- **Dependency Injection**: Hilt modules, `@Provides` / `@Binds` functions
|
||||
- **Data Layer**: Repository interfaces, data source interfaces, Room DAOs
|
||||
- **API Layer**: Retrofit service interfaces, request/response models
|
||||
- **Feature Flags**: Feature flag definitions and checks
|
||||
- **Managers**: Single-responsibility data layer classes (see `docs/ARCHITECTURE.md` Managers section)
|
||||
- **Test Fixtures**: Shared test utilities in `src/testFixtures/` directories
|
||||
- **Product Flavor Source Sets**: Code in `src/standard/` vs `src/main/` for Play Services dependencies
|
||||
|
||||
### Document Existing Patterns
|
||||
|
||||
Note the specific patterns used by the pattern anchors:
|
||||
- State class structure (sealed class, data class fields)
|
||||
- Action/Event naming conventions
|
||||
- Repository method signatures and return types
|
||||
- Test structure and assertion patterns
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Architecture Design
|
||||
|
||||
Produce an ASCII diagram showing component relationships for the planned work:
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Screen │ ← Compose UI
|
||||
│ (Composable) │
|
||||
└────────┬────────┘
|
||||
│ State / Action / Event
|
||||
┌────────▼────────┐
|
||||
│ ViewModel │ ← Business logic orchestration
|
||||
└────────┬────────┘
|
||||
│ Repository calls
|
||||
┌────────▼────────┐
|
||||
│ Repository │ ← Data coordination (sealed class results)
|
||||
└───┬────┬────┬───┘
|
||||
│ │ │
|
||||
┌───▼───┐ │ ┌─▼──────┐
|
||||
│Manager│ │ │Manager │ ← Single-responsibility (optional)
|
||||
└───┬───┘ │ └─┬──────┘
|
||||
│ │ │
|
||||
┌───▼─────▼───▼────┐
|
||||
│ Data Sources │ ← Raw data (Result<T>, never throw)
|
||||
└─┬────┬────┬──────┘
|
||||
│ │ │
|
||||
Room Retrofit SDK
|
||||
```
|
||||
|
||||
Adapt the diagram to show the actual components planned. _Consult `docs/ARCHITECTURE.md` for full data layer patterns and conventions._
|
||||
|
||||
### Design Decisions
|
||||
|
||||
Document key architectural decisions in a table:
|
||||
|
||||
| Decision | Resolution | Rationale |
|
||||
|----------|-----------|-----------|
|
||||
| [What needed deciding] | [What was chosen] | [Why] |
|
||||
|
||||
---
|
||||
|
||||
## Step 4: File Inventory
|
||||
|
||||
### Files to Create
|
||||
|
||||
| File Path | Type | Pattern Reference |
|
||||
|-----------|------|-------------------|
|
||||
| [full path] | [ViewModel / Screen / Repository / etc.] | [pattern anchor file] |
|
||||
|
||||
**Include in file inventory:**
|
||||
- `...Navigation.kt` files for new screens
|
||||
- `...Module.kt` Hilt module files for new DI bindings
|
||||
- Paired test files (`...Test.kt`) for each new class
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File Path | Change Description | Risk Level |
|
||||
|-----------|-------------------|------------|
|
||||
| [full path] | [what changes] | Low / Medium / High |
|
||||
|
||||
**Risk levels:**
|
||||
- **Low**: Additive changes (new entries in nav graph, new bindings in Hilt module)
|
||||
- **Medium**: Modifying existing logic (adding parameters, new branches)
|
||||
- **High**: Changing interfaces, data models, or shared utilities
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Implementation Phases
|
||||
|
||||
Break the work into sequential phases. Each phase should be independently testable and committable.
|
||||
|
||||
**Phase ordering principle**: Foundation → SDK/Data → Network → UI (tests accompany each phase)
|
||||
|
||||
For each phase:
|
||||
|
||||
```markdown
|
||||
### Phase N: [Name]
|
||||
|
||||
**Goal**: [What this phase accomplishes]
|
||||
|
||||
**Files**:
|
||||
- Create: [list]
|
||||
- Modify: [list]
|
||||
|
||||
**Tasks**:
|
||||
1. [Specific implementation task]
|
||||
2. [Specific implementation task]
|
||||
3. ...
|
||||
|
||||
**Verification**:
|
||||
- [Test command or manual verification step]
|
||||
|
||||
**Skills**: [Which workflow skills apply — e.g., `implementing-android-code`, `testing-android-code`]
|
||||
```
|
||||
|
||||
### Phase Guidelines
|
||||
|
||||
- Each phase should be small enough to be independently testable and committable
|
||||
- Tests are written within the same phase as the code they verify (not deferred to a "testing phase")
|
||||
- UI phases come after their data dependencies are in place
|
||||
- If a phase has more than 5 tasks, consider splitting it
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Risk & Verification
|
||||
|
||||
### Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| [What could go wrong] | Low/Med/High | Low/Med/High | [How to prevent or handle] |
|
||||
|
||||
### Verification Plan
|
||||
|
||||
**Automated Verification:**
|
||||
- Unit test commands (from `build-test-verify` skill)
|
||||
- Lint/detekt commands
|
||||
- Build verification
|
||||
|
||||
**Manual Verification:**
|
||||
- [Specific manual test scenarios]
|
||||
- [Edge cases to manually verify]
|
||||
- Verify ViewModel state survives process death (test via `SavedStateHandle` persistence and `Don't keep activities` developer option)
|
||||
181
.claude/skills/refining-android-requirements/SKILL.md
Normal file
181
.claude/skills/refining-android-requirements/SKILL.md
Normal file
@@ -0,0 +1,181 @@
|
||||
---
|
||||
name: refining-android-requirements
|
||||
version: 0.1.0
|
||||
description: Requirements gap analysis and structured specification for Bitwarden Android. Use when refining requirements, analyzing specs, identifying gaps, or producing structured specifications from tickets or descriptions. Triggered by "refine requirements", "gap analysis", "spec review", "requirements analysis", "what's missing from this spec", "analyze this ticket".
|
||||
---
|
||||
|
||||
# Requirements Refinement
|
||||
|
||||
This skill takes raw requirements (from Jira tickets, Confluence pages, or free-text descriptions) and produces a structured, implementation-ready specification through systematic gap analysis.
|
||||
|
||||
**Key principle**: This skill identifies gaps and produces specifications. It does NOT propose solutions or architecture — that is the responsibility of the `planning-android-implementation` skill.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Source Consolidation
|
||||
|
||||
Combine all input sources into a single working document. For each requirement, note its source:
|
||||
|
||||
```
|
||||
- [Source: PM-12345] User must be able to configure timeout
|
||||
- [Source: Confluence] Timeout range is 1-60 minutes
|
||||
- [Source: User] Default timeout should be 15 minutes
|
||||
```
|
||||
|
||||
Flag any contradictions between sources for immediate resolution.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Gap Analysis
|
||||
|
||||
Evaluate the consolidated requirements against the following 5-category rubric. For each category, check every item and note whether it is **covered**, **partially covered**, or **missing**.
|
||||
|
||||
### A. Functional Requirements
|
||||
|
||||
| Check | Question to Ask If Missing |
|
||||
|-------|---------------------------|
|
||||
| User actions defined? | What specific user actions trigger this feature? |
|
||||
| All states covered? (empty, loading, error, success) | What should the user see in [empty/loading/error] state? |
|
||||
| Edge cases identified? | What happens when [boundary condition]? |
|
||||
| Cancellation/back navigation flows? | Can the user cancel mid-flow? What happens to partial data? |
|
||||
| Input validation rules? | What are the valid ranges/formats for [input]? |
|
||||
| Success/failure criteria? | How does the user know the operation succeeded or failed? |
|
||||
| Offline behavior? | What happens if this is attempted offline? |
|
||||
|
||||
### B. Technical Requirements
|
||||
|
||||
| Check | Question to Ask If Missing |
|
||||
|-------|---------------------------|
|
||||
| Module scope identified? (`:app`, `:authenticator`, shared) | Which module(s) does this feature belong to? |
|
||||
| SDK dependencies? | Does this require Bitwarden SDK operations? Which ones? |
|
||||
| Data storage approach? (Room, DataStore, in-memory) | Where is the data for this feature persisted? |
|
||||
| Network API endpoints? | Which API endpoints are involved? Are they existing or new? |
|
||||
| Process death handling? | What state needs to survive process death? |
|
||||
| Migration requirements? | Does existing data need migration? |
|
||||
| Feature flag needed? | Should this be behind a feature flag for staged rollout? |
|
||||
| Product flavors (standard vs fdroid)? | Does this feature depend on Google Play Services? Available on F-Droid? |
|
||||
| Data layer tier? | Does this need a new Manager (single-responsibility) or only Repository/DataSource? Consult `docs/ARCHITECTURE.md` Data Layer section. |
|
||||
| Streaming vs discrete data? | Is data continuously observed (`DataState<T>` + `StateFlow`) or a one-shot operation (custom sealed class)? See `docs/ARCHITECTURE.md` Repositories section. |
|
||||
|
||||
### C. Security Requirements
|
||||
|
||||
| Check | Question to Ask If Missing |
|
||||
|-------|---------------------------|
|
||||
| Data sensitivity classified? | What sensitivity level does this data have? (vault-level, account-level, non-sensitive) |
|
||||
| Storage encryption required? | Must this data be encrypted at rest? Via SDK or Android Keystore? |
|
||||
| Logout cleanup behavior? | What must be cleared when the user logs out? |
|
||||
| Auth-gating? | Does accessing this feature require active authentication? |
|
||||
| Input sanitization? | Are there URL or credential inputs that need validation? |
|
||||
| Sensitive data in ViewModel state? | Will passwords, tokens, or keys appear in state? Must use `@IgnoredOnParcel`. See `implementing-android-code` skill Section F. |
|
||||
| SDK crypto context isolation? | Does this use vault encryption? Must use `ScopedVaultSdkSource` for multi-account safety. See CLAUDE.md Security Rules. |
|
||||
|
||||
### D. UX/UI Requirements
|
||||
|
||||
| Check | Question to Ask If Missing |
|
||||
|-------|---------------------------|
|
||||
| UI copy/strings defined? | What text should appear for [label/button/message]? |
|
||||
| Error messages specified? | What should the error message say when [failure case]? |
|
||||
| Loading states designed? | Should loading show a spinner, skeleton, or shimmer? |
|
||||
| Navigation flow clear? | Where does the user go after [action]? Back stack behavior? |
|
||||
| Accessibility considerations? | Are there content descriptions or focus order requirements? |
|
||||
| Toast/snackbar/dialog for feedback? | What feedback mechanism for [action result]? |
|
||||
|
||||
### E. Cross-Cutting Concerns
|
||||
|
||||
| Check | Question to Ask If Missing |
|
||||
|-------|---------------------------|
|
||||
| Multi-account behavior? | How does this behave with multiple accounts? Per-account or global? |
|
||||
| Backwards compatibility? | Does this affect existing users? Migration path? |
|
||||
| Feature flag strategy? | Is this behind a server-side or local feature flag? |
|
||||
| Analytics/logging? | Are there analytics events to track? |
|
||||
| Bitwarden Authenticator impact? | Does this affect the `:authenticator` module? |
|
||||
| F-Droid compatibility? | Does this degrade gracefully without Google Play Services (no push notifications, no Play Integrity)? |
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Present Gaps
|
||||
|
||||
Organize all identified gaps into two categories:
|
||||
|
||||
### Blocking Questions
|
||||
|
||||
Questions that **must** be answered before implementation can begin because they change the architecture, data model, or core flow.
|
||||
|
||||
Format each question as:
|
||||
|
||||
```
|
||||
**G[N]** ([Category]) — [Question text]
|
||||
Context: [Why this matters / what depends on the answer]
|
||||
```
|
||||
|
||||
### Non-Blocking Questions
|
||||
|
||||
Questions that have **reasonable defaults** and can be resolved during implementation. Note the assumed default.
|
||||
|
||||
Format each question as:
|
||||
|
||||
```
|
||||
**G[N]** ([Category]) — [Question text]
|
||||
Default assumption: [What we'll assume if not answered]
|
||||
Context: [Why this matters]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Produce Specification
|
||||
|
||||
After the user answers blocking questions (and optionally non-blocking ones), produce a structured specification:
|
||||
|
||||
```markdown
|
||||
## Overview
|
||||
|
||||
[1-2 paragraph summary of the feature, its purpose, and scope]
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| FR1 | [requirement] | [source] | [any notes] |
|
||||
| FR2 | ... | ... | ... |
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| TR1 | [requirement] | [source] | [any notes] |
|
||||
| TR2 | ... | ... | ... |
|
||||
|
||||
## Security Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| SR1 | [requirement] | [source] | [any notes] |
|
||||
|
||||
## UX Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| UX1 | [requirement] | [source] | [any notes] |
|
||||
|
||||
## Open Items
|
||||
|
||||
Non-blocking items with assumed defaults that may be revisited:
|
||||
|
||||
| ID | Question | Assumed Default | Category |
|
||||
|----|----------|----------------|----------|
|
||||
| G[N] | [question] | [default] | [category] |
|
||||
|
||||
## Source Documentation
|
||||
|
||||
| Source | Type | Link |
|
||||
|--------|------|------|
|
||||
| [name] | Jira / Confluence / User-provided | [link if available] |
|
||||
```
|
||||
|
||||
### Output Guidelines
|
||||
|
||||
- Requirements use numbered IDs (FR1, TR1, SR1, UX1) for traceability through implementation
|
||||
- Each requirement cites its source (ticket, page, or user-provided)
|
||||
- Technical requirements use table format for structured key/value data
|
||||
- Interface signatures are included as fenced code blocks when applicable
|
||||
- Open items preserve the gap ID (G[N]) for cross-referencing
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: reviewing-changes
|
||||
version: 3.0.0
|
||||
description: Guides Android code reviews with type-specific checklists and MVVM/Compose pattern validation. Use when reviewing Android PRs, pull requests, diffs, or local changes involving Kotlin, ViewModel, Composable, Repository, or Gradle files. Triggered by "review PR", "review changes", "check this code", "Android review", or code review requests mentioning bitwarden/android. Loads specialized checklists for feature additions, bug fixes, UI refinements, refactoring, dependency updates, and infrastructure changes.
|
||||
description: Android-specific code review checklist and MVVM/Compose pattern validation for Bitwarden Android — use this for any review task, even if the user doesn't explicitly ask for a "checklist". Detects change type automatically and loads the right review strategy (feature additions, bug fixes, UI refinements, refactoring, dependency updates, infrastructure). Triggered by "review PR", "review changes", "review this code", "check this code", "Android review", code review requests on Kotlin/ViewModel/Composable/Repository/Gradle files, or any time someone asks to look at a diff, PR, or code changes in bitwarden/android.
|
||||
---
|
||||
|
||||
# Reviewing Changes - Android Additions
|
||||
@@ -10,16 +9,10 @@ This skill provides Android-specific workflow additions that complement the base
|
||||
|
||||
## Instructions
|
||||
|
||||
**IMPORTANT**: Use structured thinking throughout your review process. Plan your analysis in `<thinking>` tags before providing final feedback.
|
||||
**IMPORTANT**: Work systematically through each step before providing feedback. Each checklist file includes structured thinking guidance for its review passes.
|
||||
|
||||
### Step 1: Retrieve Additional Details
|
||||
|
||||
<thinking>
|
||||
Determine if more context is available for the changes:
|
||||
1. Are there JIRA tickets or GitHub Issues mentioned in the PR title or body?
|
||||
2. Are there other GitHub pull requests mentioned in the PR title or body?
|
||||
</thinking>
|
||||
|
||||
Retrieve any additional information linked to the pull request using available tools (JIRA MCP, GitHub API).
|
||||
|
||||
If pull request title and message do not provide enough context, request additional details from the reviewer:
|
||||
@@ -28,15 +21,11 @@ If pull request title and message do not provide enough context, request additio
|
||||
- Link to another pull request
|
||||
- Add more detail to the PR title or body
|
||||
|
||||
### Step 2: Detect Change Type with Android Refinements
|
||||
**Android metadata checks** — flag as ❓ if any of these are missing:
|
||||
- PR includes `*Screen.kt` or Composable changes but has no screenshots
|
||||
- PR adds new `ViewModel` or `Repository` but has no test plan or test file changes
|
||||
|
||||
<thinking>
|
||||
Analyze the changeset systematically:
|
||||
1. What files were modified? (code vs config vs docs)
|
||||
2. What is the PR/commit title indicating?
|
||||
3. Is there new functionality or just modifications?
|
||||
4. What's the risk level of these changes?
|
||||
</thinking>
|
||||
### Step 2: Detect Change Type with Android Refinements
|
||||
|
||||
Use the base change type detection from the agent, with Android-specific refinements:
|
||||
|
||||
@@ -65,21 +54,13 @@ The checklist provides:
|
||||
|
||||
### Step 4: Execute Review Following Checklist
|
||||
|
||||
<thinking>
|
||||
Before diving into details:
|
||||
1. What are the highest-risk areas of this change?
|
||||
2. Which architectural patterns need verification?
|
||||
3. What security implications exist?
|
||||
4. How should I prioritize my findings?
|
||||
5. What tone is appropriate for this feedback?
|
||||
</thinking>
|
||||
|
||||
Follow the checklist's multi-pass strategy, thinking through each pass systematically.
|
||||
|
||||
### Step 5: Consult Android Reference Materials As Needed
|
||||
|
||||
Load reference files only when needed for specific questions:
|
||||
|
||||
- **Re-reviews** → invoke `reviewing-incremental-changes` agent skill; scope to changed lines only, do not flag new issues in unchanged code
|
||||
- **Issue prioritization** → `reference/priority-framework.md` (Critical vs Suggested vs Optional)
|
||||
- **Phrasing feedback** → `reference/review-psychology.md` (questions vs commands, I-statements)
|
||||
- **Architecture questions** → `reference/architectural-patterns.md` (MVVM, Hilt DI, module org, error handling)
|
||||
@@ -87,10 +68,12 @@ Load reference files only when needed for specific questions:
|
||||
- **Security questions (comprehensive)** → `docs/ARCHITECTURE.md#security` (full zero-knowledge architecture)
|
||||
- **Testing questions** → `reference/testing-patterns.md` (unit tests, mocking, null safety)
|
||||
- **UI questions** → `reference/ui-patterns.md` (Compose patterns, theming)
|
||||
- **Style questions** → `docs/STYLE_AND_BEST_PRACTICES.md`
|
||||
- **Style questions (project-specific)** → `reference/style-patterns.md` (Kotlin rules enforced in review)
|
||||
- **Style questions (general)** → `docs/STYLE_AND_BEST_PRACTICES.md`
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Priority order**: Security → Correctness → Breaking Changes → Performance → Maintainability
|
||||
- **Appropriate depth**: Match review rigor to change complexity and risk
|
||||
- **Specific references**: Always use `file:line_number` format for precise location
|
||||
- **Actionable feedback**: Say what to do and why, not just what's wrong
|
||||
|
||||
@@ -4,15 +4,6 @@
|
||||
|
||||
### First Pass: Understand the Bug
|
||||
|
||||
<thinking>
|
||||
Before evaluating the fix:
|
||||
1. What was the original bug/broken behavior?
|
||||
2. What is the expected correct behavior?
|
||||
3. What was the root cause?
|
||||
4. How was the bug discovered? (user report, test, production)
|
||||
5. What's the severity? (crash, data loss, UI glitch, minor annoyance)
|
||||
</thinking>
|
||||
|
||||
**1. Understand root cause:**
|
||||
- What was the broken behavior?
|
||||
- What caused it?
|
||||
@@ -29,15 +20,6 @@ Before evaluating the fix:
|
||||
|
||||
### Second Pass: Verify the Fix
|
||||
|
||||
<thinking>
|
||||
Evaluate the fix systematically:
|
||||
1. Does this fix address the root cause or just symptoms?
|
||||
2. Are there edge cases not covered?
|
||||
3. Could this break other functionality?
|
||||
4. Is the fix localized or does it ripple through the codebase?
|
||||
5. How do we prevent this bug from returning?
|
||||
</thinking>
|
||||
|
||||
**4. Code changes:**
|
||||
- Does the fix make sense?
|
||||
- Is it the simplest solution?
|
||||
@@ -101,16 +83,7 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
|
||||
|
||||
## Output Format
|
||||
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
|
||||
## Example Review
|
||||
|
||||
|
||||
@@ -4,15 +4,6 @@
|
||||
|
||||
### First Pass: Identify and Assess
|
||||
|
||||
<thinking>
|
||||
Before diving into details:
|
||||
1. Which dependencies were updated?
|
||||
2. What are the version changes? (patch, minor, major)
|
||||
3. Are any security-sensitive libraries involved? (crypto, auth, networking)
|
||||
4. Any pre-release versions (alpha, beta, RC)?
|
||||
5. What's the blast radius if something breaks?
|
||||
</thinking>
|
||||
|
||||
**1. Identify the change:**
|
||||
- Which library? Old version → New version?
|
||||
- Major (X.0.0), Minor (0.X.0), or Patch (0.0.X) version change?
|
||||
@@ -25,15 +16,6 @@ Before diving into details:
|
||||
|
||||
### Second Pass: Deep Analysis
|
||||
|
||||
<thinking>
|
||||
For each dependency update:
|
||||
1. What changes are in this release?
|
||||
2. Are there breaking changes?
|
||||
3. Are there security fixes?
|
||||
4. Do we use the affected APIs?
|
||||
5. How does this affect our codebase?
|
||||
</thinking>
|
||||
|
||||
**3. Review release notes** (if available):
|
||||
- Breaking changes mentioned?
|
||||
- Security fixes included?
|
||||
@@ -92,16 +74,7 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
|
||||
|
||||
## Output Format
|
||||
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
|
||||
## Example Reviews
|
||||
|
||||
|
||||
@@ -4,15 +4,6 @@
|
||||
|
||||
### First Pass: High-Level Assessment
|
||||
|
||||
<thinking>
|
||||
Before diving into details:
|
||||
1. What is this feature supposed to do?
|
||||
2. How does it fit into the existing architecture?
|
||||
3. What are the security implications?
|
||||
4. What's the scope? (files touched, modules affected)
|
||||
5. What are the highest-risk areas?
|
||||
</thinking>
|
||||
|
||||
**1. Understand the feature:**
|
||||
- Read PR description - what problem does this solve?
|
||||
- Identify user-facing changes vs internal changes
|
||||
@@ -30,15 +21,6 @@ Before diving into details:
|
||||
|
||||
### Second Pass: Architecture Deep-Dive
|
||||
|
||||
<thinking>
|
||||
Verify architectural integrity:
|
||||
1. Does this follow MVVM + UDF pattern?
|
||||
2. Is Hilt DI used correctly?
|
||||
3. Is state management proper (StateFlow, immutability)?
|
||||
4. Are modules organized correctly?
|
||||
5. Is error handling robust (Result types)?
|
||||
</thinking>
|
||||
|
||||
**4. MVVM + UDF Pattern Compliance:**
|
||||
- ViewModels properly structured?
|
||||
- State management using StateFlow?
|
||||
@@ -60,15 +42,6 @@ Verify architectural integrity:
|
||||
|
||||
### Third Pass: Details and Quality
|
||||
|
||||
<thinking>
|
||||
Check quality and completeness:
|
||||
1. Is code quality high? (null safety, documentation, naming)
|
||||
2. Are tests comprehensive? (unit + integration)
|
||||
3. Are there edge cases not covered?
|
||||
4. Is documentation clear?
|
||||
5. Are there any code smells or anti-patterns?
|
||||
</thinking>
|
||||
|
||||
**8. Testing:**
|
||||
- Unit tests for ViewModels and repositories?
|
||||
- Test coverage for edge cases and error scenarios?
|
||||
@@ -86,144 +59,13 @@ Check quality and completeness:
|
||||
|
||||
## Architecture Review
|
||||
|
||||
### MVVM Pattern Compliance
|
||||
Read `reference/architectural-patterns.md` for full patterns and code examples.
|
||||
|
||||
Read `reference/architectural-patterns.md` for detailed patterns.
|
||||
|
||||
**ViewModels must:**
|
||||
- Use `@HiltViewModel` annotation
|
||||
- Use `@Inject constructor`
|
||||
- Expose `StateFlow<T>`, NOT `MutableStateFlow<T>` publicly
|
||||
- Delegate business logic to Repository/Manager
|
||||
- Avoid direct Android framework dependencies (except ViewModel, SavedStateHandle)
|
||||
|
||||
**Common Violations:**
|
||||
```kotlin
|
||||
// ❌ BAD - Exposes mutable state
|
||||
class FeatureViewModel @Inject constructor() : ViewModel() {
|
||||
val state: MutableStateFlow<State> = MutableStateFlow(State.Initial)
|
||||
}
|
||||
|
||||
// ✅ GOOD - Exposes immutable state
|
||||
class FeatureViewModel @Inject constructor() : ViewModel() {
|
||||
private val _state = MutableStateFlow<State>(State.Initial)
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
}
|
||||
|
||||
// ❌ BAD - Business logic in ViewModel
|
||||
fun onSubmit() {
|
||||
val encrypted = encryptionManager.encrypt(password) // Should be in Repository
|
||||
_state.value = State.Success
|
||||
}
|
||||
|
||||
// ✅ GOOD - Business logic in Repository, state updated via internal event
|
||||
fun onSubmit() {
|
||||
viewModelScope.launch {
|
||||
// The result of the async operation is captured
|
||||
val result = repository.submitData(password)
|
||||
// A single event is sent with the result, not updating state directly
|
||||
sendAction(FeatureAction.Internal.SubmissionComplete(result))
|
||||
}
|
||||
}
|
||||
|
||||
// The ViewModel has a handler that processes the internal event
|
||||
private fun handleInternalAction(action: FeatureAction.Internal) {
|
||||
when (action) {
|
||||
is FeatureAction.Internal.SubmissionComplete -> {
|
||||
// The event handler evaluates the result and updates state
|
||||
action.result.fold(
|
||||
onSuccess = { _state.value = State.Success },
|
||||
onFailure = { _state.value = State.Error(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**UI Layer must:**
|
||||
- Only observe state, never modify
|
||||
- Pass user actions as events to ViewModel
|
||||
- Contain no business logic
|
||||
- Use existing UI components from `:ui` module where possible
|
||||
|
||||
### Hilt Dependency Injection
|
||||
|
||||
Reference: `docs/ARCHITECTURE.md#dependency-injection`
|
||||
|
||||
**Required Patterns:**
|
||||
- ViewModels: `@HiltViewModel` + `@Inject constructor`
|
||||
- Repositories: `@Inject constructor` on implementation
|
||||
- Inject interfaces, not concrete implementations
|
||||
- Modules must provide proper scoping (`@Singleton`, `@ViewModelScoped`)
|
||||
|
||||
**Common Violations:**
|
||||
```kotlin
|
||||
// ❌ BAD - Manual instantiation
|
||||
class FeatureViewModel : ViewModel() {
|
||||
private val repository = FeatureRepositoryImpl()
|
||||
}
|
||||
|
||||
// ✅ GOOD - Injected interface
|
||||
@HiltViewModel
|
||||
class FeatureViewModel @Inject constructor(
|
||||
private val repository: FeatureRepository // Interface, not implementation
|
||||
) : ViewModel()
|
||||
|
||||
// ❌ BAD - Injecting implementation
|
||||
class FeatureViewModel @Inject constructor(
|
||||
private val repository: FeatureRepositoryImpl // Should inject interface
|
||||
)
|
||||
|
||||
// ✅ GOOD - Interface injection
|
||||
class FeatureViewModel @Inject constructor(
|
||||
private val repository: FeatureRepository // Interface
|
||||
)
|
||||
```
|
||||
|
||||
### Module Organization
|
||||
|
||||
Reference: `docs/ARCHITECTURE.md#module-structure`
|
||||
|
||||
**Correct Placement:**
|
||||
- `:core` - Shared utilities (cryptography, analytics, logging)
|
||||
- `:data` - Repositories, database, domain models
|
||||
- `:network` - API clients, network utilities
|
||||
- `:ui` - Reusable Compose components, theme
|
||||
- `:app` - Feature screens, ViewModels, navigation
|
||||
- `:authenticator` - Authenticator app (separate from password manager)
|
||||
|
||||
**Check:**
|
||||
- UI code in `:ui` or `:app` modules
|
||||
- Data models in `:data`
|
||||
- Network clients in `:network`
|
||||
- No circular dependencies between modules
|
||||
|
||||
### Error Handling
|
||||
|
||||
Reference: `docs/ARCHITECTURE.md#error-handling`
|
||||
|
||||
**Required Pattern - Use Result types:**
|
||||
```kotlin
|
||||
// ✅ GOOD - Result type
|
||||
suspend fun fetchData(): Result<Data> = runCatching {
|
||||
apiService.getData()
|
||||
}
|
||||
|
||||
// ViewModel handles Result
|
||||
repository.fetchData().fold(
|
||||
onSuccess = { data -> _state.value = State.Success(data) },
|
||||
onFailure = { error -> _state.value = State.Error(error) }
|
||||
)
|
||||
|
||||
// ❌ BAD - Exception-based in business logic
|
||||
suspend fun fetchData(): Data {
|
||||
try {
|
||||
return apiService.getData()
|
||||
} catch (e: Exception) {
|
||||
throw FeatureException(e) // Don't throw in business logic
|
||||
}
|
||||
}
|
||||
```
|
||||
**Check these four areas:**
|
||||
- **MVVM/UDF**: ViewModel exposes `StateFlow` (not `MutableStateFlow`), business logic in Repository, UI is stateless
|
||||
- **Hilt DI**: `@HiltViewModel` + `@Inject constructor`, inject interfaces not implementations, no manual instantiation
|
||||
- **Module placement**: UI in `:ui`/`:app`, data in `:data`, network in `:network`, no circular dependencies
|
||||
- **Error handling**: `Result<T>` / `runCatching` throughout — no thrown exceptions from data layer
|
||||
|
||||
## Security Review
|
||||
|
||||
@@ -366,15 +208,4 @@ Use `reference/review-psychology.md` for phrasing guidance.
|
||||
|
||||
## Output Format
|
||||
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
See `examples/review-outputs.md` for comprehensive feature review example.
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
|
||||
@@ -4,15 +4,6 @@
|
||||
|
||||
### First Pass: Understand the Change
|
||||
|
||||
<thinking>
|
||||
Assess infrastructure change:
|
||||
1. What problem does this solve?
|
||||
2. Does this affect production builds, CI/CD, or dev workflow?
|
||||
3. What's the risk if this breaks?
|
||||
4. Can this be tested before merge?
|
||||
5. What's the rollback plan?
|
||||
</thinking>
|
||||
|
||||
**1. Identify the goal:**
|
||||
- What problem does this solve?
|
||||
- Is this optimization, fix, or new capability?
|
||||
@@ -30,15 +21,6 @@ Assess infrastructure change:
|
||||
|
||||
### Second Pass: Verify Implementation
|
||||
|
||||
<thinking>
|
||||
Verify configuration and impact:
|
||||
1. Is the configuration syntax valid?
|
||||
2. Are secrets/credentials handled securely?
|
||||
3. What's the impact on build times and CI performance?
|
||||
4. How will this affect the team's workflow?
|
||||
5. Is there adequate testing/validation?
|
||||
</thinking>
|
||||
|
||||
**4. Configuration correctness:**
|
||||
- Syntax valid?
|
||||
- References correct?
|
||||
@@ -189,16 +171,7 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
|
||||
|
||||
## Output Format
|
||||
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
|
||||
## Example Review
|
||||
|
||||
|
||||
@@ -4,15 +4,6 @@
|
||||
|
||||
### First Pass: Understand the Refactoring
|
||||
|
||||
<thinking>
|
||||
Analyze the refactoring scope:
|
||||
1. What pattern is being improved?
|
||||
2. Why is this refactoring needed?
|
||||
3. Does this change behavior or just structure?
|
||||
4. What's the scope? (files affected, migration completeness)
|
||||
5. What are the risks if something breaks?
|
||||
</thinking>
|
||||
|
||||
**1. Understand the goal:**
|
||||
- What pattern is being improved?
|
||||
- Why is this refactoring needed?
|
||||
@@ -30,15 +21,6 @@ Analyze the refactoring scope:
|
||||
|
||||
### Second Pass: Verify Consistency
|
||||
|
||||
<thinking>
|
||||
Verify refactoring quality:
|
||||
1. Is the new pattern applied consistently throughout?
|
||||
2. Are there missed instances of the old pattern?
|
||||
3. Do tests still pass with same behavior?
|
||||
4. Is the migration complete or partial?
|
||||
5. Does this introduce any new issues?
|
||||
</thinking>
|
||||
|
||||
**4. Pattern consistency:**
|
||||
- Is the new pattern applied consistently throughout?
|
||||
- Are there missed instances of the old pattern?
|
||||
@@ -169,16 +151,7 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
|
||||
|
||||
## Output Format
|
||||
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
|
||||
## Example Reviews
|
||||
|
||||
|
||||
@@ -4,15 +4,6 @@
|
||||
|
||||
### First Pass: Visual Changes
|
||||
|
||||
<thinking>
|
||||
Analyze the UI changes:
|
||||
1. What visual/UX problem is being solved?
|
||||
2. Are there designs or screenshots to reference?
|
||||
3. Is this affecting existing screens or new ones?
|
||||
4. What's the scope of visual changes?
|
||||
5. Are design tokens (colors, spacing, typography) being used correctly?
|
||||
</thinking>
|
||||
|
||||
**1. Understand the changes:**
|
||||
- What visual/UX problem is being solved?
|
||||
- Are there designs or screenshots to reference?
|
||||
@@ -25,15 +16,6 @@ Analyze the UI changes:
|
||||
|
||||
### Second Pass: Implementation Review
|
||||
|
||||
<thinking>
|
||||
Check implementation quality:
|
||||
1. Are Compose best practices followed?
|
||||
2. Is state hoisting applied correctly?
|
||||
3. Are existing components reused where possible?
|
||||
4. Is accessibility properly handled?
|
||||
5. Does this follow design system patterns?
|
||||
</thinking>
|
||||
|
||||
**3. Compose best practices:**
|
||||
- Composables properly structured?
|
||||
- State hoisted correctly?
|
||||
@@ -187,16 +169,7 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
|
||||
|
||||
## Output Format
|
||||
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
|
||||
## Example Review
|
||||
|
||||
|
||||
@@ -50,21 +50,34 @@ Reference: [docs link if applicable]
|
||||
- ⚠️ **IMPORTANT** - Should fix (missing tests, quality issues)
|
||||
- ♻️ **DEBT** - Technical debt (duplication, convention violations, future rework needed)
|
||||
- 🎨 **SUGGESTED** - Nice to have (refactoring, improvements)
|
||||
- 💭 **QUESTION** - Seeking clarification (requirements, design decisions)
|
||||
- ❓ **QUESTION** - Seeking clarification (requirements, design decisions)
|
||||
|
||||
### Summary Comment Format
|
||||
|
||||
**Required format for ALL PRs:**
|
||||
Uses the agent's `posting-review-summary` skill format. Surface ❌ CRITICAL issues at the top level for immediate visibility, wrap the full findings list in `<details>` for scannability.
|
||||
|
||||
```
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [issue with file:line]
|
||||
[1-2 neutral sentences describing what was reviewed]
|
||||
|
||||
See inline comments for details.
|
||||
**Critical Issues** (if any):
|
||||
- ❌ [One-line summary with file:line]
|
||||
|
||||
<details>
|
||||
<summary>All findings</summary>
|
||||
|
||||
- ❌ **CRITICAL**: [description] (`file:line`)
|
||||
- ⚠️ **IMPORTANT**: [description] (`file:line`)
|
||||
- ♻️ **DEBT**: [description] (`file:line`)
|
||||
- 🎨 **SUGGESTED**: [description] (`file:line`)
|
||||
- ❓ **QUESTION**: [description] (`file:line`)
|
||||
</details>
|
||||
```
|
||||
|
||||
All PRs use the same minimal format - no exceptions for size or complexity. Summary must be 5-10 lines maximum.
|
||||
For clean PRs with no findings, omit both sections entirely — verdict + 1-2 sentences is sufficient.
|
||||
|
||||
**GitHub pitfall**: Never use `#` followed by a number in comment text (e.g., `#42`, `#PR123`). GitHub autolinks these to issues/PRs. Use `Finding 1:` or `item 42` instead.
|
||||
|
||||
---
|
||||
|
||||
@@ -268,7 +281,7 @@ Would add security layer against brute force. Consider discussing threat model w
|
||||
|
||||
**Inline Comment 5** (on `app/vault/unlock/UnlockScreen.kt:134`):
|
||||
```markdown
|
||||
💭 **QUESTION**: Can we use BitwardenTextField?
|
||||
❓ **QUESTION**: Can we use BitwardenTextField?
|
||||
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
@@ -9,7 +9,7 @@ Use this framework to classify findings during code review. Clear prioritization
|
||||
- [⚠️ IMPORTANT (Should Fix)](#important-should-fix)
|
||||
- [♻️ DEBT (Technical Debt)](#debt-technical-debt)
|
||||
- [🎨 SUGGESTED (Nice to Have)](#suggested-nice-to-have)
|
||||
- [💭 QUESTION (Seeking Clarification)](#question-seeking-clarification)
|
||||
- [❓ QUESTION (Seeking Clarification)](#question-seeking-clarification)
|
||||
- [Optional (Acknowledge But Don't Require)](#optional-acknowledge-but-dont-require)
|
||||
|
||||
**Guidelines:**
|
||||
@@ -170,13 +170,12 @@ Will require rework when experimentation framework launches.
|
||||
|
||||
## 🎨 **SUGGESTED** (Nice to Have)
|
||||
|
||||
These are improvement opportunities but not required. Consider the effort vs. benefit before requesting changes.
|
||||
Improvements with measurable value only. A finding qualifies as SUGGESTED if it provides: security gain, cyclomatic complexity reduction, bug class prevention, or elimination of an O(n²) pattern. Subjective style preferences, vague simplifications, and naming nitpicks do not qualify — leave those out entirely or raise in conversation.
|
||||
|
||||
### Code Quality
|
||||
- Minor style inconsistencies (if not caught by linter)
|
||||
- Opportunities for DRY improvements
|
||||
- Better variable naming for clarity
|
||||
- Simplification opportunities
|
||||
- Extractable duplicated logic that reduces measurable complexity or improves testability
|
||||
- Patterns that would prevent a recurring bug class in this module
|
||||
- Architecture improvements that eliminate tight coupling with measurable impact
|
||||
|
||||
**Example**:
|
||||
```
|
||||
@@ -208,7 +207,7 @@ Could be extracted to separate validator class for reusability and testing.
|
||||
|
||||
---
|
||||
|
||||
## 💭 **QUESTION** (Seeking Clarification)
|
||||
## ❓ **QUESTION** (Seeking Clarification)
|
||||
|
||||
Questions about requirements, unclear intent, or potential conflicts that require human knowledge to answer. Open inquiries that cannot be resolved through code inspection alone.
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ Effective code review feedback is clear, actionable, and constructive. This guid
|
||||
## Table of Contents
|
||||
|
||||
**Guidelines:**
|
||||
- [Core Directives](#core-directives)
|
||||
- [Phrasing Templates](#phrasing-templates)
|
||||
- [Critical Issues (Prescriptive)](#critical-issues-prescriptive)
|
||||
- [Suggested Improvements (Exploratory)](#suggested-improvements-exploratory)
|
||||
@@ -16,17 +15,6 @@ Effective code review feedback is clear, actionable, and constructive. This guid
|
||||
|
||||
---
|
||||
|
||||
## Core Directives
|
||||
|
||||
- **Keep positive feedback minimal**: For clean PRs with no issues, use 2-3 line approval only. When acknowledging good practices in PRs with issues, use single bullet list with no elaboration. Never create elaborate sections praising correct implementations.
|
||||
- Ask questions for design decisions, be prescriptive for clear violations
|
||||
- Focus on code, not people ("This code..." not "You...")
|
||||
- Use I-statements for subjective feedback ("Hard for me to understand...")
|
||||
- Explain rationale with every recommendation
|
||||
- Avoid: "just", "simply", "obviously", "easy"
|
||||
|
||||
---
|
||||
|
||||
## Phrasing Templates
|
||||
|
||||
### Critical Issues (Prescriptive)
|
||||
|
||||
32
.claude/skills/reviewing-changes/reference/style-patterns.md
Normal file
32
.claude/skills/reviewing-changes/reference/style-patterns.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Style Patterns Quick Reference
|
||||
|
||||
Project-specific Kotlin style rules to catch during code review. These supplement (not replace) `docs/STYLE_AND_BEST_PRACTICES.md`.
|
||||
|
||||
## `when` branches with wrapped right-hand side require curly braces
|
||||
|
||||
When a `when` branch's expression is too long to fit on the same line as `->` and is wrapped to its own line, the body must be wrapped in `{ }`. A bare `->` followed by an indented expression on the next line should be flagged.
|
||||
|
||||
**Flag this:**
|
||||
|
||||
```kotlin
|
||||
when (type) {
|
||||
VaultItemCipherType.LOGIN -> VaultAddEditState.ViewState.Content.ItemType.Login()
|
||||
VaultItemCipherType.BANK_ACCOUNT ->
|
||||
VaultAddEditState.ViewState.Content.ItemType.BankAccount()
|
||||
}
|
||||
```
|
||||
|
||||
**Accept this:**
|
||||
|
||||
```kotlin
|
||||
when (type) {
|
||||
VaultItemCipherType.LOGIN -> VaultAddEditState.ViewState.Content.ItemType.Login()
|
||||
VaultItemCipherType.BANK_ACCOUNT -> {
|
||||
VaultAddEditState.ViewState.Content.ItemType.BankAccount()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Single-line branches (body fits alongside `->`) do **not** require braces.
|
||||
|
||||
**Suggested classification:** SUGGESTED (style consistency, not correctness).
|
||||
@@ -1,44 +0,0 @@
|
||||
# Testing Android Code Skill
|
||||
|
||||
Quick-reference guide for writing and reviewing tests in the Bitwarden Android codebase.
|
||||
|
||||
## Purpose
|
||||
|
||||
This skill provides tactical testing guidance for Bitwarden-specific patterns. It focuses on base test classes, test utilities, and common gotchas unique to this codebase rather than general testing concepts.
|
||||
|
||||
## When This Skill Activates
|
||||
|
||||
The skill automatically loads when you ask questions like:
|
||||
|
||||
- "How do I test this ViewModel?"
|
||||
- "Why is my Bitwarden test failing?"
|
||||
- "Write tests for this repository"
|
||||
|
||||
Or when you mention terms like: `BaseViewModelTest`, `BitwardenComposeTest`, `stateEventFlow`, `bufferedMutableSharedFlow`, `FakeDispatcherManager`, `createMockCipher`, `asSuccess`
|
||||
|
||||
## What's Included
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SKILL.md` | Core testing patterns and base class locations |
|
||||
| `references/test-base-classes.md` | Detailed base class documentation |
|
||||
| `references/flow-testing-patterns.md` | Turbine patterns for StateFlow/EventFlow |
|
||||
| `references/critical-gotchas.md` | Anti-patterns and debugging tips |
|
||||
| `examples/viewmodel-test-example.md` | Complete ViewModel test example |
|
||||
| `examples/compose-screen-test-example.md` | Complete Compose screen test |
|
||||
| `examples/repository-test-example.md` | Complete repository test with mocks |
|
||||
|
||||
## Patterns Covered
|
||||
|
||||
1. **BaseViewModelTest** - Automatic dispatcher setup with `stateEventFlow()` helper
|
||||
2. **BitwardenComposeTest** - Pre-configured with all managers and theme
|
||||
3. **BaseServiceTest** - MockWebServer setup for network testing
|
||||
4. **Turbine Flow Testing** - StateFlow (replay) vs EventFlow (no replay)
|
||||
5. **Test Data Builders** - 35+ `createMock*` functions with `number: Int` pattern
|
||||
6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource
|
||||
7. **Result Type Testing** - `.asSuccess()`, `.asFailure()`, `assertCoroutineThrows`
|
||||
|
||||
## Quick Start
|
||||
|
||||
For comprehensive architecture and testing philosophy, see:
|
||||
- `docs/ARCHITECTURE.md`
|
||||
@@ -282,6 +282,10 @@ module/src/testFixtures/kotlin/com/bitwarden/.../
|
||||
└── model/*Util.kt
|
||||
```
|
||||
|
||||
### Test Constants Placement
|
||||
|
||||
Declare test constants as top-level `private const val` at the **bottom** of the file, after the class closing brace. Do NOT use `companion object` for test constants.
|
||||
|
||||
### Test Naming
|
||||
|
||||
- Classes: `*Test.kt`, `*ScreenTest.kt`, `*ViewModelTest.kt`
|
||||
|
||||
23
.github/actions/setup-android-build/action.yml
vendored
23
.github/actions/setup-android-build/action.yml
vendored
@@ -8,27 +8,8 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
|
||||
11
.github/renovate.json
vendored
11
.github/renovate.json
vendored
@@ -3,6 +3,7 @@
|
||||
"extends": [
|
||||
"github>bitwarden/renovate-config"
|
||||
],
|
||||
"labels": ["t:deps"],
|
||||
"ignoreDeps": ["com.bitwarden:sdk-android"],
|
||||
"enabledManagers": [
|
||||
"github-actions",
|
||||
@@ -32,16 +33,6 @@
|
||||
"/org.jetbrains.kotlin.*/",
|
||||
"/com.google.devtools.ksp/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "bundler minor",
|
||||
"matchUpdateTypes": [
|
||||
"minor",
|
||||
"patch"
|
||||
],
|
||||
"matchManagers": [
|
||||
"bundler"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
150
.github/scripts/gh_release_update_issues.py
vendored
Normal file
150
.github/scripts/gh_release_update_issues.py
vendored
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
# Requires Python 3.9+
|
||||
"""
|
||||
Comment GitHub issues linked to Pull Requests mentioned in a given release.
|
||||
|
||||
Usage:
|
||||
python gh_release_update_issues.py <release_url> [--dry-run]
|
||||
|
||||
Arguments:
|
||||
release-url: The URL of the release to comment on
|
||||
--dry-run: Run without actually updating issues
|
||||
|
||||
Examples:
|
||||
python gh_release_update_issues.py https://github.com/owner/repo/releases/tag/v1.0.0
|
||||
python gh_release_update_issues.py https://github.com/owner/repo/releases/tag/v1.0.0 --dry-run
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import json
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
from typing import List, Tuple, Dict
|
||||
|
||||
def parse_release_url(release_url: str) -> Tuple[str, str, str]:
|
||||
"""Extract owner, repo name, and tag from a GitHub release URL.
|
||||
|
||||
Returns:
|
||||
Tuple of (owner, repo_name, release_tag)
|
||||
"""
|
||||
match = re.search(r'github\.com/([\w-]+)/([\w.-]+)/releases/tag/(.+)$', release_url)
|
||||
if not match:
|
||||
raise ValueError(f"Cannot parse release URL: {release_url}")
|
||||
return match.group(1), match.group(2), match.group(3)
|
||||
|
||||
def extract_pr_numbers(release_notes: str) -> List[int]:
|
||||
return [int(n) for n in re.findall(r'/pull/(\d+)', release_notes)]
|
||||
|
||||
def build_issue_comment(repo: str, release_name: str, release_link: str, pr_numbers: List[int]) -> str:
|
||||
if len(pr_numbers) == 0:
|
||||
return ""
|
||||
|
||||
pr_links = [f"* https://github.com/{repo}/pull/{pr_number}" for pr_number in pr_numbers]
|
||||
|
||||
return f":shipit: Pull Request(s) linked to this issue released in [{release_name}]({release_link}):\n\n"+ "\n".join(pr_links)
|
||||
|
||||
def gh_fetch_release(repo: str, release_tag: str) -> Tuple[str, str]:
|
||||
result = subprocess.run(
|
||||
['gh', 'release', 'view', release_tag, '--repo', repo, '--json', 'name,body'],
|
||||
capture_output=True, text=True, check=True
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
return data['name'], data['body']
|
||||
|
||||
def gh_comment_issue(repo: str, issue_number: int, comment: str) -> None:
|
||||
"""Use GitHub CLI to comment on an issue.
|
||||
"""
|
||||
subprocess.run([
|
||||
'gh', 'issue', 'comment', str(issue_number), '--body', comment, '--repo', repo
|
||||
], check=True)
|
||||
|
||||
def gh_fetch_linked_issues_batched(owner: str, repo_name: str, pr_numbers: List[int]) -> Dict[int, List[int]]:
|
||||
"""Batch-fetch linked issues for all PRs in a single GraphQL call.
|
||||
|
||||
Returns:
|
||||
Dict mapping each PR number to its list of linked issue numbers.
|
||||
"""
|
||||
if not pr_numbers:
|
||||
return {}
|
||||
|
||||
tmpl = 'pr_%d: pullRequest(number: %d) { closingIssuesReferences(first: 100) { nodes { number } } }'
|
||||
pr_fragments = "\n".join(tmpl % (pr, pr) for pr in pr_numbers)
|
||||
query = """
|
||||
query ($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
%s
|
||||
}
|
||||
}
|
||||
""" % pr_fragments
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
'gh', 'api', 'graphql',
|
||||
'-F', f'owner={owner}',
|
||||
'-F', f'repo={repo_name}',
|
||||
'-f', f'query={query}',
|
||||
],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
repo_data = data['data']['repository']
|
||||
|
||||
pr_issues_map: Dict[int, List[int]] = {}
|
||||
for pr_number in pr_numbers:
|
||||
nodes = repo_data.get(f'pr_{pr_number}', {}).get('closingIssuesReferences', {}).get('nodes', [])
|
||||
pr_issues = [node['number'] for node in nodes]
|
||||
pr_issues_map[pr_number] = pr_issues
|
||||
return pr_issues_map
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"::error::Error batch-fetching linked issues: {e.stderr}")
|
||||
raise
|
||||
|
||||
def map_issues_to_prs(pr_issues_map: Dict[int, List[int]]) -> Dict[int, List[int]]:
|
||||
"""Invert a PR->issues map into an issue->PRs map."""
|
||||
issue_pr_map: Dict[int, List[int]] = defaultdict(list)
|
||||
for pr_number, issue_numbers in pr_issues_map.items():
|
||||
for issue_number in issue_numbers:
|
||||
issue_pr_map[issue_number].append(pr_number)
|
||||
return dict(issue_pr_map)
|
||||
|
||||
def comment_issues(repo: str, issue_pr_map: Dict[int, List[int]], release_name: str, release_url: str, dry_run: bool) -> None:
|
||||
for issue_number, linked_prs in issue_pr_map.items():
|
||||
comment = build_issue_comment(repo, release_name, release_url, linked_prs)
|
||||
print(f"{'Dry run - ' if dry_run else ''}Commenting on issue {issue_number}:\n{comment}\n")
|
||||
if not dry_run and comment:
|
||||
gh_comment_issue(repo, issue_number, comment)
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Comment GitHub issues linked to Pull Requests mentioned in a given release.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'release_url',
|
||||
help='Release URL (e.g. https://github.com/owner/repo/releases/tag/v1.0.0)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Run without actually commenting issues'
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = parse_args()
|
||||
|
||||
owner, repo_name, release_tag = parse_release_url(args.release_url)
|
||||
repo = f"{owner}/{repo_name}"
|
||||
print(f"📋 Release URL: {args.release_url}")
|
||||
|
||||
release_name, release_notes = gh_fetch_release(repo, release_tag)
|
||||
print(f"📋 Release Name: {release_name}")
|
||||
|
||||
pr_numbers = extract_pr_numbers(release_notes)
|
||||
print(f"📋 PR Numbers parsed from release notes: {pr_numbers}")
|
||||
pr_issues_map = gh_fetch_linked_issues_batched(owner, repo_name, pr_numbers)
|
||||
print(f"📋 PRs with linked issues: {[pr for pr, issues in pr_issues_map.items() if issues]}\n")
|
||||
issue_pr_map = map_issues_to_prs(pr_issues_map)
|
||||
comment_issues(repo, issue_pr_map, release_name, args.release_url, args.dry_run)
|
||||
@@ -4,18 +4,13 @@ Fetches release notes from Jira issues.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python dev environment - use [uv](https://github.com/astral-sh/uv)
|
||||
- Jira API token. Generate one at: https://id.atlassian.com/manage-profile/security/api-tokens
|
||||
- Install dependencies:
|
||||
|
||||
```bash
|
||||
uv pip install -r pyproject.toml
|
||||
```
|
||||
- Jira cloud ID. Can be retrieved from the `tenant_info` endpoint, e.g.: `https://<my-site-name>.atlassian.net/_edge/tenant_info`
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
./jira_release_notes.py RELEASE-1762 example@example.com T0k3n123
|
||||
./jira_release_notes.py RELEASE-1762 jira-cloud-id example@example.com T0k3n123
|
||||
```
|
||||
|
||||
# Output Format
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
# Requires Python 3.9+
|
||||
"""Fetch release notes from a Jira issue."""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import requests
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
SCRIPT_NAME = "jira_release_notes.py"
|
||||
SCRIPT_NAME = Path(__file__).name
|
||||
|
||||
def extract_text_from_content(content):
|
||||
if isinstance(content, list):
|
||||
@@ -63,32 +68,43 @@ def parse_release_notes(response_json):
|
||||
print(f"[{SCRIPT_NAME}] Error parsing release notes: {str(e)}", file=sys.stderr)
|
||||
return ''
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 4:
|
||||
print(f"Usage: {sys.argv[0]} <issue_id> <jira_email> <jira_api_token>")
|
||||
sys.exit(1)
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
)
|
||||
parser.add_argument("issue_id", help="RELEASE issue ID to fetch release notes from")
|
||||
parser.add_argument("jira_cloud_id", help="Atlassian Cloud ID - Can be retrieved from the `tenant_info` endpoint, e.g.: `https://<my-site-name>.atlassian.net/_edge/tenant_info`")
|
||||
parser.add_argument("jira_email", help="Email used to create the API token")
|
||||
parser.add_argument("jira_api_token", help="Jira API token - Generate one at: https://id.atlassian.com/manage-profile/security/api-tokens")
|
||||
return parser.parse_args()
|
||||
|
||||
jira_issue_id = sys.argv[1]
|
||||
jira_email = sys.argv[2]
|
||||
jira_api_token = sys.argv[3]
|
||||
jira_base_url = "https://bitwarden.atlassian.net"
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
jira_issue_id = args.issue_id
|
||||
jira_cloud_id = args.jira_cloud_id
|
||||
jira_email = args.jira_email
|
||||
jira_api_token = args.jira_api_token
|
||||
jira_base_url = "https://api.atlassian.com/ex/jira"
|
||||
|
||||
auth = base64.b64encode(f"{jira_email}:{jira_api_token}".encode()).decode()
|
||||
headers = {
|
||||
"Authorization": f"Basic {auth}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
f"{jira_base_url}/rest/api/3/issue/{jira_issue_id}",
|
||||
headers=headers
|
||||
request = Request(
|
||||
f"{jira_base_url}/{jira_cloud_id}/rest/api/3/issue/{jira_issue_id}",
|
||||
headers={
|
||||
"Authorization": f"Basic {auth}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"[{SCRIPT_NAME}] Error fetching Jira issue ({jira_issue_id}). Status code: {response.status_code}. Msg: {response.text}", file=sys.stderr)
|
||||
try:
|
||||
with urlopen(request) as response:
|
||||
response_json = json.loads(response.read().decode())
|
||||
except HTTPError as error:
|
||||
error_text = error.read().decode().replace(jira_cloud_id, "[REDACTED]")
|
||||
print(f"[{SCRIPT_NAME}] Error fetching Jira issue ({jira_issue_id}). Status code: {error.code}. Msg: {error_text}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
release_notes = parse_release_notes(response.json())
|
||||
release_notes = parse_release_notes(response_json)
|
||||
print(release_notes)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
[project]
|
||||
name = "jira-get-release-notes"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"requests>=2.32.3",
|
||||
]
|
||||
91
.github/scripts/jira-get-release-notes/uv.lock
generated
vendored
91
.github/scripts/jira-get-release-notes/uv.lock
generated
vendored
@@ -1,91 +0,0 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.4.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jira-get-release-notes"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "requests", specifier = ">=2.32.3" }]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" },
|
||||
]
|
||||
23
.github/scripts/set-build-version.sh
vendored
Executable file
23
.github/scripts/set-build-version.sh
vendored
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Runs fastlane setBuildVersionInfo and appends Version Name/Number to GITHUB_STEP_SUMMARY.
|
||||
# Usage: set-build-version.sh <version_code> [version_name] [toml_path]
|
||||
|
||||
VERSION_CODE="${1:?Usage: $0 <version_code> [version_name] [toml_path]}"
|
||||
VERSION_NAME="${2:-}"
|
||||
TOML_FILE="${3:-gradle/libs.versions.toml}"
|
||||
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:"$VERSION_CODE" \
|
||||
versionName:"$VERSION_NAME"
|
||||
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
VERSION_NAME=""
|
||||
regex='appVersionName = "([^"]+)"'
|
||||
if [[ "$(cat "$TOML_FILE")" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
2
.github/workflows/_version.yml
vendored
2
.github/workflows/_version.yml
vendored
@@ -167,7 +167,7 @@ jobs:
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload version info artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: version-info
|
||||
path: version_info.json
|
||||
|
||||
129
.github/workflows/build-authenticator.yml
vendored
129
.github/workflows/build-authenticator.yml
vendored
@@ -31,7 +31,6 @@ on:
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
|
||||
|
||||
@@ -50,70 +49,10 @@ jobs:
|
||||
version_number: ${{ inputs.version-code }}
|
||||
patch_version: ${{ inputs.patch_version && '999' || '' }}
|
||||
|
||||
build:
|
||||
name: Build Authenticator
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Log inputs to job summary
|
||||
uses: bitwarden/android/.github/actions/log-inputs@main
|
||||
with:
|
||||
inputs: "${{ toJson(inputs) }}"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Check Authenticator
|
||||
run: bundle exec fastlane check
|
||||
|
||||
- name: Build Authenticator
|
||||
run: bundle exec fastlane buildAuthenticatorDebug
|
||||
|
||||
publish_playstore:
|
||||
name: Publish Authenticator Play Store artifacts
|
||||
needs:
|
||||
- version
|
||||
- build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -128,16 +67,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
@@ -197,40 +126,15 @@ jobs:
|
||||
- name: AZ Logout
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
|
||||
- name: Verify Play Store credentials
|
||||
if: ${{ env.PUBLISH_TO_PLAY_STORE }}
|
||||
run: |
|
||||
bundle exec fastlane run validate_play_store_json_key \
|
||||
json_key:"${{ github.workspace }}/secrets/authenticator_play_store-creds.json"
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Update app CI Build info
|
||||
run: |
|
||||
./scripts/update_app_ci_build_info.sh \
|
||||
@@ -242,22 +146,9 @@ jobs:
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
DEFAULT_VERSION_CODE: ${{ github.run_number }}
|
||||
INPUT_VERSION_CODE: "${{ needs.version.outputs.version_number }}"
|
||||
INPUT_VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: |
|
||||
VERSION_CODE="${INPUT_VERSION_CODE:-$DEFAULT_VERSION_CODE}"
|
||||
VERSION_NAME_INPUT="${INPUT_VERSION_NAME:-}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:"$VERSION_CODE" \
|
||||
versionName:"$VERSION_NAME_INPUT"
|
||||
|
||||
regex='appVersionName = "([^"]+)"'
|
||||
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
|
||||
|
||||
- name: Generate release Play Store bundle
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
@@ -285,7 +176,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.bitwarden.authenticator.aab
|
||||
path: authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab
|
||||
@@ -293,7 +184,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.bitwarden.authenticator.apk
|
||||
path: authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk
|
||||
@@ -313,7 +204,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: authenticator-android-apk-sha256.txt
|
||||
path: ./authenticator-android-apk-sha256.txt
|
||||
@@ -321,7 +212,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: authenticator-android-aab-sha256.txt
|
||||
path: ./authenticator-android-aab-sha256.txt
|
||||
|
||||
64
.github/workflows/build-testharness.yml
vendored
64
.github/workflows/build-testharness.yml
vendored
@@ -20,7 +20,6 @@ on:
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -53,69 +52,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
DEFAULT_VERSION_CODE: ${{ github.run_number }}
|
||||
INPUT_VERSION_CODE: "${{ needs.version.outputs.version_number }}"
|
||||
INPUT_VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: |
|
||||
VERSION_CODE="${INPUT_VERSION_CODE:-$DEFAULT_VERSION_CODE}"
|
||||
VERSION_NAME_INPUT="${INPUT_VERSION_NAME:-}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:"$VERSION_CODE" \
|
||||
versionName:"$VERSION_NAME_INPUT"
|
||||
|
||||
regex='appVersionName = "(.+)"'
|
||||
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
|
||||
|
||||
- name: Build Test Harness Debug APK
|
||||
run: ./gradlew :testharness:assembleDebug
|
||||
|
||||
- name: Upload Test Harness APK
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.bitwarden.testharness.dev-debug.apk
|
||||
path: testharness/build/outputs/apk/debug/com.bitwarden.testharness.dev.apk
|
||||
@@ -127,7 +77,7 @@ jobs:
|
||||
> ./com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
|
||||
- name: Upload Test Harness SHA file
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
path: ./com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
|
||||
198
.github/workflows/build.yml
vendored
198
.github/workflows/build.yml
vendored
@@ -31,7 +31,6 @@ on:
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
|
||||
@@ -52,77 +51,10 @@ jobs:
|
||||
version_number: ${{ inputs.version-code }}
|
||||
patch_version: ${{ inputs.patch_version && '999' || '' }}
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Log inputs to job summary
|
||||
uses: bitwarden/android/.github/actions/log-inputs@main
|
||||
with:
|
||||
inputs: "${{ toJson(inputs) }}"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Check
|
||||
run: bundle exec fastlane check
|
||||
|
||||
- name: Build
|
||||
run: bundle exec fastlane assembleDebugApks
|
||||
|
||||
- name: Upload test reports on failure
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: failure()
|
||||
with:
|
||||
name: test-reports
|
||||
path: app/build/reports/tests/
|
||||
|
||||
publish_playstore:
|
||||
name: Publish Play Store artifacts
|
||||
needs:
|
||||
- version
|
||||
- build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -137,16 +69,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
@@ -199,33 +121,8 @@ jobs:
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
|
||||
- name: Update app CI Build info
|
||||
run: |
|
||||
@@ -238,13 +135,9 @@ jobs:
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number }}
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: |
|
||||
VERSION_CODE="${VERSION_CODE:-$GITHUB_RUN_NUMBER}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:$VERSION_CODE \
|
||||
versionName:$VERSION_NAME
|
||||
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
|
||||
|
||||
- name: Generate release Play Store bundle
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' }}
|
||||
@@ -299,7 +192,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.aab
|
||||
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab
|
||||
@@ -307,7 +200,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.aab
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.aab
|
||||
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab
|
||||
@@ -315,7 +208,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.apk
|
||||
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk
|
||||
@@ -323,7 +216,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.apk
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.apk
|
||||
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden.beta.apk
|
||||
@@ -332,7 +225,7 @@ jobs:
|
||||
# When building variants other than 'prod'
|
||||
- name: Upload to GitHub Artifacts - dev.apk
|
||||
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
|
||||
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk
|
||||
@@ -370,7 +263,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.apk-sha256.txt
|
||||
@@ -378,7 +271,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.apk-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
|
||||
@@ -386,7 +279,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.aab-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.aab-sha256.txt
|
||||
@@ -394,7 +287,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.aab-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.aab-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
|
||||
@@ -402,7 +295,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - debug.apk-sha256.txt
|
||||
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
|
||||
@@ -445,7 +338,6 @@ jobs:
|
||||
name: Publish F-Droid artifacts
|
||||
needs:
|
||||
- version
|
||||
- build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -455,16 +347,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
@@ -503,33 +385,8 @@ jobs:
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
|
||||
- name: Update app CI Build info
|
||||
run: |
|
||||
@@ -542,20 +399,9 @@ jobs:
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number }}
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: |
|
||||
VERSION_CODE="${VERSION_CODE:-$GITHUB_RUN_NUMBER}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:$VERSION_CODE \
|
||||
versionName:$VERSION_NAME
|
||||
|
||||
regex='appVersionName = "([^"]+)"'
|
||||
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
|
||||
- name: Generate F-Droid artifacts
|
||||
env:
|
||||
FDROID_STORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-KEYSTORE-PASSWORD }}
|
||||
@@ -578,7 +424,7 @@ jobs:
|
||||
keyPassword:$FDROID_BETA_KEY_PASSWORD
|
||||
|
||||
- name: Upload to GitHub Artifacts - fdroid.apk
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden-fdroid.apk
|
||||
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk
|
||||
@@ -590,14 +436,14 @@ jobs:
|
||||
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
|
||||
|
||||
- name: Upload to GitHub Artifacts - fdroid.apk-sha256.txt
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden-fdroid.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.fdroid.apk
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta-fdroid.apk
|
||||
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk
|
||||
@@ -609,7 +455,7 @@ jobs:
|
||||
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.fdroid.apk-sha256.txt
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
|
||||
10
.github/workflows/crowdin-pull.yml
vendored
10
.github/workflows/crowdin-pull.yml
vendored
@@ -40,14 +40,14 @@ jobs:
|
||||
id: retrieve-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "crowdin-api-token, github-gpg-private-key, github-gpg-private-key-passphrase"
|
||||
keyvault: "gh-android"
|
||||
secrets: "CROWDIN-API-TOKEN"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
|
||||
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.CROWDIN-API-TOKEN }}
|
||||
_CROWDIN_PROJECT_ID: "269690"
|
||||
with:
|
||||
config: crowdin.yml
|
||||
@@ -74,5 +74,3 @@ jobs:
|
||||
pull_request_title: "Crowdin Pull"
|
||||
pull_request_body: ":inbox_tray: New translations received!"
|
||||
pull_request_labels: "automated-pr, t:misc"
|
||||
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
|
||||
gpg_passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
|
||||
|
||||
6
.github/workflows/crowdin-push.yml
vendored
6
.github/workflows/crowdin-push.yml
vendored
@@ -31,14 +31,14 @@ jobs:
|
||||
id: retrieve-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "crowdin-api-token"
|
||||
keyvault: "gh-android"
|
||||
secrets: "CROWDIN-API-TOKEN"
|
||||
|
||||
- name: Upload sources
|
||||
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
|
||||
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.CROWDIN-API-TOKEN }}
|
||||
_CROWDIN_PROJECT_ID: "269690"
|
||||
with:
|
||||
config: crowdin.yml
|
||||
|
||||
5
.github/workflows/github-release.yml
vendored
5
.github/workflows/github-release.yml
vendored
@@ -168,7 +168,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-android
|
||||
secrets: "JIRA-API-EMAIL,JIRA-API-TOKEN"
|
||||
secrets: "JIRA-API-EMAIL,JIRA-API-TOKEN,JIRA-CLOUD-ID"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
@@ -180,13 +180,14 @@ jobs:
|
||||
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
|
||||
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
|
||||
_RELEASE_TICKET_ID: ${{ inputs.release-ticket-id }}
|
||||
_JIRA_CLOUD_ID: ${{ steps.get-kv-secrets.outputs.JIRA-CLOUD-ID }}
|
||||
_JIRA_API_EMAIL: ${{ steps.get-kv-secrets.outputs.JIRA-API-EMAIL }}
|
||||
_JIRA_API_TOKEN: ${{ steps.get-kv-secrets.outputs.JIRA-API-TOKEN }}
|
||||
run: |
|
||||
echo "Getting product release notes..."
|
||||
# capture output and exit code so this step continues even if we can't retrieve release notes.
|
||||
script_exit_code=0
|
||||
product_release_notes=$(python .github/scripts/jira-get-release-notes/jira_release_notes.py "$_RELEASE_TICKET_ID" "$_JIRA_API_EMAIL" "$_JIRA_API_TOKEN") || script_exit_code=$?
|
||||
product_release_notes=$(python3 .github/scripts/jira-get-release-notes/jira_release_notes.py "$_RELEASE_TICKET_ID" "$_JIRA_CLOUD_ID" "$_JIRA_API_EMAIL" "$_JIRA_API_TOKEN") || script_exit_code=$?
|
||||
echo "--------------------------------"
|
||||
|
||||
if [[ $script_exit_code -ne 0 || -z "$product_release_notes" ]]; then
|
||||
|
||||
37
.github/workflows/sdlc-gh-release-update-issue.yml
vendored
Normal file
37
.github/workflows/sdlc-gh-release-update-issue.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: SDLC / Update Linked Issues on Release
|
||||
run-name: ${{ inputs.dry-run && '(Dry Run) ' || '' }}Update Linked Issues on Release - ${{ github.event.release.name || inputs.release_url }}
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_url:
|
||||
description: 'Release URL (e.g. https://github.com/owner/repo/releases/tag/v1.0.0)'
|
||||
required: true
|
||||
dry-run:
|
||||
description: 'Dry run'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
update-linked-issues:
|
||||
name: Update Linked Issues
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Update Linked Issues
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
_RELEASE_URL: ${{ github.event.release.html_url || inputs.release_url }}
|
||||
_DRY_RUN: ${{ inputs.dry-run && '--dry-run' || '' }}
|
||||
run: |
|
||||
python3 .github/scripts/gh_release_update_issues.py "$_RELEASE_URL" $_DRY_RUN
|
||||
2
.github/workflows/sdlc-sdk-update.yml
vendored
2
.github/workflows/sdlc-sdk-update.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
|
||||
178
.github/workflows/test.yml
vendored
178
.github/workflows/test.yml
vendored
@@ -3,9 +3,8 @@ name: Test
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "rc"
|
||||
- "hotfix-rc"
|
||||
- main
|
||||
- release/**/*
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
merge_group:
|
||||
@@ -13,16 +12,45 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
_JAVA_VERSION: 21
|
||||
_GITHUB_ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
test-sharded:
|
||||
name: "Test ${{ matrix.group }}"
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
packages: read
|
||||
pull-requests: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- group: static-analysis
|
||||
fastlane_method: checkLint
|
||||
fastlane_options: ""
|
||||
# App shards
|
||||
- group: app-data
|
||||
fastlane_method: testAppShard
|
||||
fastlane_options: "--tests com.x8bit.bitwarden.data.*"
|
||||
- group: app-ui-auth-tools
|
||||
fastlane_method: testAppShard
|
||||
fastlane_options: "--tests com.x8bit.bitwarden.ui.auth.* --tests com.x8bit.bitwarden.ui.tools.* --tests com.x8bit.bitwarden.ui.autofill.* --tests com.x8bit.bitwarden.ui.credentials.*"
|
||||
- group: app-ui-platform
|
||||
fastlane_method: testAppShard
|
||||
fastlane_options: "--tests com.x8bit.bitwarden.ui.platform.*"
|
||||
- group: app-ui-vault
|
||||
fastlane_method: testAppShard
|
||||
fastlane_options: "--tests com.x8bit.bitwarden.ui.vault.*"
|
||||
# Authenticator
|
||||
- group: authenticator
|
||||
fastlane_method: testLibraries
|
||||
fastlane_options: ":authenticator"
|
||||
# Library shards
|
||||
- group: lib-core-network-bridge
|
||||
fastlane_method: testLibraries
|
||||
fastlane_options: ":core :network :cxf :authenticatorbridge :testharness"
|
||||
- group: lib-data-ui
|
||||
fastlane_method: testLibraries
|
||||
fastlane_options: ":data :ui"
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
@@ -30,87 +58,101 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env._JAVA_VERSION }}
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Build and test
|
||||
- name: Run tests
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Used in settings.gradle.kts to download the SDK from GitHub Maven Packages
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
_GROUP: ${{ matrix.group }}
|
||||
_FASTLANE_METHOD: ${{ matrix.fastlane_method }}
|
||||
_FASTLANE_OPTIONS: ${{ matrix.fastlane_options }}
|
||||
run: |
|
||||
bundle exec fastlane check
|
||||
if [ "$_GROUP" = "app-ui-auth-tools" ]; then
|
||||
_TOP_LEVEL_TESTS=$(basename -a -s .kt app/src/test/kotlin/com/x8bit/bitwarden/*Test.kt \
|
||||
| xargs -I{} printf ' --tests com.x8bit.bitwarden.{}')
|
||||
_FASTLANE_OPTIONS="${_FASTLANE_OPTIONS} ${_TOP_LEVEL_TESTS}"
|
||||
fi
|
||||
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: test-reports
|
||||
path: |
|
||||
build/reports/kover/reportMergedCoverage.xml
|
||||
app/build/reports/tests/
|
||||
authenticator/build/reports/tests/
|
||||
authenticatorbridge/build/reports/tests/
|
||||
core/build/reports/tests/
|
||||
data/build/reports/tests/
|
||||
network/build/reports/tests/
|
||||
ui/build/reports/tests/
|
||||
if [ "$_GROUP" = "static-analysis" ]; then
|
||||
bundle exec fastlane "$_FASTLANE_METHOD"
|
||||
else
|
||||
bundle exec fastlane "$_FASTLANE_METHOD" target:"$_FASTLANE_OPTIONS"
|
||||
fi
|
||||
|
||||
- name: Generate coverage report
|
||||
if: always() && matrix.group != 'static-analysis' && (github.event_name == 'push' || github.event_name == 'pull_request')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
bundle exec fastlane generateCoverageReport
|
||||
|
||||
- name: Upload to codecov.io
|
||||
id: upload-to-codecov
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
if: always() && matrix.group != 'static-analysis' && (github.event_name == 'push' || github.event_name == 'pull_request')
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
os: linux
|
||||
files: build/reports/kover/reportMergedCoverage.xml
|
||||
flags: ${{ matrix.group }}
|
||||
fail_ci_if_error: true
|
||||
disable_search: true
|
||||
|
||||
- name: Comment PR if tests failed
|
||||
if: steps.upload-to-codecov.outcome == 'failure' && (github.event_name == 'push' || github.event_name == 'pull_request')
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: test-reports-${{ matrix.group }}
|
||||
path: |
|
||||
**/build/reports/tests/
|
||||
app/build/reports/lint-results-*.html
|
||||
app/build/reports/detekt/
|
||||
if-no-files-found: warn
|
||||
|
||||
coverage-notify:
|
||||
name: Coverage Notification
|
||||
runs-on: ubuntu-24.04
|
||||
needs: test-sharded
|
||||
if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'pull_request')
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Notify Codecov that all uploads are complete
|
||||
id: codecov-notify
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
run_command: send-notifications
|
||||
|
||||
- name: Comment PR if coverage notification failed
|
||||
if: steps.codecov-notify.outcome == 'failure'
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RUN_ACTOR: ${{ github.triggering_actor }}
|
||||
run: |
|
||||
echo "> [!WARNING]" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "> Uploading code coverage report failed. Please check the \"Upload to codecov.io\" step of \"Process Test Reports\" job for more details." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "> Uploading code coverage report failed. Please check the \"Notify Codecov\" step for more details." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
message=$'> [!WARNING]\n> @'$RUN_ACTOR' Uploading code coverage report failed. Please check the "Upload to codecov.io" step of [Process Test Reports job]('$_GITHUB_ACTION_RUN_URL') for more details.'
|
||||
message=$'> [!WARNING]\n> @'$RUN_ACTOR' Uploading code coverage report failed. Please check the "Coverage Notification" step of [Test]('$_GITHUB_ACTION_RUN_URL') for more details.'
|
||||
gh pr comment --repo "$GITHUB_REPOSITORY" "$PR_NUMBER" --body "$message"
|
||||
fi
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-24.04
|
||||
permissions: {}
|
||||
needs: test-sharded
|
||||
if: always()
|
||||
steps:
|
||||
- name: Ensure sharded tests passed
|
||||
env:
|
||||
TESTS_RESULT: ${{ needs.test-sharded.result }}
|
||||
run: |
|
||||
if [ "$TESTS_RESULT" != "success" ]; then
|
||||
echo "❌ Tests failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All tests passed!"
|
||||
|
||||
9
.mcp.json
Normal file
9
.mcp.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"android-device": {
|
||||
"type": "stdio",
|
||||
"command": "bash",
|
||||
"args": ["-c", "cd .claude/mcp/android-device-server && npm install --silent >/dev/null 2>&1 && npm run build >/dev/null 2>&1 && exec node build/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
18
Gemfile
18
Gemfile
@@ -1,21 +1,21 @@
|
||||
source "https://rubygems.org"
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby File.read(".ruby-version").strip
|
||||
|
||||
gem 'fastlane'
|
||||
gem 'time'
|
||||
gem 'fastlane', '2.229.1'
|
||||
gem 'time', '0.4.2'
|
||||
|
||||
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
|
||||
eval_gemfile(plugins_path) if File.exist?(plugins_path)
|
||||
|
||||
# Since ruby 3.4.0 these are not included in the standard library
|
||||
gem 'abbrev'
|
||||
gem 'logger'
|
||||
gem 'mutex_m'
|
||||
gem 'csv'
|
||||
gem 'abbrev', '0.1.2'
|
||||
gem 'logger', '1.7.0'
|
||||
gem 'mutex_m', '0.3.0'
|
||||
gem 'csv', '3.3.5'
|
||||
|
||||
# Since ruby 3.4.1 these are not included in the standard library
|
||||
gem 'nkf'
|
||||
gem 'nkf', '0.2.0'
|
||||
|
||||
# Starting with Ruby 3.5.0, these are not included in the standard library
|
||||
gem 'ostruct'
|
||||
gem 'ostruct', '0.6.3'
|
||||
|
||||
58
Gemfile.lock
58
Gemfile.lock
@@ -3,13 +3,13 @@ GEM
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.8.8)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1213.0)
|
||||
aws-sdk-core (3.242.0)
|
||||
aws-partitions (1.1246.0)
|
||||
aws-sdk-core (3.246.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -17,18 +17,18 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.121.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-kms (1.124.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.213.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-s3 (1.221.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
bigdecimal (4.0.1)
|
||||
base64 (0.2.0)
|
||||
bigdecimal (4.1.2)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
@@ -68,17 +68,18 @@ GEM
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday-retry (1.0.4)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.229.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.229.1)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
@@ -104,6 +105,7 @@ GEM
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3.0)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2.0)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
@@ -120,8 +122,7 @@ GEM
|
||||
fastlane-plugin-firebase_app_distribution (0.10.1)
|
||||
google-apis-firebaseappdistribution_v1 (~> 0.3.0)
|
||||
google-apis-firebaseappdistribution_v1alpha (~> 0.2.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
@@ -148,7 +149,7 @@ GEM
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.5.0)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.47.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
@@ -169,13 +170,13 @@ GEM
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.18.1)
|
||||
json (2.19.5)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.19.1)
|
||||
multi_json (1.21.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
@@ -185,13 +186,13 @@ GEM
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (7.0.2)
|
||||
rake (13.3.1)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
retriable (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
@@ -205,7 +206,6 @@ GEM
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
@@ -235,15 +235,15 @@ PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
abbrev
|
||||
csv
|
||||
fastlane
|
||||
abbrev (= 0.1.2)
|
||||
csv (= 3.3.5)
|
||||
fastlane (= 2.229.1)
|
||||
fastlane-plugin-firebase_app_distribution
|
||||
logger
|
||||
mutex_m
|
||||
nkf
|
||||
ostruct
|
||||
time
|
||||
logger (= 1.7.0)
|
||||
mutex_m (= 0.3.0)
|
||||
nkf (= 0.2.0)
|
||||
ostruct (= 0.6.3)
|
||||
time (= 0.4.2)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.2p28
|
||||
|
||||
@@ -294,9 +294,11 @@ dependencies {
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
// Standard-specific flavor dependencies
|
||||
standardImplementation(libs.google.firebase.cloud.messaging)
|
||||
standardImplementation(libs.google.billing)
|
||||
standardImplementation(platform(libs.google.firebase.bom))
|
||||
standardImplementation(libs.google.firebase.cloud.messaging)
|
||||
standardImplementation(libs.google.firebase.crashlytics)
|
||||
standardImplementation(libs.google.mlkit.text.recognition)
|
||||
standardImplementation(libs.google.play.review)
|
||||
|
||||
// Pull in test fixtures from other modules
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.bitwarden.ui.platform.feature.cardscanner.util
|
||||
|
||||
import androidx.camera.core.ImageProxy
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
|
||||
/**
|
||||
* No-op [CardTextAnalyzer] for the F-Droid build flavor.
|
||||
*
|
||||
* Google ML Kit is not permitted in F-Droid releases, so this stub replaces the
|
||||
* standard analyzer at build time. The Scan Card UI is hidden via
|
||||
* `BuildInfoManager.isFdroid`; this implementation exists solely to satisfy the
|
||||
* flavor-uniform construction path used by `LocalManagerProvider`. The
|
||||
* `cardDataParser` argument is unused, retained so the constructor signature
|
||||
* matches the standard flavor and call sites remain identical.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@Suppress("UnusedParameter")
|
||||
class CardTextAnalyzerImpl(
|
||||
cardDataParser: CardDataParser,
|
||||
) : CardTextAnalyzer {
|
||||
|
||||
override lateinit var onCardScanned: (CardScanData) -> Unit
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.x8bit.bitwarden.data.billing.manager
|
||||
|
||||
import android.content.Context
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* F-Droid implementation of [PlayBillingManager]. Always returns `true` since
|
||||
* F-Droid users are eligible for the Premium upgrade flow.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@Suppress("UnusedParameter")
|
||||
class PlayBillingManagerImpl(
|
||||
context: Context,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : PlayBillingManager {
|
||||
|
||||
override val isInAppBillingSupportedFlow: StateFlow<Boolean> =
|
||||
MutableStateFlow(true)
|
||||
}
|
||||
@@ -40,6 +40,18 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "org.calyxos.chromium",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "CB:33:EE:73:84:2F:2F:BD:C3:E3:52:5F:D1:C3:74:07:41:82:6F:33:84:9B:C9:6F:95:4D:76:18:17:D3:00:EB"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
|
||||
@@ -828,6 +828,22 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "ai.perplexity.comet",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "89:58:A4:05:40:1F:69:F5:B0:FB:54:44:24:74:6C:40:DE:C3:0C:09:1F:40:1F:95:1F:61:3C:48:35:C3:E5:EC"
|
||||
},
|
||||
{
|
||||
"build": "userdebug",
|
||||
"cert_fingerprint_sha256": "68:75:3A:54:59:93:C1:34:D3:BD:A3:72:2A:30:53:BF:4D:48:AD:23:63:2C:4E:27:8B:B3:BF:C1:FB:F6:52:8C"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.navigation.compose.NavHost
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.bitwarden.ui.platform.util.setHorizonOSAppLayout
|
||||
import com.bitwarden.ui.platform.util.setupEdgeToEdge
|
||||
import com.bitwarden.ui.platform.util.validate
|
||||
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManager
|
||||
@@ -88,6 +89,10 @@ class MainActivity : AppCompatActivity() {
|
||||
mainViewModel.trySendAction(MainAction.CookieAcquisitionResult(it))
|
||||
}
|
||||
|
||||
private val premiumCheckoutLauncher = AuthTabIntent.registerActivityResultLauncher(this) {
|
||||
mainViewModel.trySendAction(MainAction.PremiumCheckoutResult(it))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
intent = intent.validate()
|
||||
var shouldShowSplashScreen = true
|
||||
@@ -115,6 +120,7 @@ class MainActivity : AppCompatActivity() {
|
||||
sso = ssoLauncher,
|
||||
webAuthn = webAuthnLauncher,
|
||||
cookie = cookieLauncher,
|
||||
premiumCheckout = premiumCheckoutLauncher,
|
||||
),
|
||||
) {
|
||||
ObserveScreenDataEffect(
|
||||
@@ -207,6 +213,16 @@ class MainActivity : AppCompatActivity() {
|
||||
.takeIf { it }
|
||||
?: super.dispatchKeyEvent(event)
|
||||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
super.onPostCreate(savedInstanceState)
|
||||
// resize only one time at the start
|
||||
if (!mainViewModel.stateFlow.value.hasResizeBeenRequested) {
|
||||
setHorizonOSAppLayout {
|
||||
mainViewModel.trySendAction(MainAction.Internal.ResizeHasBeenRequested)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupEventsEffect(navController: NavController) {
|
||||
EventsEffect(viewModel = mainViewModel) { event ->
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
|
||||
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
|
||||
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
|
||||
import com.x8bit.bitwarden.data.billing.util.getPremiumCheckoutCallbackResult
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
|
||||
@@ -46,6 +47,7 @@ import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
|
||||
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
|
||||
import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
|
||||
import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
|
||||
import com.x8bit.bitwarden.ui.platform.util.isPremiumCheckoutCallback
|
||||
import com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -96,6 +98,7 @@ class MainViewModel @Inject constructor(
|
||||
theme = settingsRepository.appTheme,
|
||||
isScreenCaptureAllowed = settingsRepository.isScreenCaptureAllowed,
|
||||
isDynamicColorsEnabled = settingsRepository.isDynamicColorsEnabled,
|
||||
hasResizeBeenRequested = false,
|
||||
),
|
||||
) {
|
||||
private var specialCircumstance: SpecialCircumstance?
|
||||
@@ -197,6 +200,7 @@ class MainViewModel @Inject constructor(
|
||||
is MainAction.SsoResult -> handleSsoResult(action)
|
||||
is MainAction.WebAuthnResult -> handleWebAuthnResult(action)
|
||||
is MainAction.CookieAcquisitionResult -> handleCookieAcquisitionResult(action)
|
||||
is MainAction.PremiumCheckoutResult -> handlePremiumCheckoutResult(action)
|
||||
is MainAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
@@ -219,6 +223,7 @@ class MainViewModel @Inject constructor(
|
||||
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
|
||||
is MainAction.Internal.DynamicColorsUpdate -> handleDynamicColorsUpdate(action)
|
||||
is MainAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
|
||||
is MainAction.Internal.ResizeHasBeenRequested -> handleResizeHasBeenRequested()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +251,12 @@ class MainViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun handlePremiumCheckoutResult(action: MainAction.PremiumCheckoutResult) {
|
||||
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.PremiumCheckout(
|
||||
callbackResult = action.authResult.getPremiumCheckoutCallbackResult(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
|
||||
when (val data = action.screenResumeData) {
|
||||
null -> appResumeManager.clearResumeScreen()
|
||||
@@ -293,6 +304,10 @@ class MainViewModel @Inject constructor(
|
||||
sendEvent(MainEvent.NavigateToCookieAcquisition)
|
||||
}
|
||||
|
||||
private fun handleResizeHasBeenRequested() {
|
||||
mutableStateFlow.update { it.copy(hasResizeBeenRequested = true) }
|
||||
}
|
||||
|
||||
private fun handleFirstIntentReceived(action: MainAction.ReceiveFirstIntent) {
|
||||
handleIntent(
|
||||
intent = action.intent,
|
||||
@@ -333,6 +348,7 @@ class MainViewModel @Inject constructor(
|
||||
val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
|
||||
val hasVaultShortcut = intent.isMyVaultShortcut
|
||||
val hasAccountSecurityShortcut = intent.isAccountSecurityShortcut
|
||||
val hasPremiumCheckoutCallback = intent.isPremiumCheckoutCallback
|
||||
val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull()
|
||||
val importCredentialsRequest = intent.getProviderImportCredentialsRequest()
|
||||
val credentialProviderRequest =
|
||||
@@ -394,6 +410,13 @@ class MainViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
hasPremiumCheckoutCallback -> {
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.PremiumCheckout(
|
||||
callbackResult = intent.data.getPremiumCheckoutCallbackResult(),
|
||||
)
|
||||
}
|
||||
|
||||
hasGeneratorShortcut -> {
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.GeneratorShortcut
|
||||
@@ -514,6 +537,7 @@ data class MainState(
|
||||
val theme: AppTheme,
|
||||
val isScreenCaptureAllowed: Boolean,
|
||||
val isDynamicColorsEnabled: Boolean,
|
||||
val hasResizeBeenRequested: Boolean,
|
||||
) : Parcelable {
|
||||
/**
|
||||
* Contains all feature flags that are available to the UI.
|
||||
@@ -548,6 +572,13 @@ sealed class MainAction {
|
||||
val cookieCallbackResult: AuthTabIntent.AuthResult,
|
||||
) : MainAction()
|
||||
|
||||
/**
|
||||
* Receive the result from the premium checkout flow.
|
||||
*/
|
||||
data class PremiumCheckoutResult(
|
||||
val authResult: AuthTabIntent.AuthResult,
|
||||
) : MainAction()
|
||||
|
||||
/**
|
||||
* Receive first Intent by the application.
|
||||
*/
|
||||
@@ -624,6 +655,11 @@ sealed class MainAction {
|
||||
* should proceed.
|
||||
*/
|
||||
data object CookieAcquisitionReady : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that resize has been requested on the Activity
|
||||
*/
|
||||
data object ResizeHasBeenRequested : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,16 @@ interface AuthDiskSource : AppIdProvider {
|
||||
*/
|
||||
fun storeUserKey(userId: String, userKey: String?)
|
||||
|
||||
/**
|
||||
* Retrieves the local user data key for the given [userId].
|
||||
*/
|
||||
fun getLocalUserDataKey(userId: String): String?
|
||||
|
||||
/**
|
||||
* Stores the local user data key for a given [userId].
|
||||
*/
|
||||
fun storeLocalUserDataKey(userId: String, wrappedKey: String?)
|
||||
|
||||
/**
|
||||
* Retrieves a private key using a [userId].
|
||||
*/
|
||||
|
||||
@@ -35,6 +35,7 @@ private const val REMEMBERED_ORG_IDENTIFIER_KEY = "rememberedOrgIdentifier"
|
||||
private const val STATE_KEY = "state"
|
||||
private const val INVALID_UNLOCK_ATTEMPTS_KEY = "invalidUnlockAttempts"
|
||||
private const val MASTER_KEY_ENCRYPTION_USER_KEY = "masterKeyEncryptedUserKey"
|
||||
private const val LOCAL_USER_DATA_KEY = "localUserDataKey"
|
||||
private const val MASTER_KEY_ENCRYPTION_PRIVATE_KEY = "encPrivateKey"
|
||||
private const val PIN_PROTECTED_USER_KEY_KEY = "pinKeyEncryptedUserKey"
|
||||
private const val PIN_PROTECTED_USER_KEY_KEY_ENVELOPE = "pinKeyEncryptedUserKeyEnvelope"
|
||||
@@ -144,6 +145,7 @@ class AuthDiskSourceImpl(
|
||||
override fun clearData(userId: String) {
|
||||
storeInvalidUnlockAttempts(userId = userId, invalidUnlockAttempts = null)
|
||||
storeUserKey(userId = userId, userKey = null)
|
||||
storeLocalUserDataKey(userId = userId, wrappedKey = null)
|
||||
storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = null)
|
||||
storePrivateKey(userId = userId, privateKey = null)
|
||||
storeAccountKeys(userId = userId, accountKeys = null)
|
||||
@@ -237,6 +239,13 @@ class AuthDiskSourceImpl(
|
||||
)
|
||||
}
|
||||
|
||||
override fun getLocalUserDataKey(userId: String): String? =
|
||||
getString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId))
|
||||
|
||||
override fun storeLocalUserDataKey(userId: String, wrappedKey: String?) {
|
||||
putString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId), value = wrappedKey)
|
||||
}
|
||||
|
||||
@Deprecated("Use getAccountKeys instead.", replaceWith = ReplaceWith("getAccountKeys"))
|
||||
override fun getPrivateKey(userId: String): String? =
|
||||
getString(key = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId))
|
||||
|
||||
@@ -37,12 +37,12 @@ data class AccountJson(
|
||||
*
|
||||
* @property userId The ID of the user.
|
||||
* @property email The user's email address.
|
||||
* @property isEmailVerified Whether or not the user's email is verified.
|
||||
* @property isTwoFactorEnabled If the profile has two factor authentication enabled.
|
||||
* @property isEmailVerified Whether the user's email is verified.
|
||||
* @property isTwoFactorEnabled If the profile has two-factor authentication enabled.
|
||||
* @property name The user's name (if applicable).
|
||||
* @property stamp The account's security stamp (if applicable).
|
||||
* @property organizationId The ID of the associated organization (if applicable).
|
||||
* @property hasPremium True if the user has a premium account.
|
||||
* @property hasPremium True if the user has a Premium account.
|
||||
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
|
||||
* @property forcePasswordResetReason Describes the reason for a forced password reset.
|
||||
* @property kdfType The KDF type.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package com.x8bit.bitwarden.data.auth.datasource.sdk
|
||||
|
||||
import com.bitwarden.auth.JitMasterPasswordRegistrationResponse
|
||||
import com.bitwarden.auth.KeyConnectorRegistrationResult
|
||||
import com.bitwarden.auth.TdeRegistrationResponse
|
||||
import com.bitwarden.auth.UserMasterPasswordRegistrationResponse
|
||||
import com.bitwarden.core.AuthRequestResponse
|
||||
import com.bitwarden.core.KeyConnectorResponse
|
||||
import com.bitwarden.core.MasterPasswordPolicyOptions
|
||||
@@ -12,7 +16,55 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
|
||||
/**
|
||||
* Source of authentication information and functionality from the Bitwarden SDK.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
interface AuthSdkSource {
|
||||
/**
|
||||
* Enrolls the user to master password unlock.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
suspend fun postKeysForJitPasswordRegistration(
|
||||
userId: String,
|
||||
organizationId: String,
|
||||
organizationPublicKey: String,
|
||||
organizationSsoIdentifier: String,
|
||||
salt: String,
|
||||
masterPassword: String,
|
||||
masterPasswordHint: String?,
|
||||
shouldResetPasswordEnroll: Boolean,
|
||||
): Result<JitMasterPasswordRegistrationResponse>
|
||||
|
||||
/**
|
||||
* Enrolls the user to key connector unlock.
|
||||
*/
|
||||
suspend fun postKeysForKeyConnectorRegistration(
|
||||
userId: String,
|
||||
accessToken: String,
|
||||
keyConnectorUrl: String,
|
||||
ssoOrganizationIdentifier: String,
|
||||
): Result<KeyConnectorRegistrationResult>
|
||||
|
||||
/**
|
||||
* Enrolls the user to TDE unlock.
|
||||
*/
|
||||
suspend fun postKeysForTdeRegistration(
|
||||
userId: String,
|
||||
organizationId: String,
|
||||
organizationPublicKey: String,
|
||||
deviceIdentifier: String,
|
||||
shouldTrustDevice: Boolean,
|
||||
): Result<TdeRegistrationResponse>
|
||||
|
||||
/**
|
||||
* Enrolls the user for password unlock.
|
||||
*/
|
||||
suspend fun postKeysForUserPasswordRegistration(
|
||||
email: String,
|
||||
salt: String,
|
||||
masterPassword: String,
|
||||
masterPasswordHint: String?,
|
||||
emailVerificationToken: String,
|
||||
): Result<UserMasterPasswordRegistrationResponse>
|
||||
|
||||
/**
|
||||
* Gets the data needed to create a new auth request.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package com.x8bit.bitwarden.data.auth.datasource.sdk
|
||||
|
||||
import com.bitwarden.auth.JitMasterPasswordRegistrationRequest
|
||||
import com.bitwarden.auth.JitMasterPasswordRegistrationResponse
|
||||
import com.bitwarden.auth.KeyConnectorRegistrationResult
|
||||
import com.bitwarden.auth.TdeRegistrationRequest
|
||||
import com.bitwarden.auth.TdeRegistrationResponse
|
||||
import com.bitwarden.auth.UserMasterPasswordRegistrationRequest
|
||||
import com.bitwarden.auth.UserMasterPasswordRegistrationResponse
|
||||
import com.bitwarden.core.AuthRequestResponse
|
||||
import com.bitwarden.core.FingerprintRequest
|
||||
import com.bitwarden.core.KeyConnectorResponse
|
||||
@@ -19,33 +26,119 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
|
||||
* Primary implementation of [AuthSdkSource] that serves as a convenience wrapper around a
|
||||
* [AuthClient].
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class AuthSdkSourceImpl(
|
||||
sdkClientManager: SdkClientManager,
|
||||
) : BaseSdkSource(sdkClientManager = sdkClientManager),
|
||||
AuthSdkSource {
|
||||
|
||||
override suspend fun postKeysForJitPasswordRegistration(
|
||||
userId: String,
|
||||
organizationId: String,
|
||||
organizationPublicKey: String,
|
||||
organizationSsoIdentifier: String,
|
||||
salt: String,
|
||||
masterPassword: String,
|
||||
masterPasswordHint: String?,
|
||||
shouldResetPasswordEnroll: Boolean,
|
||||
): Result<JitMasterPasswordRegistrationResponse> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.auth()
|
||||
.registration()
|
||||
.postKeysForJitPasswordRegistration(
|
||||
request = JitMasterPasswordRegistrationRequest(
|
||||
orgId = organizationId,
|
||||
orgPublicKey = organizationPublicKey,
|
||||
userId = userId,
|
||||
organizationSsoIdentifier = organizationSsoIdentifier,
|
||||
salt = salt,
|
||||
masterPassword = masterPassword,
|
||||
masterPasswordHint = masterPasswordHint,
|
||||
resetPasswordEnroll = shouldResetPasswordEnroll,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun postKeysForKeyConnectorRegistration(
|
||||
userId: String,
|
||||
accessToken: String,
|
||||
keyConnectorUrl: String,
|
||||
ssoOrganizationIdentifier: String,
|
||||
): Result<KeyConnectorRegistrationResult> = runCatchingWithLogs {
|
||||
useClient(userId = userId, accessToken = accessToken) {
|
||||
auth().registration().postKeysForKeyConnectorRegistration(
|
||||
keyConnectorUrl = keyConnectorUrl,
|
||||
ssoOrgIdentifier = ssoOrganizationIdentifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun postKeysForTdeRegistration(
|
||||
userId: String,
|
||||
organizationId: String,
|
||||
organizationPublicKey: String,
|
||||
deviceIdentifier: String,
|
||||
shouldTrustDevice: Boolean,
|
||||
): Result<TdeRegistrationResponse> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.auth()
|
||||
.registration()
|
||||
.postKeysForTdeRegistration(
|
||||
request = TdeRegistrationRequest(
|
||||
orgId = organizationId,
|
||||
orgPublicKey = organizationPublicKey,
|
||||
userId = userId,
|
||||
deviceIdentifier = deviceIdentifier,
|
||||
trustDevice = shouldTrustDevice,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun postKeysForUserPasswordRegistration(
|
||||
email: String,
|
||||
salt: String,
|
||||
masterPassword: String,
|
||||
masterPasswordHint: String?,
|
||||
emailVerificationToken: String,
|
||||
): Result<UserMasterPasswordRegistrationResponse> = runCatchingWithLogs {
|
||||
useClient {
|
||||
auth().registration().postKeysForUserPasswordRegistration(
|
||||
request = UserMasterPasswordRegistrationRequest(
|
||||
email = email,
|
||||
salt = salt,
|
||||
masterPassword = masterPassword,
|
||||
masterPasswordHint = masterPasswordHint,
|
||||
emailVerificationToken = emailVerificationToken,
|
||||
organizationUserId = null,
|
||||
orgInviteToken = null,
|
||||
orgSponsoredFreeFamilyPlanToken = null,
|
||||
acceptEmergencyAccessInviteToken = null,
|
||||
acceptEmergencyAccessId = null,
|
||||
providerInviteToken = null,
|
||||
providerUserId = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getNewAuthRequest(
|
||||
email: String,
|
||||
): Result<AuthRequestResponse> = runCatchingWithLogs {
|
||||
getClient()
|
||||
.auth()
|
||||
.newAuthRequest(
|
||||
email = email.lowercase(),
|
||||
)
|
||||
useClient { auth().newAuthRequest(email = email.lowercase()) }
|
||||
}
|
||||
|
||||
override suspend fun getUserFingerprint(
|
||||
email: String,
|
||||
publicKey: String,
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
getClient()
|
||||
.platform()
|
||||
.fingerprint(
|
||||
useClient {
|
||||
platform().fingerprint(
|
||||
req = FingerprintRequest(
|
||||
fingerprintMaterial = email.lowercase(),
|
||||
publicKey = publicKey,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun hashPassword(
|
||||
@@ -54,21 +147,19 @@ class AuthSdkSourceImpl(
|
||||
kdf: Kdf,
|
||||
purpose: HashPurpose,
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
getClient()
|
||||
.auth()
|
||||
.hashPassword(
|
||||
useClient {
|
||||
auth().hashPassword(
|
||||
email = email,
|
||||
password = password,
|
||||
kdfParams = kdf,
|
||||
purpose = purpose,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun makeKeyConnectorKeys(): Result<KeyConnectorResponse> =
|
||||
runCatchingWithLogs {
|
||||
getClient()
|
||||
.auth()
|
||||
.makeKeyConnectorKeys()
|
||||
useClient { auth().makeKeyConnectorKeys() }
|
||||
}
|
||||
|
||||
override suspend fun makeRegisterKeys(
|
||||
@@ -76,13 +167,13 @@ class AuthSdkSourceImpl(
|
||||
password: String,
|
||||
kdf: Kdf,
|
||||
): Result<RegisterKeyResponse> = runCatchingWithLogs {
|
||||
getClient()
|
||||
.auth()
|
||||
.makeRegisterKeys(
|
||||
useClient {
|
||||
auth().makeRegisterKeys(
|
||||
email = email,
|
||||
password = password,
|
||||
kdf = kdf,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun makeRegisterTdeKeysAndUnlockVault(
|
||||
@@ -105,15 +196,16 @@ class AuthSdkSourceImpl(
|
||||
password: String,
|
||||
additionalInputs: List<String>,
|
||||
): Result<PasswordStrength> = runCatchingWithLogs {
|
||||
@Suppress("UnsafeCallOnNullableType")
|
||||
getClient()
|
||||
.auth()
|
||||
.passwordStrength(
|
||||
password = password,
|
||||
email = email,
|
||||
additionalInputs = additionalInputs,
|
||||
)
|
||||
.toPasswordStrengthOrNull()!!
|
||||
useClient {
|
||||
@Suppress("UnsafeCallOnNullableType")
|
||||
auth()
|
||||
.passwordStrength(
|
||||
password = password,
|
||||
email = email,
|
||||
additionalInputs = additionalInputs,
|
||||
)
|
||||
.toPasswordStrengthOrNull()!!
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun satisfiesPolicy(
|
||||
@@ -121,12 +213,12 @@ class AuthSdkSourceImpl(
|
||||
passwordStrength: PasswordStrength,
|
||||
policy: MasterPasswordPolicyOptions,
|
||||
): Result<Boolean> = runCatchingWithLogs {
|
||||
getClient()
|
||||
.auth()
|
||||
.satisfiesPolicy(
|
||||
useClient {
|
||||
auth().satisfiesPolicy(
|
||||
password = password,
|
||||
strength = passwordStrength.toUByte(),
|
||||
policy = policy,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package com.x8bit.bitwarden.data.auth.manager
|
||||
|
||||
import com.bitwarden.core.KeyConnectorResponse
|
||||
import com.bitwarden.crypto.Kdf
|
||||
import com.bitwarden.network.model.AccountKeysJson
|
||||
import com.bitwarden.network.model.KdfTypeJson
|
||||
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.MigrateNewUserToKeyConnectorResult
|
||||
|
||||
/**
|
||||
* Manager used to interface with a key connector.
|
||||
@@ -36,6 +37,8 @@ interface KeyConnectorManager {
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
suspend fun migrateNewUserToKeyConnector(
|
||||
userId: String,
|
||||
accountKeys: AccountKeysJson?,
|
||||
url: String,
|
||||
accessToken: String,
|
||||
kdfType: KdfTypeJson,
|
||||
@@ -43,5 +46,5 @@ interface KeyConnectorManager {
|
||||
kdfMemory: Int?,
|
||||
kdfParallelism: Int?,
|
||||
organizationIdentifier: String,
|
||||
): Result<KeyConnectorResponse>
|
||||
): Result<MigrateNewUserToKeyConnectorResult>
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
package com.x8bit.bitwarden.data.auth.manager
|
||||
|
||||
import com.bitwarden.core.KeyConnectorResponse
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.manager.model.FlagKey
|
||||
import com.bitwarden.core.data.util.flatMap
|
||||
import com.bitwarden.crypto.Kdf
|
||||
import com.bitwarden.network.model.AccountKeysJson
|
||||
import com.bitwarden.network.model.KdfTypeJson
|
||||
import com.bitwarden.network.model.KeyConnectorKeyRequestJson
|
||||
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
|
||||
import com.bitwarden.network.service.AccountsService
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.MigrateNewUserToKeyConnectorResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.DeriveKeyConnectorResult
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The default implementation of the [KeyConnectorManager].
|
||||
@@ -19,6 +26,8 @@ class KeyConnectorManagerImpl(
|
||||
private val accountsService: AccountsService,
|
||||
private val authSdkSource: AuthSdkSource,
|
||||
private val vaultSdkSource: VaultSdkSource,
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
) : KeyConnectorManager {
|
||||
override suspend fun getMasterKeyFromKeyConnector(
|
||||
url: String,
|
||||
@@ -77,6 +86,8 @@ class KeyConnectorManagerImpl(
|
||||
}
|
||||
|
||||
override suspend fun migrateNewUserToKeyConnector(
|
||||
userId: String,
|
||||
accountKeys: AccountKeysJson?,
|
||||
url: String,
|
||||
accessToken: String,
|
||||
kdfType: KdfTypeJson,
|
||||
@@ -84,7 +95,52 @@ class KeyConnectorManagerImpl(
|
||||
kdfMemory: Int?,
|
||||
kdfParallelism: Int?,
|
||||
organizationIdentifier: String,
|
||||
): Result<KeyConnectorResponse> =
|
||||
): Result<MigrateNewUserToKeyConnectorResult> =
|
||||
if (featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionKeyConnector)) {
|
||||
withContext(dispatcherManager.io) {
|
||||
authSdkSource
|
||||
.postKeysForKeyConnectorRegistration(
|
||||
userId = userId,
|
||||
accessToken = accessToken,
|
||||
keyConnectorUrl = url,
|
||||
ssoOrganizationIdentifier = organizationIdentifier,
|
||||
)
|
||||
.map {
|
||||
MigrateNewUserToKeyConnectorResult(
|
||||
masterKey = it.keyConnectorKey,
|
||||
encryptedUserKey = it.keyConnectorKeyWrappedUserKey,
|
||||
privateKey = when (val state = it.accountCryptographicState) {
|
||||
is WrappedAccountCryptographicState.V1 -> state.privateKey
|
||||
is WrappedAccountCryptographicState.V2 -> state.privateKey
|
||||
},
|
||||
accountCryptographicState = it.accountCryptographicState,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
legacyMigrateNewUserToKeyConnector(
|
||||
accountKeys = accountKeys,
|
||||
url = url,
|
||||
accessToken = accessToken,
|
||||
kdfType = kdfType,
|
||||
kdfIterations = kdfIterations,
|
||||
kdfMemory = kdfMemory,
|
||||
kdfParallelism = kdfParallelism,
|
||||
organizationIdentifier = organizationIdentifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private suspend fun legacyMigrateNewUserToKeyConnector(
|
||||
accountKeys: AccountKeysJson?,
|
||||
url: String,
|
||||
accessToken: String,
|
||||
kdfType: KdfTypeJson,
|
||||
kdfIterations: Int?,
|
||||
kdfMemory: Int?,
|
||||
kdfParallelism: Int?,
|
||||
organizationIdentifier: String,
|
||||
): Result<MigrateNewUserToKeyConnectorResult> =
|
||||
authSdkSource
|
||||
.makeKeyConnectorKeys()
|
||||
.flatMap { keyConnectorResponse ->
|
||||
@@ -111,6 +167,15 @@ class KeyConnectorManagerImpl(
|
||||
),
|
||||
)
|
||||
}
|
||||
.map { keyConnectorResponse }
|
||||
.map {
|
||||
MigrateNewUserToKeyConnectorResult(
|
||||
masterKey = keyConnectorResponse.masterKey,
|
||||
encryptedUserKey = keyConnectorResponse.encryptedUserKey,
|
||||
privateKey = keyConnectorResponse.keys.private,
|
||||
accountCryptographicState = accountKeys.toAccountCryptographicState(
|
||||
privateKey = keyConnectorResponse.keys.private,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ class UserLogoutManagerImpl(
|
||||
val ableToSwitchToNewAccount = switchUserIfAvailable(
|
||||
currentUserId = userId,
|
||||
isSecurityStamp = isSecurityStamp,
|
||||
removeCurrentUserFromAccounts = true,
|
||||
)
|
||||
|
||||
if (!ableToSwitchToNewAccount) {
|
||||
@@ -87,12 +86,6 @@ class UserLogoutManagerImpl(
|
||||
userId = userId,
|
||||
)
|
||||
|
||||
switchUserIfAvailable(
|
||||
currentUserId = userId,
|
||||
removeCurrentUserFromAccounts = false,
|
||||
isSecurityStamp = isSecurityStamp,
|
||||
)
|
||||
|
||||
clearData(userId = userId)
|
||||
mutableLogoutEventFlow.tryEmit(LogoutEvent(loggedOutUserId = userId))
|
||||
|
||||
@@ -135,7 +128,6 @@ class UserLogoutManagerImpl(
|
||||
|
||||
private fun switchUserIfAvailable(
|
||||
currentUserId: String,
|
||||
removeCurrentUserFromAccounts: Boolean,
|
||||
isSecurityStamp: Boolean,
|
||||
): Boolean {
|
||||
val currentUserState = authDiskSource.userState ?: return false
|
||||
@@ -143,8 +135,7 @@ class UserLogoutManagerImpl(
|
||||
val currentAccountsMap = currentUserState.accounts
|
||||
|
||||
// Remove the active user from the accounts map
|
||||
val updatedAccounts = currentAccountsMap
|
||||
.filterKeys { it != currentUserId }
|
||||
val updatedAccounts = currentAccountsMap.filterKeys { it != currentUserId }
|
||||
|
||||
// Check if there is a new active user
|
||||
return if (updatedAccounts.isNotEmpty()) {
|
||||
@@ -163,11 +154,7 @@ class UserLogoutManagerImpl(
|
||||
// Update the user information and emit an updated token
|
||||
authDiskSource.userState = currentUserState.copy(
|
||||
activeUserId = updatedActiveUserId,
|
||||
accounts = if (removeCurrentUserFromAccounts) {
|
||||
updatedAccounts
|
||||
} else {
|
||||
currentAccountsMap
|
||||
},
|
||||
accounts = updatedAccounts,
|
||||
)
|
||||
true
|
||||
} else {
|
||||
|
||||
@@ -89,11 +89,15 @@ object AuthManagerModule {
|
||||
accountsService: AccountsService,
|
||||
authSdkSource: AuthSdkSource,
|
||||
vaultSdkSource: VaultSdkSource,
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): KeyConnectorManager =
|
||||
KeyConnectorManagerImpl(
|
||||
accountsService = accountsService,
|
||||
authSdkSource = authSdkSource,
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
featureFlagManager = featureFlagManager,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.x8bit.bitwarden.data.auth.manager.model
|
||||
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
|
||||
/**
|
||||
* Models result of migrating a new user to key connector.
|
||||
* */
|
||||
data class MigrateNewUserToKeyConnectorResult(
|
||||
val masterKey: String,
|
||||
val encryptedUserKey: String,
|
||||
val privateKey: String,
|
||||
val accountCryptographicState: WrappedAccountCryptographicState,
|
||||
)
|
||||
@@ -136,7 +136,7 @@ interface AuthRepository :
|
||||
val organizations: List<Organization>
|
||||
|
||||
/**
|
||||
* Whether or not the welcome carousel should be displayed, based on the feature flag and
|
||||
* Whether the welcome carousel should be displayed, based on the feature flag and
|
||||
* whether the user has ever logged in or created an account before.
|
||||
*/
|
||||
val showWelcomeCarousel: Boolean
|
||||
@@ -230,7 +230,10 @@ interface AuthRepository :
|
||||
/**
|
||||
* Continue the previously halted login attempt.
|
||||
*/
|
||||
suspend fun continueKeyConnectorLogin(): LoginResult
|
||||
suspend fun continueKeyConnectorLogin(
|
||||
orgIdentifier: String,
|
||||
email: String,
|
||||
): LoginResult
|
||||
|
||||
/**
|
||||
* Cancel the previously halted login attempt.
|
||||
@@ -277,7 +280,7 @@ interface AuthRepository :
|
||||
email: String,
|
||||
masterPassword: String,
|
||||
masterPasswordHint: String?,
|
||||
emailVerificationToken: String? = null,
|
||||
emailVerificationToken: String,
|
||||
shouldCheckDataBreaches: Boolean,
|
||||
isMasterPasswordStrong: Boolean,
|
||||
): RegisterResult
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.core.RegisterTdeKeyResponse
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.manager.model.FlagKey
|
||||
import com.bitwarden.core.data.manager.toast.ToastManager
|
||||
import com.bitwarden.core.data.repository.error.MissingPropertyException
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
@@ -17,17 +18,19 @@ import com.bitwarden.data.datasource.disk.ConfigDiskSource
|
||||
import com.bitwarden.data.repository.util.appLinksScheme
|
||||
import com.bitwarden.data.repository.util.toEnvironmentUrls
|
||||
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
|
||||
import com.bitwarden.network.model.AccountKeysJson
|
||||
import com.bitwarden.network.model.CreateAccountKeysResponseJson
|
||||
import com.bitwarden.network.model.DeleteAccountResponseJson
|
||||
import com.bitwarden.network.model.GetTokenResponseJson
|
||||
import com.bitwarden.network.model.IdentityTokenAuthModel
|
||||
import com.bitwarden.network.model.OrganizationAutoEnrollStatusResponseJson
|
||||
import com.bitwarden.network.model.OrganizationKeysResponseJson
|
||||
import com.bitwarden.network.model.OrganizationType
|
||||
import com.bitwarden.network.model.PasswordHintResponseJson
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.network.model.PrevalidateSsoResponseJson
|
||||
import com.bitwarden.network.model.RefreshTokenResponseJson
|
||||
import com.bitwarden.network.model.RegisterFinishRequestJson
|
||||
import com.bitwarden.network.model.RegisterRequestJson
|
||||
import com.bitwarden.network.model.RegisterResponseJson
|
||||
import com.bitwarden.network.model.ResendEmailRequestJson
|
||||
import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson
|
||||
@@ -99,8 +102,11 @@ import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.accountKeysJson
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.privateKey
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
||||
@@ -113,6 +119,7 @@ import com.x8bit.bitwarden.data.auth.util.toSdkParams
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.LogsManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PushManager
|
||||
@@ -123,7 +130,6 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -144,6 +150,7 @@ import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.time.Clock
|
||||
import javax.inject.Singleton
|
||||
@@ -177,9 +184,10 @@ class AuthRepositoryImpl(
|
||||
private val userStateManager: UserStateManager,
|
||||
private val kdfManager: KdfManager,
|
||||
private val toastManager: ToastManager,
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
logsManager: LogsManager,
|
||||
pushManager: PushManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
) : AuthRepository,
|
||||
AuthRequestManager by authRequestManager,
|
||||
BiometricsEncryptionManager by biometricsEncryptionManager,
|
||||
@@ -461,84 +469,140 @@ class AuthRepositoryImpl(
|
||||
?: return NewSsoUserResult.Failure(error = NoActiveUserException())
|
||||
val orgIdentifier = rememberedOrgIdentifier
|
||||
?: return NewSsoUserResult.Failure(error = MissingPropertyException("OrgIdentifier"))
|
||||
val userId = account.profile.userId
|
||||
return organizationService
|
||||
.getOrganizationAutoEnrollStatus(orgIdentifier)
|
||||
.flatMap { orgAutoEnrollStatus ->
|
||||
organizationService
|
||||
.getOrganizationKeys(orgAutoEnrollStatus.organizationId)
|
||||
.flatMap { organizationKeys ->
|
||||
authSdkSource.makeRegisterTdeKeysAndUnlockVault(
|
||||
userId = userId,
|
||||
email = account.profile.email,
|
||||
orgPublicKey = organizationKeys.publicKey,
|
||||
rememberDevice = authDiskSource
|
||||
.getShouldTrustDevice(userId = userId) == true,
|
||||
)
|
||||
}
|
||||
.flatMap { registerTdeKeyResponse ->
|
||||
accountsService
|
||||
.createAccountKeys(
|
||||
publicKey = registerTdeKeyResponse.publicKey,
|
||||
encryptedPrivateKey = registerTdeKeyResponse.privateKey,
|
||||
)
|
||||
.map { createAccountKeysResponse ->
|
||||
registerTdeKeyResponse to createAccountKeysResponse
|
||||
return userStateManager.userStateTransaction {
|
||||
organizationService
|
||||
.getOrganizationAutoEnrollStatus(organizationIdentifier = orgIdentifier)
|
||||
.flatMap { orgAutoEnrollStatus ->
|
||||
organizationService
|
||||
.getOrganizationKeys(organizationId = orgAutoEnrollStatus.organizationId)
|
||||
.flatMap { organizationKeys ->
|
||||
if (featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionTde)) {
|
||||
registerUserForTdeV2(
|
||||
profile = account.profile,
|
||||
orgAutoEnrollStatus = orgAutoEnrollStatus,
|
||||
orgKeys = organizationKeys,
|
||||
)
|
||||
} else {
|
||||
registerUserForTdeV1(
|
||||
profile = account.profile,
|
||||
orgAutoEnrollStatus = orgAutoEnrollStatus,
|
||||
orgKeys = organizationKeys,
|
||||
)
|
||||
}
|
||||
}
|
||||
.flatMap { (registerTdeKeyResponse, createAccountKeysResponse) ->
|
||||
organizationService
|
||||
.organizationResetPasswordEnroll(
|
||||
organizationId = orgAutoEnrollStatus.organizationId,
|
||||
userId = userId,
|
||||
passwordHash = null,
|
||||
resetPasswordKey = registerTdeKeyResponse.adminReset,
|
||||
)
|
||||
.map { registerTdeKeyResponse to createAccountKeysResponse }
|
||||
}
|
||||
.onSuccess { (registerTdeKeyResponse, createAccountKeysResponse) ->
|
||||
createNewSsoUserSuccess(
|
||||
userId = userId,
|
||||
createAccountKeysResponse = createAccountKeysResponse,
|
||||
registerTdeKeyResponse = registerTdeKeyResponse,
|
||||
)
|
||||
}
|
||||
}
|
||||
.fold(
|
||||
onSuccess = { NewSsoUserResult.Success },
|
||||
onFailure = { NewSsoUserResult.Failure(error = it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
.fold(
|
||||
onSuccess = { NewSsoUserResult.Success },
|
||||
onFailure = { NewSsoUserResult.Failure(error = it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores all the relevant data from a successful creation of an SSO user. The data is stored
|
||||
* while in an [UserStateManager.userStateTransaction] to ensure the `UserState` is only
|
||||
* updated once after data stored.
|
||||
*/
|
||||
private suspend fun createNewSsoUserSuccess(
|
||||
userId: String,
|
||||
createAccountKeysResponse: CreateAccountKeysResponseJson,
|
||||
registerTdeKeyResponse: RegisterTdeKeyResponse,
|
||||
): Unit = userStateManager.userStateTransaction {
|
||||
authDiskSource.storeAccountKeys(
|
||||
userId = userId,
|
||||
accountKeys = createAccountKeysResponse.accountKeys,
|
||||
)
|
||||
// TDE and SSO user creation still uses crypto-v1. These users are not
|
||||
// expected to have the AEAD keys so we only store the private key for now.
|
||||
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
|
||||
// for more details.
|
||||
authDiskSource.storePrivateKey(
|
||||
userId = userId,
|
||||
privateKey = registerTdeKeyResponse.privateKey,
|
||||
)
|
||||
vaultRepository.syncVaultState(userId = userId)
|
||||
registerTdeKeyResponse.deviceKey?.let { trustDeviceResponse ->
|
||||
trustedDeviceManager.trustThisDevice(
|
||||
private suspend fun registerUserForTdeV1(
|
||||
profile: AccountJson.Profile,
|
||||
orgAutoEnrollStatus: OrganizationAutoEnrollStatusResponseJson,
|
||||
orgKeys: OrganizationKeysResponseJson,
|
||||
): Result<Pair<RegisterTdeKeyResponse, CreateAccountKeysResponseJson>> {
|
||||
val userId = profile.userId
|
||||
return authSdkSource
|
||||
.makeRegisterTdeKeysAndUnlockVault(
|
||||
userId = userId,
|
||||
trustDeviceResponse = trustDeviceResponse,
|
||||
email = profile.email,
|
||||
orgPublicKey = orgKeys.publicKey,
|
||||
rememberDevice = authDiskSource.getShouldTrustDevice(userId = userId) == true,
|
||||
)
|
||||
.flatMap { registerTdeKeyResponse ->
|
||||
accountsService
|
||||
.createAccountKeys(
|
||||
publicKey = registerTdeKeyResponse.publicKey,
|
||||
encryptedPrivateKey = registerTdeKeyResponse.privateKey,
|
||||
)
|
||||
.map { createAccountKeysResponse ->
|
||||
registerTdeKeyResponse to createAccountKeysResponse
|
||||
}
|
||||
}
|
||||
.flatMap { (registerTdeKeyResponse, createAccountKeysResponse) ->
|
||||
organizationService
|
||||
.organizationResetPasswordEnroll(
|
||||
organizationId = orgAutoEnrollStatus.organizationId,
|
||||
userId = userId,
|
||||
passwordHash = null,
|
||||
resetPasswordKey = registerTdeKeyResponse.adminReset,
|
||||
)
|
||||
.map { registerTdeKeyResponse to createAccountKeysResponse }
|
||||
}
|
||||
.onSuccess { (registerTdeKeyResponse, createAccountKeysResponse) ->
|
||||
authDiskSource.storeAccountKeys(
|
||||
userId = userId,
|
||||
accountKeys = createAccountKeysResponse.accountKeys,
|
||||
)
|
||||
// TDE and SSO user creation still uses crypto-v1. These users are not expected to
|
||||
// have the AEAD keys so we only store the private key for now.
|
||||
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
|
||||
// for more details.
|
||||
authDiskSource.storePrivateKey(
|
||||
userId = userId,
|
||||
privateKey = registerTdeKeyResponse.privateKey,
|
||||
)
|
||||
vaultRepository.syncVaultState(userId = userId)
|
||||
registerTdeKeyResponse.deviceKey?.let { response ->
|
||||
trustedDeviceManager.trustThisDevice(
|
||||
userId = userId,
|
||||
trustDeviceResponse = response,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun registerUserForTdeV2(
|
||||
profile: AccountJson.Profile,
|
||||
orgAutoEnrollStatus: OrganizationAutoEnrollStatusResponseJson,
|
||||
orgKeys: OrganizationKeysResponseJson,
|
||||
): Result<VaultUnlockResult> {
|
||||
val userId = profile.userId
|
||||
val shouldTrustDevice = authDiskSource.getShouldTrustDevice(userId = userId) == true
|
||||
return withContext(dispatcherManager.io) {
|
||||
authSdkSource.postKeysForTdeRegistration(
|
||||
userId = userId,
|
||||
organizationId = orgAutoEnrollStatus.organizationId,
|
||||
organizationPublicKey = orgKeys.publicKey,
|
||||
deviceIdentifier = authDiskSource.uniqueAppId,
|
||||
shouldTrustDevice = shouldTrustDevice,
|
||||
)
|
||||
}
|
||||
.map { response ->
|
||||
// Clear the 'should trust device' flag, since the SDK trusted the device above.
|
||||
authDiskSource.storeShouldTrustDevice(userId = userId, shouldTrustDevice = null)
|
||||
this
|
||||
.unlockVault(
|
||||
accountCryptographicState = response.accountCryptographicState,
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.DecryptedKey(
|
||||
decryptedUserKey = response.userKey,
|
||||
),
|
||||
)
|
||||
.also { result ->
|
||||
if (result is VaultUnlockResult.Success) {
|
||||
authDiskSource.storeAccountKeys(
|
||||
userId = userId,
|
||||
accountKeys = response.accountCryptographicState.accountKeysJson,
|
||||
)
|
||||
|
||||
// Storing the private key here for legacy purposes, the
|
||||
// `accountKeysJson` stored above will be used for most purposes.
|
||||
authDiskSource.storePrivateKey(
|
||||
userId = userId,
|
||||
privateKey = response.accountCryptographicState.privateKey,
|
||||
)
|
||||
if (shouldTrustDevice) {
|
||||
authDiskSource.storeDeviceKey(
|
||||
userId = userId,
|
||||
deviceKey = response.deviceKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun completeTdeLogin(
|
||||
@@ -555,9 +619,6 @@ class AuthRepositoryImpl(
|
||||
errorMessage = null,
|
||||
error = MissingPropertyException("Private Key"),
|
||||
)
|
||||
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
|
||||
val securityState = accountKeys?.securityState?.securityState
|
||||
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
|
||||
|
||||
checkForVaultUnlockError(
|
||||
onVaultUnlockError = { error ->
|
||||
@@ -565,11 +626,8 @@ class AuthRepositoryImpl(
|
||||
},
|
||||
) {
|
||||
unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
accountCryptographicState = accountKeys.toAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
@@ -681,15 +739,18 @@ class AuthRepositoryImpl(
|
||||
error = MissingPropertyException("Identity Token Auth Model"),
|
||||
)
|
||||
|
||||
override suspend fun continueKeyConnectorLogin(): LoginResult {
|
||||
override suspend fun continueKeyConnectorLogin(
|
||||
orgIdentifier: String,
|
||||
email: String,
|
||||
): LoginResult {
|
||||
val response = keyConnectorResponse ?: return LoginResult.Error(
|
||||
errorMessage = null,
|
||||
error = MissingPropertyException("Key Connector Response"),
|
||||
)
|
||||
return handleLoginCommonSuccess(
|
||||
loginResponse = response,
|
||||
email = rememberedEmailAddress.orEmpty(),
|
||||
orgIdentifier = rememberedOrgIdentifier,
|
||||
email = email,
|
||||
orgIdentifier = orgIdentifier,
|
||||
password = null,
|
||||
deviceData = null,
|
||||
userConfirmedKeyConnector = true,
|
||||
@@ -890,7 +951,7 @@ class AuthRepositoryImpl(
|
||||
email: String,
|
||||
masterPassword: String,
|
||||
masterPasswordHint: String?,
|
||||
emailVerificationToken: String?,
|
||||
emailVerificationToken: String,
|
||||
shouldCheckDataBreaches: Boolean,
|
||||
isMasterPasswordStrong: Boolean,
|
||||
): RegisterResult {
|
||||
@@ -910,6 +971,22 @@ class AuthRepositoryImpl(
|
||||
if (!isMasterPasswordStrong) {
|
||||
return RegisterResult.WeakPassword
|
||||
}
|
||||
if (featureFlagManager.getFeatureFlag(key = FlagKey.V2EncryptionPassword)) {
|
||||
return withContext(dispatcherManager.io) {
|
||||
authSdkSource.postKeysForUserPasswordRegistration(
|
||||
email = email,
|
||||
salt = email,
|
||||
masterPassword = masterPassword,
|
||||
masterPasswordHint = masterPasswordHint,
|
||||
emailVerificationToken = emailVerificationToken,
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onSuccess = { RegisterResult.Success },
|
||||
onFailure = { RegisterResult.Error(errorMessage = null, error = it) },
|
||||
)
|
||||
}
|
||||
|
||||
val kdf = Kdf.Pbkdf2(iterations = DEFAULT_PBKDF2_ITERATIONS.toUInt())
|
||||
return authSdkSource
|
||||
.makeRegisterKeys(
|
||||
@@ -918,39 +995,21 @@ class AuthRepositoryImpl(
|
||||
kdf = kdf,
|
||||
)
|
||||
.flatMap { registerKeyResponse ->
|
||||
if (emailVerificationToken == null) {
|
||||
// TODO PM-6675: Remove register call and service implementation
|
||||
identityService.register(
|
||||
body = RegisterRequestJson(
|
||||
email = email,
|
||||
masterPasswordHash = registerKeyResponse.masterPasswordHash,
|
||||
masterPasswordHint = masterPasswordHint,
|
||||
key = registerKeyResponse.encryptedUserKey,
|
||||
keys = RegisterRequestJson.Keys(
|
||||
publicKey = registerKeyResponse.keys.public,
|
||||
encryptedPrivateKey = registerKeyResponse.keys.private,
|
||||
),
|
||||
kdfType = kdf.toKdfTypeJson(),
|
||||
kdfIterations = kdf.iterations,
|
||||
identityService.registerFinish(
|
||||
body = RegisterFinishRequestJson(
|
||||
email = email,
|
||||
masterPasswordHash = registerKeyResponse.masterPasswordHash,
|
||||
masterPasswordHint = masterPasswordHint,
|
||||
emailVerificationToken = emailVerificationToken,
|
||||
userSymmetricKey = registerKeyResponse.encryptedUserKey,
|
||||
userAsymmetricKeys = RegisterFinishRequestJson.Keys(
|
||||
publicKey = registerKeyResponse.keys.public,
|
||||
encryptedPrivateKey = registerKeyResponse.keys.private,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
identityService.registerFinish(
|
||||
body = RegisterFinishRequestJson(
|
||||
email = email,
|
||||
masterPasswordHash = registerKeyResponse.masterPasswordHash,
|
||||
masterPasswordHint = masterPasswordHint,
|
||||
emailVerificationToken = emailVerificationToken,
|
||||
userSymmetricKey = registerKeyResponse.encryptedUserKey,
|
||||
userAsymmetricKeys = RegisterFinishRequestJson.Keys(
|
||||
publicKey = registerKeyResponse.keys.public,
|
||||
encryptedPrivateKey = registerKeyResponse.keys.private,
|
||||
),
|
||||
kdfType = kdf.toKdfTypeJson(),
|
||||
kdfIterations = kdf.iterations,
|
||||
),
|
||||
)
|
||||
}
|
||||
kdfType = kdf.toKdfTypeJson(),
|
||||
kdfIterations = kdf.iterations,
|
||||
),
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onSuccess = {
|
||||
@@ -1107,85 +1166,73 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
override suspend fun setPassword(
|
||||
organizationIdentifier: String,
|
||||
password: String,
|
||||
passwordHint: String?,
|
||||
): SetPasswordResult {
|
||||
val activeAccount = authDiskSource
|
||||
.userState
|
||||
?.activeAccount
|
||||
val profile = authDiskSource.userState?.activeAccount?.profile
|
||||
?: return SetPasswordResult.Error(error = NoActiveUserException())
|
||||
val userId = activeAccount.profile.userId
|
||||
|
||||
// Update the saved master password hash.
|
||||
val passwordHash = authSdkSource
|
||||
.hashPassword(
|
||||
email = activeAccount.profile.email,
|
||||
password = password,
|
||||
kdf = activeAccount.profile.toSdkParams(),
|
||||
purpose = HashPurpose.SERVER_AUTHORIZATION,
|
||||
)
|
||||
.getOrElse { return@setPassword SetPasswordResult.Error(error = it) }
|
||||
|
||||
return when (activeAccount.profile.forcePasswordResetReason) {
|
||||
return when (profile.forcePasswordResetReason) {
|
||||
ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION -> {
|
||||
vaultSdkSource
|
||||
.updatePassword(userId = userId, newPassword = password)
|
||||
.map { it.newKey to null }
|
||||
setUpdatedPassword(
|
||||
profile = profile,
|
||||
organizationIdentifier = organizationIdentifier,
|
||||
password = password,
|
||||
passwordHint = passwordHint,
|
||||
)
|
||||
}
|
||||
|
||||
ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET,
|
||||
ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
|
||||
null,
|
||||
-> {
|
||||
authSdkSource
|
||||
.makeRegisterKeys(
|
||||
email = activeAccount.profile.email,
|
||||
password = password,
|
||||
kdf = activeAccount.profile.toSdkParams(),
|
||||
)
|
||||
.map { it.encryptedUserKey to it.keys }
|
||||
setPasswordForJit(
|
||||
profile = profile,
|
||||
organizationIdentifier = organizationIdentifier,
|
||||
password = password,
|
||||
passwordHint = passwordHint,
|
||||
)
|
||||
}
|
||||
}
|
||||
.flatMap { (encryptedUserKey, rsaKeys) ->
|
||||
}
|
||||
|
||||
private suspend fun setUpdatedPassword(
|
||||
profile: AccountJson.Profile,
|
||||
organizationIdentifier: String,
|
||||
password: String,
|
||||
passwordHint: String?,
|
||||
): SetPasswordResult {
|
||||
val userId = profile.userId
|
||||
return vaultSdkSource
|
||||
.updatePassword(userId = userId, newPassword = password)
|
||||
.flatMap { response ->
|
||||
accountsService
|
||||
.setPassword(
|
||||
body = SetPasswordRequestJson(
|
||||
passwordHash = passwordHash,
|
||||
passwordHash = response.passwordHash,
|
||||
passwordHint = passwordHint,
|
||||
organizationIdentifier = organizationIdentifier,
|
||||
kdfIterations = activeAccount.profile.kdfIterations,
|
||||
kdfMemory = activeAccount.profile.kdfMemory,
|
||||
kdfParallelism = activeAccount.profile.kdfParallelism,
|
||||
kdfType = activeAccount.profile.kdfType,
|
||||
key = encryptedUserKey,
|
||||
keys = rsaKeys?.let {
|
||||
RegisterRequestJson.Keys(
|
||||
publicKey = it.public,
|
||||
encryptedPrivateKey = it.private,
|
||||
)
|
||||
},
|
||||
kdfIterations = profile.kdfIterations,
|
||||
kdfMemory = profile.kdfMemory,
|
||||
kdfParallelism = profile.kdfParallelism,
|
||||
kdfType = profile.kdfType,
|
||||
key = response.newKey,
|
||||
keys = null,
|
||||
),
|
||||
)
|
||||
.onSuccess {
|
||||
rsaKeys?.private?.let {
|
||||
// This process is used by TDE and Enterprise accounts during initial
|
||||
// login. We continue to store the locally generated keys
|
||||
// until TDE and Enterprise accounts support AEAD keys.
|
||||
authDiskSource.storePrivateKey(userId = userId, privateKey = it)
|
||||
}
|
||||
authDiskSource.storeUserKey(userId = userId, userKey = encryptedUserKey)
|
||||
authDiskSource.storeUserKey(userId = userId, userKey = response.newKey)
|
||||
}
|
||||
.map { response.passwordHash }
|
||||
}
|
||||
.flatMap {
|
||||
.flatMap { masterPasswordHash ->
|
||||
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
|
||||
is VaultUnlockResult.Success -> {
|
||||
enrollUserInPasswordReset(
|
||||
userId = userId,
|
||||
organizationIdentifier = organizationIdentifier,
|
||||
passwordHash = passwordHash,
|
||||
passwordHash = masterPasswordHash,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1196,8 +1243,155 @@ class AuthRepositoryImpl(
|
||||
}
|
||||
}
|
||||
.onSuccess {
|
||||
authDiskSource.storeMasterPasswordHash(userId = userId, passwordHash = passwordHash)
|
||||
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword()
|
||||
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
|
||||
masterPasswordUnlock = null,
|
||||
)
|
||||
this.organizationIdentifier = null
|
||||
}
|
||||
.fold(
|
||||
onFailure = { SetPasswordResult.Error(error = it) },
|
||||
onSuccess = { SetPasswordResult.Success },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun setPasswordForJit(
|
||||
profile: AccountJson.Profile,
|
||||
organizationIdentifier: String,
|
||||
password: String,
|
||||
passwordHint: String?,
|
||||
): SetPasswordResult {
|
||||
if (!featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionJitPassword)) {
|
||||
return setPasswordForJitV1(
|
||||
profile = profile,
|
||||
organizationIdentifier = organizationIdentifier,
|
||||
password = password,
|
||||
passwordHint = passwordHint,
|
||||
)
|
||||
}
|
||||
val userId = profile.userId
|
||||
return organizationService
|
||||
.getOrganizationAutoEnrollStatus(organizationIdentifier = organizationIdentifier)
|
||||
.flatMap { enrollStatus ->
|
||||
organizationService
|
||||
.getOrganizationKeys(organizationId = enrollStatus.organizationId)
|
||||
.map { orgKeys -> enrollStatus to orgKeys }
|
||||
}
|
||||
.flatMap { (enrollStatus, orgKeys) ->
|
||||
withContext(dispatcherManager.io) {
|
||||
authSdkSource.postKeysForJitPasswordRegistration(
|
||||
userId = userId,
|
||||
organizationId = enrollStatus.organizationId,
|
||||
organizationPublicKey = orgKeys.publicKey,
|
||||
organizationSsoIdentifier = organizationIdentifier,
|
||||
salt = profile.email,
|
||||
masterPassword = password,
|
||||
masterPasswordHint = passwordHint,
|
||||
shouldResetPasswordEnroll = enrollStatus.isResetPasswordEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
.onSuccess { response ->
|
||||
authDiskSource.storeAccountKeys(
|
||||
userId = userId,
|
||||
accountKeys = response.accountCryptographicState.accountKeysJson,
|
||||
)
|
||||
// TDE and SSO user creation still uses crypto-v1. These users are not
|
||||
// expected to have the AEAD keys so we only store the private key for now.
|
||||
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
|
||||
// for more details.
|
||||
authDiskSource.storePrivateKey(
|
||||
userId = userId,
|
||||
privateKey = response.accountCryptographicState.privateKey,
|
||||
)
|
||||
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
|
||||
masterPasswordUnlock = response.masterPasswordUnlock,
|
||||
)
|
||||
this.organizationIdentifier = null
|
||||
}
|
||||
.flatMap { response ->
|
||||
// Logging in with the password instead of the decrypted userKey will store
|
||||
// the master password hash automatically.
|
||||
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
|
||||
VaultUnlockResult.Success -> response.asSuccess()
|
||||
is VaultUnlockError -> {
|
||||
(result.error ?: IllegalStateException("Failed to unlock vault"))
|
||||
.asFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
.fold(
|
||||
onFailure = { SetPasswordResult.Error(error = it) },
|
||||
onSuccess = { SetPasswordResult.Success },
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private suspend fun setPasswordForJitV1(
|
||||
profile: AccountJson.Profile,
|
||||
organizationIdentifier: String,
|
||||
password: String,
|
||||
passwordHint: String?,
|
||||
): SetPasswordResult {
|
||||
val userId = profile.userId
|
||||
return authSdkSource
|
||||
.makeRegisterKeys(
|
||||
email = profile.email,
|
||||
password = password,
|
||||
kdf = profile.toSdkParams(),
|
||||
)
|
||||
.flatMap { response ->
|
||||
accountsService
|
||||
.setPassword(
|
||||
body = SetPasswordRequestJson(
|
||||
passwordHash = response.masterPasswordHash,
|
||||
passwordHint = passwordHint,
|
||||
organizationIdentifier = organizationIdentifier,
|
||||
kdfIterations = profile.kdfIterations,
|
||||
kdfMemory = profile.kdfMemory,
|
||||
kdfParallelism = profile.kdfParallelism,
|
||||
kdfType = profile.kdfType,
|
||||
key = response.encryptedUserKey,
|
||||
keys = SetPasswordRequestJson.Keys(
|
||||
publicKey = response.keys.public,
|
||||
encryptedPrivateKey = response.keys.private,
|
||||
),
|
||||
),
|
||||
)
|
||||
.onSuccess {
|
||||
// This process is used by TDE and Enterprise accounts during initial
|
||||
// login. We continue to store the locally generated keys
|
||||
// until TDE and Enterprise accounts support AEAD keys.
|
||||
authDiskSource.storePrivateKey(
|
||||
userId = userId,
|
||||
privateKey = response.keys.private,
|
||||
)
|
||||
authDiskSource.storeUserKey(
|
||||
userId = userId,
|
||||
userKey = response.encryptedUserKey,
|
||||
)
|
||||
}
|
||||
.map { response.masterPasswordHash }
|
||||
}
|
||||
.flatMap { masterPasswordHash ->
|
||||
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
|
||||
is VaultUnlockResult.Success -> {
|
||||
enrollUserInPasswordReset(
|
||||
userId = userId,
|
||||
organizationIdentifier = organizationIdentifier,
|
||||
passwordHash = masterPasswordHash,
|
||||
)
|
||||
}
|
||||
|
||||
is VaultUnlockError -> {
|
||||
(result.error ?: IllegalStateException("Failed to unlock vault"))
|
||||
.asFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onSuccess {
|
||||
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
|
||||
masterPasswordUnlock = null,
|
||||
)
|
||||
this.organizationIdentifier = null
|
||||
}
|
||||
.fold(
|
||||
@@ -1676,6 +1870,7 @@ class AuthRepositoryImpl(
|
||||
|
||||
checkForVaultUnlockError(
|
||||
onVaultUnlockError = { vaultUnlockError ->
|
||||
authDiskSource.storeAccountTokens(userId = profile.userId, accountTokens = null)
|
||||
return@userStateTransaction vaultUnlockError.toLoginErrorResult()
|
||||
},
|
||||
) {
|
||||
@@ -1698,7 +1893,7 @@ class AuthRepositoryImpl(
|
||||
val isNewKeyConnectorUser =
|
||||
loginResponse.userDecryptionOptions?.hasMasterPassword == false &&
|
||||
loginResponse.key == null &&
|
||||
loginResponse.privateKey == null
|
||||
loginResponse.privateKeyOrNull() == null
|
||||
val isNotConfirmed = !userConfirmedKeyConnector
|
||||
|
||||
// If a new KeyConnector user is logging in for the first time,
|
||||
@@ -1773,7 +1968,7 @@ class AuthRepositoryImpl(
|
||||
}
|
||||
// We continue to store the private key for backwards compatibility. Key connector
|
||||
// conversion still relies on the private key.
|
||||
loginResponse.privateKey?.let {
|
||||
loginResponse.privateKeyOrNull()?.let {
|
||||
// Only set the value if it's present, since we may have set it already
|
||||
// when we completed the key connector conversion.
|
||||
authDiskSource.storePrivateKey(userId = userId, privateKey = it)
|
||||
@@ -1863,7 +2058,7 @@ class AuthRepositoryImpl(
|
||||
loginResponse: GetTokenResponseJson.Success,
|
||||
): VaultUnlockResult? {
|
||||
val key = loginResponse.key
|
||||
val privateKey = loginResponse.privateKey
|
||||
val privateKey = loginResponse.privateKeyOrNull()
|
||||
return if (loginResponse.userDecryptionOptions?.hasMasterPassword != false) {
|
||||
// This user has a master password, so we skip the key-connector logic as it is not
|
||||
// setup yet. The user can still unlock the vault with their master password.
|
||||
@@ -1877,18 +2072,9 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
.map {
|
||||
unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = loginResponse.accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = loginResponse.accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountCryptographicState = loginResponse
|
||||
.accountKeys
|
||||
.toAccountCryptographicState(privateKey = privateKey),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
|
||||
masterKey = it.masterKey,
|
||||
@@ -1902,9 +2088,12 @@ class AuthRepositoryImpl(
|
||||
onSuccess = { it },
|
||||
)
|
||||
} else {
|
||||
// This is a new user who needs to setup the key connector
|
||||
// This is a new user who needs to set up the key connector
|
||||
val userId = profile.userId
|
||||
keyConnectorManager
|
||||
.migrateNewUserToKeyConnector(
|
||||
userId = userId,
|
||||
accountKeys = loginResponse.accountKeys,
|
||||
url = keyConnectorUrl,
|
||||
accessToken = loginResponse.accessToken,
|
||||
kdfType = loginResponse.kdfType,
|
||||
@@ -1913,46 +2102,37 @@ class AuthRepositoryImpl(
|
||||
kdfParallelism = loginResponse.kdfParallelism,
|
||||
organizationIdentifier = orgIdentifier,
|
||||
)
|
||||
.map { keyConnectorResponse ->
|
||||
val accountKeys = loginResponse.accountKeys
|
||||
val result = unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = keyConnectorResponse.keys.private,
|
||||
securityState = accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
|
||||
masterKey = keyConnectorResponse.masterKey,
|
||||
userKey = keyConnectorResponse.encryptedUserKey,
|
||||
),
|
||||
)
|
||||
if (result is VaultUnlockResult.Success) {
|
||||
// We now know that login/unlock was successful, so we store the userKey
|
||||
// and privateKey we now have since it didn't exist on the loginResponse
|
||||
authDiskSource.storeUserKey(
|
||||
userId = profile.userId,
|
||||
userKey = keyConnectorResponse.encryptedUserKey,
|
||||
.map { keyConnector ->
|
||||
this
|
||||
.unlockVault(
|
||||
accountCryptographicState = keyConnector.accountCryptographicState,
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
|
||||
masterKey = keyConnector.masterKey,
|
||||
userKey = keyConnector.encryptedUserKey,
|
||||
),
|
||||
)
|
||||
// We continue to store the private key for backwards compatibility since
|
||||
// key connector conversion still relies on the private key.
|
||||
authDiskSource.storePrivateKey(
|
||||
userId = profile.userId,
|
||||
privateKey = keyConnectorResponse.keys.private,
|
||||
)
|
||||
authDiskSource.storeAccountKeys(
|
||||
userId = profile.userId,
|
||||
accountKeys = loginResponse.accountKeys,
|
||||
)
|
||||
}
|
||||
result
|
||||
.also { result ->
|
||||
if (result is VaultUnlockResult.Success) {
|
||||
// We now know that login/unlock was successful, so we store the
|
||||
// userKey and privateKey we now have since it didn't exist on the
|
||||
// loginResponse.
|
||||
authDiskSource.storeUserKey(
|
||||
userId = userId,
|
||||
userKey = keyConnector.encryptedUserKey,
|
||||
)
|
||||
// We continue to store the private key for backwards compatibility
|
||||
// since key connector conversion still relies on the private key.
|
||||
authDiskSource.storePrivateKey(
|
||||
userId = userId,
|
||||
privateKey = keyConnector.privateKey,
|
||||
)
|
||||
authDiskSource.storeAccountKeys(
|
||||
userId = userId,
|
||||
accountKeys = loginResponse.accountKeys,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.fold(
|
||||
// If the request failed, we want to abort the login process
|
||||
@@ -1984,17 +2164,8 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
|
||||
return unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
accountCryptographicState = loginResponse.accountKeys.toAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = loginResponse.accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = loginResponse.accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = initUserCryptoMethod,
|
||||
@@ -2017,18 +2188,9 @@ class AuthRepositoryImpl(
|
||||
if (privateKey != null && key != null) {
|
||||
deviceData?.let { model ->
|
||||
return unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = loginResponse.accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = loginResponse.accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountCryptographicState = loginResponse
|
||||
.accountKeys
|
||||
.toAccountCryptographicState(privateKey = privateKey),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
requestPrivateKey = model.privateKey,
|
||||
@@ -2054,36 +2216,14 @@ class AuthRepositoryImpl(
|
||||
.userDecryptionOptions
|
||||
?.trustedDeviceUserDecryptionOptions
|
||||
?.let { options ->
|
||||
loginResponse.accountKeys
|
||||
?.let { accountKeys ->
|
||||
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
|
||||
options = options,
|
||||
profile = profile,
|
||||
privateKey = accountKeys
|
||||
.publicKeyEncryptionKeyPair
|
||||
.wrappedPrivateKey,
|
||||
securityState = accountKeys
|
||||
.securityState
|
||||
?.securityState,
|
||||
signedPublicKey = accountKeys
|
||||
.publicKeyEncryptionKeyPair
|
||||
.signedPublicKey,
|
||||
signingKey = accountKeys
|
||||
.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
)
|
||||
}
|
||||
?: loginResponse.privateKey
|
||||
?.let { privateKey ->
|
||||
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
|
||||
options = options,
|
||||
profile = profile,
|
||||
privateKey = privateKey,
|
||||
securityState = null,
|
||||
signedPublicKey = null,
|
||||
signingKey = null,
|
||||
)
|
||||
}
|
||||
loginResponse.privateKeyOrNull()?.let { privateKey ->
|
||||
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
|
||||
options = options,
|
||||
profile = profile,
|
||||
privateKey = privateKey,
|
||||
accountKeys = loginResponse.accountKeys,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2095,9 +2235,7 @@ class AuthRepositoryImpl(
|
||||
options: TrustedDeviceUserDecryptionOptionsJson,
|
||||
profile: AccountJson.Profile,
|
||||
privateKey: String,
|
||||
securityState: String?,
|
||||
signedPublicKey: String?,
|
||||
signingKey: String?,
|
||||
accountKeys: AccountKeysJson?,
|
||||
): VaultUnlockResult? {
|
||||
var vaultUnlockResult: VaultUnlockResult? = null
|
||||
val userId = profile.userId
|
||||
@@ -2114,11 +2252,8 @@ class AuthRepositoryImpl(
|
||||
// For approved requests the key will always be present.
|
||||
val userKey = requireNotNull(request.key)
|
||||
vaultUnlockResult = unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
accountCryptographicState = accountKeys.toAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
@@ -2146,11 +2281,8 @@ class AuthRepositoryImpl(
|
||||
}
|
||||
|
||||
vaultUnlockResult = unlockVault(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
accountCryptographicState = accountKeys.toAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.LogsManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
@@ -73,6 +74,7 @@ object AuthRepositoryModule {
|
||||
userStateManager: UserStateManager,
|
||||
kdfManager: KdfManager,
|
||||
toastManager: ToastManager,
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
): AuthRepository = AuthRepositoryImpl(
|
||||
clock = clock,
|
||||
accountsService = accountsService,
|
||||
@@ -100,6 +102,7 @@ object AuthRepositoryModule {
|
||||
userStateManager = userStateManager,
|
||||
kdfManager = kdfManager,
|
||||
toastManager = toastManager,
|
||||
featureFlagManager = featureFlagManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.bitwarden.ui.platform.base.util.toHexColorRepresentation
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Represents the overall "user state" of the current active user as well as any users that may be
|
||||
@@ -40,10 +42,10 @@ data class UserState(
|
||||
* @property name The user's name (if applicable).
|
||||
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
|
||||
* @property environment The [Environment] associated with the user's account.
|
||||
* @property isPremium `true` if the account has a premium membership.
|
||||
* @property isPremium `true` if the account has a Premium membership.
|
||||
* @property isLoggedIn `true` if the account is logged in, or `false` if it requires additional
|
||||
* authentication to view their vault.
|
||||
* @property isVaultUnlocked Whether or not the user's vault is currently unlocked.
|
||||
* @property isVaultUnlocked Whether the user's vault is currently unlocked.
|
||||
* @property needsPasswordReset If the user needs to reset their password.
|
||||
* @property needsMasterPassword Indicates whether the user needs to create a password (e.g.
|
||||
* they logged in using SSO and don't yet have one). NOTE: This should **not** be used to
|
||||
@@ -55,6 +57,7 @@ data class UserState(
|
||||
* user's vault is enabled.
|
||||
* @property vaultUnlockType The mechanism by which the user's vault may be unlocked.
|
||||
* @property isUsingKeyConnector Indicates if the account is currently using a key connector.
|
||||
* @property creationDate The date the account was created, if available.
|
||||
*/
|
||||
data class Account(
|
||||
val userId: String,
|
||||
@@ -76,6 +79,7 @@ data class UserState(
|
||||
val onboardingStatus: OnboardingStatus,
|
||||
val firstTimeState: FirstTimeState,
|
||||
val isExportable: Boolean,
|
||||
val creationDate: Instant?,
|
||||
) {
|
||||
/**
|
||||
* Indicates that the user does or does not have a means to manually unlock the vault.
|
||||
@@ -96,4 +100,33 @@ data class UserState(
|
||||
val hasLoginApprovingDevice: Boolean,
|
||||
val hasResetPasswordPermission: Boolean,
|
||||
)
|
||||
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
/**
|
||||
* A basic empty account model.
|
||||
*/
|
||||
val EMPTY_ACCOUNT: Account = Account(
|
||||
userId = "",
|
||||
name = null,
|
||||
email = "",
|
||||
avatarColorHex = "".toHexColorRepresentation(),
|
||||
environment = Environment.Us,
|
||||
isPremium = false,
|
||||
isLoggedIn = false,
|
||||
isVaultUnlocked = false,
|
||||
needsPasswordReset = false,
|
||||
organizations = emptyList(),
|
||||
isBiometricsEnabled = false,
|
||||
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
|
||||
needsMasterPassword = false,
|
||||
hasMasterPassword = true,
|
||||
trustedDevice = null,
|
||||
isUsingKeyConnector = false,
|
||||
onboardingStatus = OnboardingStatus.COMPLETE,
|
||||
firstTimeState = FirstTimeState(),
|
||||
isExportable = false,
|
||||
creationDate = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.util
|
||||
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
import com.bitwarden.network.model.AccountKeysJson
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
|
||||
|
||||
/**
|
||||
* Creates a [WrappedAccountCryptographicState] based on the available cryptographic parameters.
|
||||
*
|
||||
* Returns [WrappedAccountCryptographicState.V2] if signing key, signed public key, and security
|
||||
* state are all present, otherwise returns [WrappedAccountCryptographicState.V1].
|
||||
*
|
||||
* @receiver The users account keys.
|
||||
* @param privateKey The user's wrapped private key.
|
||||
*/
|
||||
fun AccountKeysJson?.toAccountCryptographicState(
|
||||
privateKey: String,
|
||||
): WrappedAccountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = this?.securityState?.securityState,
|
||||
signingKey = this?.signatureKeyPair?.wrappedSigningKey,
|
||||
signedPublicKey = this?.publicKeyEncryptionKeyPair?.signedPublicKey,
|
||||
)
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.util
|
||||
|
||||
import com.bitwarden.core.MasterPasswordUnlockData
|
||||
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
|
||||
import com.bitwarden.network.model.KdfTypeJson
|
||||
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
|
||||
import com.bitwarden.network.model.OrganizationType
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
@@ -9,6 +11,7 @@ import com.bitwarden.network.model.UserDecryptionOptionsJson
|
||||
import com.bitwarden.ui.platform.base.util.toHexColorRepresentation
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfRequestModel
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
|
||||
@@ -58,6 +61,7 @@ fun UserStateJson.toUpdatedUserStateJson(
|
||||
val userId = syncProfile.id
|
||||
val account = this.accounts[userId] ?: return this
|
||||
val profile = account.profile
|
||||
val masterPasswordUnlockKdf = syncResponse.userDecryption?.masterPasswordUnlock?.kdf
|
||||
val userDecryptionOptions = syncResponse
|
||||
.userDecryption
|
||||
?.let { syncUserDecryption ->
|
||||
@@ -83,6 +87,14 @@ fun UserStateJson.toUpdatedUserStateJson(
|
||||
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
|
||||
creationDate = syncProfile.creationDate,
|
||||
userDecryptionOptions = userDecryptionOptions,
|
||||
kdfType = masterPasswordUnlockKdf?.kdfType
|
||||
?: profile.kdfType,
|
||||
kdfIterations = masterPasswordUnlockKdf?.iterations
|
||||
?: profile.kdfIterations,
|
||||
kdfMemory = masterPasswordUnlockKdf?.memory
|
||||
?: profile.kdfMemory,
|
||||
kdfParallelism = masterPasswordUnlockKdf?.parallelism
|
||||
?: profile.kdfParallelism,
|
||||
)
|
||||
val updatedAccount = account.copy(profile = updatedProfile)
|
||||
return this
|
||||
@@ -99,20 +111,34 @@ fun UserStateJson.toUpdatedUserStateJson(
|
||||
* Updates the [UserStateJson] to set the `hasMasterPassword` value to `true` after a user sets
|
||||
* their password.
|
||||
*/
|
||||
fun UserStateJson.toUserStateJsonWithPassword(): UserStateJson {
|
||||
fun UserStateJson.toUserStateJsonWithPassword(
|
||||
masterPasswordUnlock: MasterPasswordUnlockData?,
|
||||
): UserStateJson {
|
||||
val account = this.activeAccount
|
||||
val profile = account.profile
|
||||
val userDecryptionOptions = profile.userDecryptionOptions
|
||||
val masterPasswordUnlockJson = masterPasswordUnlock
|
||||
?.let {
|
||||
MasterPasswordUnlockDataJson(
|
||||
salt = it.salt,
|
||||
kdf = it.kdf.toKdfRequestModel(),
|
||||
masterKeyWrappedUserKey = it.masterKeyWrappedUserKey,
|
||||
)
|
||||
}
|
||||
?: userDecryptionOptions?.masterPasswordUnlock
|
||||
val updatedProfile = profile
|
||||
.copy(
|
||||
forcePasswordResetReason = null,
|
||||
userDecryptionOptions = profile
|
||||
.userDecryptionOptions
|
||||
?.copy(hasMasterPassword = true)
|
||||
userDecryptionOptions = userDecryptionOptions
|
||||
?.copy(
|
||||
hasMasterPassword = true,
|
||||
masterPasswordUnlock = masterPasswordUnlockJson,
|
||||
)
|
||||
?: UserDecryptionOptionsJson(
|
||||
hasMasterPassword = true,
|
||||
keyConnectorUserDecryptionOptions = null,
|
||||
trustedDeviceUserDecryptionOptions = null,
|
||||
masterPasswordUnlock = null,
|
||||
masterPasswordUnlock = masterPasswordUnlockJson,
|
||||
),
|
||||
)
|
||||
val updatedAccount = account.copy(profile = updatedProfile)
|
||||
@@ -248,6 +274,7 @@ fun UserStateJson.toUserState(
|
||||
firstTimeState = firstTimeState,
|
||||
isExportable = !hasPersonalOwnershipRestrictedOrg &&
|
||||
!hasPersonalVaultExportRestrictedOrg,
|
||||
creationDate = profile.creationDate,
|
||||
)
|
||||
},
|
||||
hasPendingAccountAddition = hasPendingAccountAddition,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.util
|
||||
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
import com.bitwarden.network.model.AccountKeysJson
|
||||
import com.bitwarden.network.model.AccountKeysJson.PublicKeyEncryptionKeyPair
|
||||
import com.bitwarden.network.model.AccountKeysJson.SecurityState
|
||||
import com.bitwarden.network.model.AccountKeysJson.SignatureKeyPair
|
||||
|
||||
/**
|
||||
* The user's encryption private key, wrapped by the user key.
|
||||
*/
|
||||
val WrappedAccountCryptographicState.privateKey: String
|
||||
get() = when (this) {
|
||||
is WrappedAccountCryptographicState.V1 -> this.privateKey
|
||||
is WrappedAccountCryptographicState.V2 -> this.privateKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the [WrappedAccountCryptographicState] into a [AccountKeysJson].
|
||||
*
|
||||
* @receiver `WrappedAccountCryptographicState` to convert to `AccountEncryptionKeysJson`.
|
||||
*/
|
||||
val WrappedAccountCryptographicState.accountKeysJson: AccountKeysJson?
|
||||
get() = when (this) {
|
||||
is WrappedAccountCryptographicState.V1 -> null
|
||||
is WrappedAccountCryptographicState.V2 -> AccountKeysJson(
|
||||
publicKeyEncryptionKeyPair = PublicKeyEncryptionKeyPair(
|
||||
publicKey = "",
|
||||
signedPublicKey = this.signedPublicKey,
|
||||
wrappedPrivateKey = this.privateKey,
|
||||
),
|
||||
signatureKeyPair = SignatureKeyPair(
|
||||
wrappedSigningKey = this.signingKey,
|
||||
verifyingKey = "",
|
||||
),
|
||||
securityState = SecurityState(
|
||||
securityState = this.securityState,
|
||||
securityVersion = 2,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package com.x8bit.bitwarden.data.autofill.accessibility.manager
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* A container for values specifying whether or not the accessibility service is enabled.
|
||||
* A container for values specifying whether the accessibility service is enabled.
|
||||
*/
|
||||
interface AccessibilityEnabledManager {
|
||||
/**
|
||||
|
||||
@@ -5,13 +5,13 @@ import android.view.autofill.AutofillManager
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bitwarden.data.manager.appstate.AppStateManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManagerImpl
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
||||
@@ -2,12 +2,13 @@ package com.x8bit.bitwarden.data.autofill.manager
|
||||
|
||||
import android.view.autofill.AutofillManager
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import com.bitwarden.data.manager.appstate.AppStateManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillManager
|
||||
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserThirdPartyAutofillStatus
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Primary implementation of [AutofillActivityManager].
|
||||
@@ -20,10 +21,34 @@ class AutofillActivityManagerImpl(
|
||||
lifecycleScope: LifecycleCoroutineScope,
|
||||
browserThirdPartyAutofillEnabledManager: BrowserThirdPartyAutofillEnabledManager,
|
||||
) : AutofillActivityManager {
|
||||
private val isAutofillEnabledAndSupported: Boolean
|
||||
get() = autofillManager.isEnabled &&
|
||||
autofillManager.hasEnabledAutofillServices() &&
|
||||
private val autofillManagerIsEnabled: Boolean
|
||||
get() = try {
|
||||
autofillManager.isEnabled
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: RuntimeException) {
|
||||
Timber.e(e, "autofillManager.isEnabled failed")
|
||||
false
|
||||
}
|
||||
|
||||
private val autofillManagerHasEnabledAutofillServices: Boolean
|
||||
get() = try {
|
||||
autofillManager.hasEnabledAutofillServices()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: RuntimeException) {
|
||||
Timber.e(e, "autofillManager.hasEnabledAutofillServices() failed")
|
||||
false
|
||||
}
|
||||
|
||||
private val autofillManagerIsAutofillSupported: Boolean
|
||||
get() = try {
|
||||
autofillManager.isAutofillSupported
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: RuntimeException) {
|
||||
Timber.e(e, "autofillManager.isAutofillSupported() failed")
|
||||
false
|
||||
}
|
||||
|
||||
private val isAutofillEnabledAndSupported: Boolean
|
||||
get() = autofillManagerIsEnabled &&
|
||||
autofillManagerHasEnabledAutofillServices &&
|
||||
autofillManagerIsAutofillSupported
|
||||
|
||||
private val browserAutofillStatus: BrowserThirdPartyAutofillStatus
|
||||
get() = BrowserThirdPartyAutofillStatus(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user