Compare commits

..

596 Commits

Author SHA1 Message Date
Amy Galles
51fd3cd526 truncate f-droid release notes 2026-05-08 14:58:53 -07:00
Amy Galles
6d55597837 add create keystores directory 2026-05-08 14:13:31 -07:00
Amy Galles
aca729393d restrict build to fdroid only 2026-05-08 14:06:37 -07:00
Amy Galles
11e45948bb remove debug files 2026-05-08 14:01:36 -07:00
David Perez
b11884ca00 bug: Fix minor pading issue on CompleteRegistrationScreen (#6894) 2026-05-08 19:55:37 +00:00
Patrick Honkonen
aae473e0c5 [PM-32810] feat: Add Add/Edit support for Bank Account item type (#6851) 2026-05-08 19:02:35 +00:00
David Perez
eabfaa6934 Deps: Update Firebase BOM to 34.13.0 (#6893) 2026-05-08 18:53:44 +00:00
David Perez
38b725f5e3 Deps: Update KSP to v2.3.7 (#6892) 2026-05-08 17:41:13 +00:00
David Perez
f37671e724 PM-24225: feat: Support V2 encryption in user password flow (#6891) 2026-05-08 16:39:26 +00:00
Patrick Honkonen
710ee64d11 [PM-36867] fix: Disable card scanner on F-Droid builds (#6888) 2026-05-08 15:25:39 +00:00
David Perez
d15744b5e8 Deps: Update to latest Compose BOM and CameraX libraries (#6886) 2026-05-08 15:24:09 +00:00
Patrick Honkonen
a52ab665b9 [PM-32810] test: Cover Bank Account vault, listing, and search surfaces (#6881) 2026-05-07 19:29:22 +00:00
Patrick Honkonen
fd9618bddb [PM-34038] fix: Address card scanner QA findings (#6867) 2026-05-07 19:29:00 +00:00
Patrick Honkonen
a9cc18e4fd [PM-32810] feat: Add Bank Account vault, listing, and search surfaces (#6877) 2026-05-07 16:55:55 +00:00
Patrick Honkonen
340c585a99 [PM-34487] llm: Add Android device interaction MCP server with ADB tooling (#6747) 2026-05-07 15:18:42 +00:00
Álison Fernandes
7fd63f7a06 [PM-36184] Update jira issue fetching process (#6860) 2026-05-06 22:37:07 +00:00
aj-rosado
aaceaa4a4f [PM-30625] fix: Filtering empty totp from count on vault screen (#6834)
Co-authored-by: David Perez <david@livefront.com>
2026-05-06 18:52:37 +00:00
bw-ghapp[bot]
f5ca914059 Update SDK to 2.0.0-6645-6849537d (#6878)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-05-06 17:35:04 +00:00
Álison Fernandes
ed2763d59d [PM-35434] Update renovate config to remove bundler group and add t:deps label (#6861) 2026-05-06 16:03:00 +00:00
Patrick Honkonen
38c4da23bc [PM-32810] feat: Add Bank Account item detail view (#6875) 2026-05-06 16:01:03 +00:00
bw-ghapp[bot]
01859beb06 Update SDK to 2.0.0-6639-21488a37 (#6864)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-05-06 15:13:20 +00:00
aj-rosado
a53260fdf7 [BWA-253] bug: Filtering empty otp uris sent by Password Manager app (#6869) 2026-05-06 13:44:42 +00:00
David Perez
b9449a7df7 bug: Add plurals support for subscription past due string (#6876) 2026-05-05 22:08:41 +00:00
David Perez
d0401b310e PM-36474: bug: Ensure shared totp labels do not parse secret (#6873) 2026-05-05 19:20:46 +00:00
David Perez
47ef8914a9 Deps: Update AGP to v9.2.1 (#6872) 2026-05-05 19:13:57 +00:00
Patrick Honkonen
3b1ea1e3cd [PM-36057] feat: Add Upgraded to Premium acknowledgment (#6863) 2026-05-05 15:13:56 +00:00
David Perez
6ba5159922 Chore: Remove the unused register API (#6870) 2026-05-04 19:34:14 +00:00
David Perez
574e86b352 PM-36475: bug: Update when search icon is shown (#6868) 2026-05-04 19:20:55 +00:00
Patrick Honkonen
8223fb3089 [PM-32009] feat: Add infrastructure for new vault item types (#6828) 2026-05-04 16:40:44 +00:00
github-actions[bot]
67182df91b Update Google privileged browsers list (#6865)
Co-authored-by: GitHub Actions Bot <actions@github.com>
2026-05-04 14:16:21 +00:00
bw-ghapp[bot]
7303246945 Crowdin Pull (#6866)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-05-04 14:14:33 +00:00
David Perez
a1cf193f40 PM-27241: feat: TDE encryption v2 (#6821) 2026-05-01 15:18:00 +00:00
Vince Grassia
0fa26acee4 [BRE-1851] Remove GPG secrets (#6862) 2026-05-01 15:10:00 +00:00
Vince Grassia
e6f0db6918 [BRE-1851] Update the Crowdin API token (#6859) 2026-04-30 19:14:27 +00:00
mpbw2
072fc8e5da [PM-36177] Pin bundler dependencies (#6858) 2026-04-30 18:15:58 +00:00
aj-rosado
d67d05ebb2 llm: Expand string resource naming convention in CLAUDE.md (#6856) 2026-04-30 17:42:19 +00:00
aj-rosado
cdd682c5aa [PM-28834] bug: Setting configuration for VR devices on MainActivity (#6563) 2026-04-30 16:38:40 +00:00
renovate[bot]
336b13ce31 [deps]: Lock file maintenance (#6839)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-30 13:02:15 +00:00
Patrick Honkonen
7fbde8b239 llm: Codify when-branch brace rule in implementation and review skills (#6849) 2026-04-30 05:57:01 +00:00
Mick Letofsky
f4651af841 PM-35200 - Create contributing guide for Claude tooling (#6848) 2026-04-29 17:49:00 +00:00
aj-rosado
b3848ffdb4 [PM-24380] fix: Correct and redact flight recorder hostname on logs (#6633) 2026-04-29 17:37:56 +00:00
bw-ghapp[bot]
3845c1fb13 Update SDK to 2.0.0-6484-a19b6544 (#6847)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-04-29 16:04:13 +00:00
David Perez
796a4dbcbd PM-27234: feat: jit password v2 encryption (#6835) 2026-04-29 15:51:05 +00:00
Patrick Honkonen
771090d529 [PM-35116] llm: Remove local agents and delivery skills, migrate to marketplace plugins (#6799) 2026-04-29 08:46:08 +00:00
David Perez
7231e14488 PM-32814: Chore: Parsing lists safely (#6846) 2026-04-28 20:56:41 +00:00
Patrick Honkonen
32b704cfde [PM-35455] feat: Wire premium subscription data into Plan screen (#6819) 2026-04-28 20:04:31 +00:00
David Perez
be1dabb9dc PM-30130: feat: Remove the Archive Items feature flag (#6667) 2026-04-28 19:10:40 +00:00
Colin Rinke
41142a3d4d [PM-35352] [PM-21264] Group card numbers in vault item display (#6810) 2026-04-28 18:55:13 +00:00
David Perez
0586edb592 PM-35925: bug: Update 'hexToColor' function to handle default names (#6841) 2026-04-28 18:45:03 +00:00
David Perez
909d999186 Deps: Update to AGP v9.2.0 (#6845) 2026-04-28 14:30:03 +00:00
David Perez
e3b26be1bf deps: Update to Kotlin v2.3.21 (#6843) 2026-04-27 21:58:21 +00:00
David Perez
a3f32e31cd deps: Update Androidx Compose BOM and Navigation libraries (#6832) 2026-04-27 20:45:44 +00:00
Patrick Honkonen
2b4ca430f1 [PM-35454] feat: Add subscription API, domain models, and status badge component (#6818) 2026-04-27 20:42:18 +00:00
bw-ghapp[bot]
bd6be6b851 Update SDK to 2.0.0-6370-96753eef (#6780)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-04-27 19:45:12 +00:00
Patrick Honkonen
1aba32fa3d [PM-33519] feat: Rewire upgrade CTAs to use conditional routing (#6796) 2026-04-27 16:25:10 +00:00
renovate[bot]
1a3679fb43 [deps]: Update com.google.guava:guava to v33.6.0-jre (#6838)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-27 14:11:12 +00:00
David Perez
ca84284f37 Update the Androidx Credentials dependency (#6831) 2026-04-24 16:22:50 +00:00
Patrick Honkonen
325c9837e0 [PM-33518] feat: Add isInAppUpgradeAvailableFlow to PremiumStateManager (#6795) 2026-04-23 15:38:03 +00:00
David Perez
cbc9c290bc PM-35654: bug: User switch should not occur on soft-logout (#6825) 2026-04-23 14:56:49 +00:00
Matt Van Horn
d2092357d5 [PM-35700] fix(about): localize Version label with parameterized string resource (#6824) (#6827)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-04-23 14:22:04 +00:00
David Perez
c01fc62658 SDK Update - com.bitwarden:sdk-android 2.0.0-6340-00b609f9 (#6823) 2026-04-22 21:06:54 +00:00
David Perez
985562eb61 deps: Update the Bitwarden SDK to address breaking change (#6820) 2026-04-22 18:15:47 +00:00
Patrick Honkonen
72c6310f95 [PM-33517] feat: Add Plan row to Settings and premium upgrade flow (#6794) 2026-04-22 14:42:13 +00:00
David Perez
83f8fca0d1 chore: Remove deprecated code from BitwardenCutCopyTextToolbar (#6815) 2026-04-22 14:05:04 +00:00
David Perez
cfead6e6a7 Update Glide to latest version (v5.0.7) (#6816) 2026-04-22 14:04:42 +00:00
David Perez
72cd8f5abc PM-35444: bug: Pass org identifier and email directly into continueKeyConnectorLogin function (#6817) 2026-04-21 23:53:24 +00:00
David Perez
308a1f98b7 PM-27237: feat: v2 encryption for key connector (#6814) 2026-04-21 23:53:06 +00:00
Patrick Honkonen
73c1a45010 [PM-33946] feat: Add dynamic pricing and fix checkout flow (#6793) 2026-04-21 17:06:35 +00:00
David Perez
88a80cd2be PM-35281: feat: Update the BlockAutofill screen UI (#6807) 2026-04-21 14:25:22 +00:00
Patrick Honkonen
39240b3317 chore: Remove MaxLineLength suppression guidance from testing skill (#6813) 2026-04-21 12:52:17 +00:00
David Perez
c1eafbeaeb bug: Move string to correct resource file (#6812) 2026-04-20 18:54:22 +00:00
David Perez
d0aa74cdd6 chore: Use private key accessor (#6808) 2026-04-20 17:38:19 +00:00
David Perez
9df39eb7a7 chore: Update to latest Protobuf plugin and start using new DSL (#6811) 2026-04-20 15:28:02 +00:00
David Perez
6e16daf001 PM-35273: feat: Add support for SDK API calls by providing base urls (#6805) 2026-04-20 14:21:39 +00:00
bw-ghapp[bot]
75b87e134c Crowdin Pull (#6809)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-04-20 13:13:42 +00:00
David Perez
759898d05d Chore: Remove UI decisions based on portrait vs landscape (#6792) 2026-04-17 18:32:43 +00:00
David Perez
853307e941 Update Firebase BOM to v34.12.0 (#6791) 2026-04-17 18:13:26 +00:00
Patrick Honkonen
0ab6beef67 [BWA-99] feat: Add next TOTP code preview in Authenticator (#6779) 2026-04-17 18:11:53 +00:00
David Perez
0de4cf7860 Update to Kotlinx Serialization v1.11.0 (#6803) 2026-04-17 18:04:52 +00:00
Vince Grassia
c6cbebf73f Add missing Password Manager Locale for Play Store (#6804) 2026-04-17 18:03:16 +00:00
aj-rosado
0143f93ef3 [PM-35117] fix: Getting updated values from KDF before displaying update KDF prompt (#6802) 2026-04-16 17:02:31 +00:00
David Perez
7d2bfe1395 PM-34840: bug: Allow related-origin passkey creation (#6777) 2026-04-14 15:33:57 +00:00
David Perez
287d8a9f4e deps: Update agp to latest version (#6790) 2026-04-13 16:33:02 +00:00
renovate[bot]
929232d6db [deps]: Update codecov/codecov-action action to v6 (#6787)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 13:48:22 +00:00
renovate[bot]
2cae3bbbfd [deps]: Update gradle/actions action to v6 (#6788)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 13:48:06 +00:00
renovate[bot]
7d83269ab2 [deps]: Lock file maintenance (#6789)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 13:36:00 +00:00
bw-ghapp[bot]
9e38e1fb09 Crowdin Pull (#6785)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-04-13 13:03:27 +00:00
David Perez
747d2d58e5 PM-33458: feat: Add speed bump when archiving item from a list (#6774) 2026-04-10 16:01:32 +00:00
Gabriel Brand
954ac5b7d7 [PM-34833] bug: Search improvements (#6776) 2026-04-10 14:05:30 +00:00
David Perez
fb166691e4 PM-34872, PM-34873, PM-34874, PM-34875: feat: Add feature flags for Encryption v2 Registration (#6778) 2026-04-09 20:19:40 +00:00
bw-ghapp[bot]
62ceab5aba Update SDK to 2.0.0-6074-f373e7f3 (#6771)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-04-09 19:14:52 +00:00
Patrick Honkonen
29e73adef0 [PM-34127] feat: Integrate card scanner with VaultAddEdit (#6722) 2026-04-09 01:38:33 +00:00
ifernandezdiaz
7d9d65a490 QA-1655: chore: Adding testTags to RecordedLogsScreen.kt (#6772) 2026-04-08 19:30:11 +00:00
Gabriel Brand
67f993ee60 [PM-23379] bug: custom fields: hide or show move up or down actions depending on the items' index (#5480) 2026-04-08 18:34:58 +00:00
Patrick Honkonen
de47c507cc [PM-34126] feat: Add card scan screen (#6721) 2026-04-07 19:47:07 +00:00
renovate[bot]
31b3b0304c [deps]: Update androidxCredentialsProviderEvents to v1.0.0-alpha06 (#6734)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2026-04-07 19:21:44 +00:00
bw-ghapp[bot]
89491bbb71 Update SDK to 2.0.0-6000-b41ccf65 (#6677)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-04-07 18:39:24 +00:00
David Perez
36366923e6 Chore: Add 'isActive' extension menthods for CipherView and CipherListView (#6769) 2026-04-07 15:05:31 +00:00
David Perez
ee2401e717 Update Androidx dependencies to their latest versions (#6768) 2026-04-06 20:46:54 +00:00
David Perez
10be562ec0 Update Kotlinx-Kover to v0.9.8. (#6766) 2026-04-06 20:46:27 +00:00
renovate[bot]
692807e361 [deps]: Update protobuf monorepo to v4.34.1 (#6735)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 20:23:18 +00:00
David Perez
c38745cb01 PM-34238: bug: Hide archived ciphers on VerificationCodes Screen (#6767) 2026-04-06 20:22:56 +00:00
Patrick Honkonen
8c5c145f34 [PM-34125] feat: Add card text analysis pipeline (#6720) 2026-04-06 19:05:36 +00:00
David Perez
8585b9ff2a [PM-29309] [BWA-209] bug: Fix TOTP countdown freeze when returning to Authenticator app (change Flow to StateFlow) (#6764)
Co-authored-by: Michal Tajchert <michal.tajchert@lite.tech>
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-06 18:33:54 +00:00
David Perez
09a2abf6bb chore: Update the AppVersionName to 2026.4.0 (#6765) 2026-04-06 18:30:46 +00:00
bw-ghapp[bot]
99fb051000 Crowdin Pull (#6762)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-04-06 18:14:45 +00:00
Patrick Honkonen
d806988fd0 [PM-34124] refactor: Generalize CameraPreview analyzer parameter (#6719) 2026-04-06 18:13:09 +00:00
ifernandezdiaz
54e74e98b4 QA-1702: Adding missing testTags for TestHarness app (#6763) 2026-04-06 16:16:45 +00:00
David Perez
61955d7cbe PM-34544: bug: Handle large attachments in preview (#6757) 2026-04-03 14:55:48 +00:00
David Perez
ab583296aa PM-34498: bug: Update attachments premium dialogs (#6753) 2026-04-01 17:47:19 +00:00
David Perez
e404477059 PM-34499: bug: Add appropriate external link callouts for attachments (#6752) 2026-04-01 14:54:30 +00:00
David Perez
fb65c3ba51 PM-29763: bug: Handle invalid URI crash (#6748) 2026-03-31 21:11:16 +00:00
David Perez
8057171e45 chore: Attachment UI tweaks (#6749) 2026-03-31 21:10:58 +00:00
David Perez
9f4ae99c0b PM-34231: feat: Support renaming attachments during creation (#6742) 2026-03-31 18:52:10 +00:00
David Perez
2402e21b4d chore: Create common UI elements for VaultItemScreen (#6746) 2026-03-31 18:40:43 +00:00
David Perez
5d7ea8f27c BWA-228: bug: Update identity custom field keys to use index (#6743) 2026-03-31 14:36:47 +00:00
renovate[bot]
ec860c9acf [deps]: Update actions/create-github-app-token action to v3 (#6737)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:02:05 +00:00
David Perez
6eeab656d1 chore: Update AttachmentsState to use immutable list (#6741) 2026-03-30 19:06:04 +00:00
David Perez
0871a2a33d BWA-224: bug: Add sort order for Authenticator items (#6740) 2026-03-30 19:05:51 +00:00
renovate[bot]
8263a178ca [deps]: Update com.google.firebase:firebase-bom to v34.11.0 (#6736)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-30 15:37:06 +00:00
David Perez
288b2b26cf PM-34228: feat: Add feature flag for forthcoming attachment updates (#6739) 2026-03-30 15:29:26 +00:00
renovate[bot]
4decce570d [deps]: Lock file maintenance (#6738)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-30 12:54:34 +00:00
bw-ghapp[bot]
57c3df8754 Crowdin Pull (#6731)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-03-30 12:46:24 +00:00
Patrick Honkonen
4ffcac20d8 llm: Add AI review label prompt to PR creation skill (#6729) 2026-03-30 08:38:47 +00:00
Patrick Honkonen
cc7ce34667 llm: Add test constants placement rule to testing skill (#6726) 2026-03-30 08:38:23 +00:00
David Perez
5cec17a21e PM-34193: bug: Unlock vault from Never-Lock should be on io thread (#6728) 2026-03-27 18:01:47 +00:00
Patrick Honkonen
94e550dbbc [PM-34107] llm: Add android-architect agent (#6686) 2026-03-27 13:54:25 +00:00
Patrick Honkonen
c38a54c238 [PM-33941] llm: Refine skills and commands for agent reliability (#6703) 2026-03-27 13:13:37 +00:00
Lucas
84f422e209 [PM-34168] Add future CalyxOS Chromium key to FIDO2 privilege community list (#6723) 2026-03-27 13:09:53 +00:00
David Perez
6d0f69b23e chore: Update UI lists to ImmutableLists (#6718) 2026-03-25 20:38:52 +00:00
Patrick Honkonen
69a13c91b5 [PM-33516] feat: Create PlanScreen, PlanViewModel, and modal navigation (#6715) 2026-03-25 18:38:38 +00:00
David Perez
d58c82ced4 PM-34042: feat: Preview attachments from AttachmentsScreen (#6712) 2026-03-25 17:03:30 +00:00
David Perez
db0cf19780 PM-34115: bug: Consistent visual length of TOTP codes (#6716) 2026-03-25 16:59:54 +00:00
David Perez
41b7ca2b68 BWA-238: bug: Send additional cipher data for Authenticator Sync (#6714) 2026-03-25 14:07:25 +00:00
David Perez
d926ce98b5 PM-32721: bug: Sort password history before persisting (#6709) 2026-03-25 14:05:55 +00:00
David Perez
6332081356 chore: Update RootNavScreen to enforce state-based navigation (#6713) 2026-03-25 14:04:22 +00:00
David Perez
b4917ceb95 chore: Implement Folder Repo interface for Bitwarden SDK (#6691) 2026-03-24 17:39:47 +00:00
David Perez
2b69753397 PM-29871: bug: Add more accessibility callouts for external links (#6708) 2026-03-23 20:06:16 +00:00
Patrick Honkonen
c786756f5b [PM-33999] chore: Standardize casing of Premium account status references (#6707) 2026-03-23 15:33:13 +00:00
David Perez
078b4e6f1b PM-25654: feat: Preview attachment (#6675)
Co-authored-by: amrg101 <amr2018xo@gmail.com>
2026-03-23 14:07:50 +00:00
bw-ghapp[bot]
d2ca13f88b Crowdin Pull (#6705)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-03-23 12:39:27 +00:00
Patrick Honkonen
2e29ab389d [PM-33515] feat: Render premium upgrade banner in Vault UI (#6698) 2026-03-20 21:27:18 +00:00
David Perez
6c7348ebd4 misc: Update BitwardenButtonData for more usability (#6704) 2026-03-20 21:26:53 +00:00
David Perez
6cf15fb792 chore: Remove unused how_to_manage_my_vault string (#6702) 2026-03-20 16:27:05 +00:00
Patrick Honkonen
988a321944 [PM-33514] feat: Add premium upgrade banner visibility logic (#6696) 2026-03-20 15:58:47 +00:00
aj-rosado
044bfb1bb2 [PM-23560] bug: Added guard to ensure duplicate scan events are not fired (#6687) 2026-03-20 15:24:16 +00:00
David Perez
eab2720e3e PM-32721: bug: Add sorting to password history (#6700) 2026-03-20 14:30:08 +00:00
Patrick Honkonen
4a069e9703 [PM-33513] feat: Add checkout callback deep link handling (#6692) 2026-03-20 14:16:43 +00:00
Patrick Honkonen
12c96de168 chore: Reorganize imports in VaultViewModel (#6701) 2026-03-20 14:08:32 +00:00
David Perez
4375782b09 PM-33913: bug: Remove org event to avoid duplicate entry (#6699) 2026-03-20 13:57:45 +00:00
David Perez
e969a42eff PM-33909: bug: Check the column index before querying for 3rd party autofill data (#6697) 2026-03-19 20:47:45 +00:00
David Perez
68e2fe4dd7 PM-33907: bug: Handle exceptions thrown when querying the AutofillManager (#6695) 2026-03-19 20:25:00 +00:00
Patrick Honkonen
37907cbe0c [PM-33512] feat: Add PremiumStateManager for upgrade banner eligibility (#6690) 2026-03-19 19:32:27 +00:00
Patrick Honkonen
c1d1de27f0 [PM-33510] feat: Add Play Billing Library dependency and PlayBillingManager (#6680) 2026-03-19 18:12:03 +00:00
David Perez
be8777cb8e PM-33893: bug: Crash caused by empty credential password (#6693) 2026-03-19 17:12:59 +00:00
Patrick Honkonen
2b9e142107 [PM-33509] feat: Add BillingRepository and Hilt billing modules (#6674) 2026-03-18 19:31:50 +00:00
David Perez
685493fde0 misc: Rename the VaultDiskSource Flows (#6689) 2026-03-18 18:59:20 +00:00
Patrick Honkonen
6d04c04929 [PM-33508] feat: Add AuthenticatedBillingApi and BillingService network layer (#6668) 2026-03-18 16:23:09 +00:00
David Perez
04c3147a56 misc: Add an error message to the DownloadAttachmentResult (#6688) 2026-03-18 16:22:54 +00:00
Patrick Honkonen
44c22deb3a llm: Add /review-android command and align reviewing-changes skill with agent (#6665) 2026-03-18 06:51:03 +00:00
Patrick Honkonen
6824af48e1 llm: Clarify @Suppress("MaxLineLength") usage in testing skill (#6685) 2026-03-18 06:50:00 +00:00
David Perez
183255cbff PM-33160: Instantiate SDK client with Repositories class (#6681) 2026-03-17 20:28:14 +00:00
David Perez
9d5a82e9ea Update app to use the latest version of Kotlin (#6684) 2026-03-17 20:27:09 +00:00
David Perez
7046029a45 Update Androidx dependencies (#6683) 2026-03-17 20:26:50 +00:00
Patrick Honkonen
4ed731706c [PM-33365] feat: Add GmsManager to gate CXP features on GMS Core version (#6678) 2026-03-17 20:21:53 +00:00
Patrick Honkonen
ec3c9001cf [PM-33553] fix: Remove "Why am I seeing this?" link from cookie sync screen (#6676) 2026-03-17 16:15:45 +00:00
David Perez
7666fb82b8 misc: Add support for icons in buttons via BitwardenButtonData (#6682) 2026-03-17 16:12:48 +00:00
Álison Fernandes
fcfa647806 [PM-18892] ci: Comment linked issues when a new GitHub Release is published (#6552) 2026-03-17 14:44:56 +00:00
Patrick Honkonen
e91797f86c Revert "Update SDK to 2.0.0-5676-14521973" (#6679) 2026-03-16 19:19:00 +00:00
André Bispo
ad7dc3fb5d [PM-33356] feat: Sync when push notification policy changed is received (#6664) 2026-03-16 15:37:26 +00:00
bw-ghapp[bot]
43bd83f883 Update SDK to 2.0.0-5676-14521973 (#6615)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2026-03-16 15:00:54 +00:00
renovate[bot]
0b78fd0018 [deps]: Update actions/upload-artifact action to v7 (#6672)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 12:24:51 +00:00
aj-rosado
6888e676dc [PM-32663] feat: Update vault migration screens (#6660) 2026-03-16 12:19:16 +00:00
renovate[bot]
c52d5efb46 [deps]: Lock file maintenance (#6673)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 11:58:46 +00:00
renovate[bot]
4fb379911d [deps]: Update org.sonarqube to v7.2.3.7755 (#6671)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 11:57:31 +00:00
renovate[bot]
8b5793734a [deps]: Update androidx.credentials:credentials to v1.6.0-rc02 (#6670)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 11:56:16 +00:00
bw-ghapp[bot]
d17617ee5a Crowdin Pull (#6669)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-03-16 11:53:20 +00:00
Patrick Honkonen
ae5a14e386 [PM-33511] feat: Add creationDate to UserState.Account (#6662) 2026-03-13 20:50:00 +00:00
renovate[bot]
193ec12ebd [deps]: Lock file maintenance (#6604)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-13 19:37:15 +00:00
David Perez
53afde1509 PM-25654: Update premium dialog for attachments (#6663) 2026-03-13 18:08:40 +00:00
Patrick Honkonen
8707a8db95 llm: Capture test failures on first run in build-test-verify skill (#6661) 2026-03-13 17:51:31 +00:00
Patrick Honkonen
13c8cc08a6 [PM-33506] feat: Add PremiumStatusChanged push notification support (#6656) 2026-03-13 17:09:26 +00:00
Patrick Honkonen
85c3a1deb8 [PM-33561] debt: Wire CipherManager and cipher ViewModel error handlers (#6651) 2026-03-13 17:08:41 +00:00
Patrick Honkonen
870f15418b [PM-33394] debt: Wire FolderManager and folder ViewModel error handlers (#6653) 2026-03-13 17:07:15 +00:00
Patrick Honkonen
453fc22d57 [PM-33507] feat: Add premium upgrade banner dismissal persistence (#6657) 2026-03-13 15:52:15 +00:00
Patrick Honkonen
93a3e0af32 [PM-33560] debt: Wire SendManager and Send ViewModel error handlers (#6652) 2026-03-13 15:31:04 +00:00
Patrick Honkonen
026a348d12 [PM-33505] feat: Add MobilePremiumUpgrade feature flag (#6655) 2026-03-13 15:03:36 +00:00
David Perez
01a137e4e3 PM-29871: bug: Add external link callouts for buttons (#6648) 2026-03-13 14:55:33 +00:00
David Perez
5b965e7923 Update error state to allow for a more customizable button (#6654) 2026-03-13 14:21:46 +00:00
Patrick Honkonen
3904f24f0a [PM-33478] llm: Add android-implementer agent for autonomous development workflow (#6635) 2026-03-13 07:56:49 +00:00
Álison Fernandes
68880ff5e3 [PM-33495] ci: Remove build job to reduce Build workflows time (#6658) 2026-03-12 23:01:08 +00:00
David Perez
d9f8c3d792 PM-29869: bug: Update colorscheme to improve accessibility (#6647) 2026-03-12 18:06:54 +00:00
David Perez
8455f7f706 PM-33441: bug: Add external link callout for start registration screen (#6646) 2026-03-12 17:47:26 +00:00
Patrick Honkonen
bb46c3812f [PM-33394] fix: Surface CookieRedirectException message during sync-on-unlock (#6643) 2026-03-12 15:10:05 +00:00
Patrick Honkonen
9068307928 [PM-33394] debt: Add userFriendlyMessage extension and errorMessage to result types (#6642) 2026-03-12 13:56:49 +00:00
David Perez
04bcd35776 PM-33411: bug: Defer early navigation until lifecycle is resumed (#6638) 2026-03-11 21:26:58 +00:00
David Perez
55e65480f1 PM-33428: bug: Fix loading dialog statusbar content color (#6641) 2026-03-11 21:26:39 +00:00
Patrick Honkonen
5af4af95e4 [PM-33394] fix: Propagate CookieRedirectException error message (#6639) 2026-03-11 18:17:52 +00:00
aj-rosado
417a14fca2 [PM-29673] feat: Improved pre-polutated data on the FlightRecorder logs (#6616) 2026-03-11 14:36:43 +00:00
David Perez
44f5f614b6 PM-29871: bug: Add external link callouts (#6634) 2026-03-10 20:53:16 +00:00
David Perez
9e3360e421 PM-18596: feat: SSN field should be hidden by default (#6628) 2026-03-10 14:50:04 +00:00
David Perez
1b6b46f72e docs: Clean up kdoc issues (#6629) 2026-03-10 14:43:50 +00:00
Patrick Honkonen
6570115d9e [PM-33227] feat: Add Clear SSO Cookies button to debug menu (#6620) 2026-03-09 20:35:59 +00:00
David Perez
ee40623911 Update protobuf library (#6626) 2026-03-09 20:24:05 +00:00
Patrick Honkonen
f99eaafc67 [PM-32123] feat: Propagate informative cookie redirect error message (#6622) 2026-03-09 20:19:24 +00:00
Patrick Honkonen
77d541d033 [PM-33262] feat: Add cookie support to Glide image requests (#6627) 2026-03-09 20:18:39 +00:00
David Perez
2d7475556f PM-29861: Update overflow content description to 'More options' (#6621) 2026-03-09 19:16:34 +00:00
David Perez
e260f1d2a5 PM-29871: Add additional callouts for external links in the app (#6614) 2026-03-09 18:14:10 +00:00
David Perez
5bd15a8fca Update AGP and gradle wrapper (#6619) 2026-03-09 17:51:10 +00:00
David Perez
fa4347db96 PM-33266: Allow the VaultUnlockViewModel and VaultViewModel to safely initialize without a UserState (#6623) 2026-03-09 16:53:24 +00:00
David Perez
d88de04acb PM-26059: Remove CipherKeyEncryption feature flag (#6617) 2026-03-09 16:44:48 +00:00
David Perez
aeed96e210 Remove remember ViewModel (#6618) 2026-03-09 16:41:54 +00:00
bw-ghapp[bot]
6473d54f16 Crowdin Pull (#6625)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-03-09 14:44:43 +00:00
bw-ghapp[bot]
aa23d5e5dc Update SDK to 2.0.0-5451-c73f9161 (#6605)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2026-03-04 20:45:01 +00:00
Daniel James Smith
053ac28e38 Remove Gitter chat badge from README (#6612) 2026-03-04 17:54:54 +00:00
Patrick Honkonen
3400d5f875 llm: Add plan-android-work command and planning skills (#6597) 2026-03-04 13:35:57 +00:00
David Perez
9f274bbffa PM-33112: Avoid double announcement of BitwardenSwitch content description (#6611) 2026-03-04 00:48:15 +00:00
David Perez
cf1455a45a Add Authenticator app-lock timeout (#6609) 2026-03-03 20:14:35 +00:00
Patrick Honkonen
d0dc4200f8 [PM-21659] llm: Add workflow skills and finalize CLAUDE.md restructuring (#6575)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-03 06:57:57 +00:00
David Perez
8a2b46e81a Move the AppStateManager to the data module (#6593) 2026-03-02 20:53:22 +00:00
David Perez
3538ca54ca Update Compose to 2026.02.01 (#6607) 2026-03-02 19:21:24 +00:00
David Perez
5a61ba96f6 Update Firebase BOM (#6606) 2026-03-02 19:21:07 +00:00
David Perez
836233f4d5 Move FakeLifecycle to core module (#6608) 2026-03-02 17:48:20 +00:00
renovate[bot]
3b081faf65 [deps]: Update hilt to v2.59.2 (#6602)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 16:35:30 +00:00
renovate[bot]
61517014a7 [deps]: Update com.google.devtools.ksp to v2.3.6 (#6601)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 16:03:34 +00:00
renovate[bot]
4a1582b1e4 [deps]: Update org.junit:junit-bom to v6.0.3 (#6603)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 16:03:09 +00:00
bw-ghapp[bot]
227224b6cb Crowdin Pull (#6600)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-03-02 15:05:12 +00:00
Patrick Honkonen
60bc6ee0ca [PM-32802] fix: 400 error when archiving/unarchiving org-owned ciphers (#6592) 2026-02-27 20:10:58 +00:00
Patrick Honkonen
e509d60af6 Replace test workflow with sharded parallel CI execution (#6582)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Álison Fernandes <vvolkgang@users.noreply.github.com>
2026-02-27 18:49:47 +00:00
Patrick Honkonen
1f9390a668 [PM-32658] Add skill routing to CLAUDE.md Quick Reference (#6574)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-27 18:13:46 +00:00
Patrick Honkonen
ed1abcac5b [PM-32657] Add build-test-verify skill and extract build/test/deploy sections (#6573) 2026-02-27 16:32:02 +00:00
bw-ghapp[bot]
209e216213 Update SDK to 2.0.0-5425-a6f4a233 (#6595)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-02-27 15:43:26 +00:00
bw-ghapp[bot]
7bde0ce716 Update SDK to 2.0.0-5422-26e2b107 (#6569)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-02-27 11:34:47 +00:00
Patrick Honkonen
a517b3f970 [PM-32656] Fix implementing-android-code skill annotations and formatting (#6572)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-02-26 20:00:19 +00:00
Álison Fernandes
c7d173cf9a [PM-32751] ci: Fix version name output in run summary (#6585) 2026-02-26 18:56:45 +00:00
Patrick Honkonen
38f3d3d720 [PM-32714] Add cookie domain-suffix resolution and fix cloud config path exclusion (#6589) 2026-02-26 16:35:03 +00:00
David Perez
487b163d38 BWA-235: Update Authenticator to use state-based navigation for top-level navigation (#6586) 2026-02-26 15:27:49 +00:00
Patrick Honkonen
52da80e0fc [PM-32780] Disable Claude Code attribution in commits and PRs (#6588) 2026-02-26 15:16:30 +00:00
Álison Fernandes
1abb640512 [PM-32758] ci: Improve CI cache to fix GitHub runners running out of memory (#6583) 2026-02-25 22:24:48 +00:00
David Perez
64a79ff108 PM-29870: Add explicit traversal order for scaffold (#6580) 2026-02-25 18:08:20 +00:00
David Perez
fd6d32ec09 PM-31772: Simplify origin for verified sources (#6577) 2026-02-25 17:13:47 +00:00
David Perez
4ca79bb8c7 Remove unnecessary opt-in annotations (#6581) 2026-02-25 17:11:38 +00:00
Patrick Honkonen
642456f2fe [PM-32655] Extract troubleshooting guide into docs/TROUBLESHOOTING.md (#6571)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-25 16:07:10 +00:00
André Bispo
7b1b519b0d [PM-30916] bug: Create passkey myitems (#6558) 2026-02-25 15:12:35 +00:00
David Perez
d51d6c7c54 PM-29867: Fix notifications announcement (#6570) 2026-02-24 21:59:53 +00:00
Patrick Honkonen
4adb46170d [PM-32566] Refactor cookie acquisition ViewModel and simplify tests (#6564)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-24 16:16:26 +00:00
bw-ghapp[bot]
3360999706 Update SDK to 2.0.0-5335-7a22aa7f (#6562)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-02-24 11:59:16 +00:00
Patrick Honkonen
b10568a3ae Add implementing-android-code skill and deduplicate CLAUDE.md (#6534)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-02-24 07:09:15 +00:00
David Perez
d9f6fe97ff PM-32607: Label headers for accesibility (#6567) 2026-02-23 22:08:32 +00:00
David Perez
89f70a6b18 PM-29871: Add external links announcements (#6566) 2026-02-23 17:48:35 +00:00
David Perez
8b2aaf9c79 PM-29866: Remove redundant content description in icon buttons (#6565) 2026-02-23 17:41:13 +00:00
bw-ghapp[bot]
c9f3afa851 Crowdin Pull (#6561)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-02-23 12:57:50 +00:00
bw-ghapp[bot]
5ef7482fae Update SDK to 2.0.0-5302-1693d4d4 (#6549)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-02-23 09:42:08 +00:00
David Perez
c69f3554c6 PM-30892: Fix radio button spacing (#6559) 2026-02-20 23:15:52 +00:00
David Perez
c6b4c490ca Replace ZonedDateTime with Instant (#6554) 2026-02-20 19:02:25 +00:00
David Perez
92664b6752 Fix incorrect apostrophe (#6557) 2026-02-20 16:48:30 +00:00
Hunter Wittenborn
06284a31df [PM-32356] Fix: Use soft logout for token refresh failures to preserve account (#6545)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-19 21:59:30 +00:00
aj-rosado
794781213e [PM-31835] feat: add generator copy password field on send (#6508) 2026-02-19 19:50:10 +00:00
aj-rosado
d1cf808e97 [PM-31810] Added logic to gate Send auth verification behind premium (#6556) 2026-02-19 19:10:59 +00:00
Álison Fernandes
4356156aad [PM-32200] ci: Add workflow to enforce PR labels (#6530) 2026-02-19 18:32:29 +00:00
David Perez
268be4210e PM-29863: Update segmented control font (#6555) 2026-02-19 17:47:05 +00:00
aj-rosado
4ee55111f4 [PM-32149] Send email verification error dialogs (#6535) 2026-02-19 15:30:18 +00:00
Patrick Honkonen
1a6936262c [PM-32122] Add cookie acquisition navigation (#6529)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-02-18 18:11:43 +00:00
David Perez
6f19ae534f Clean up ColorExtensions tests (#6551) 2026-02-18 13:26:07 +00:00
Patrick Honkonen
46a8236ef7 Update RootNavScreen docs (#6553) 2026-02-18 13:24:40 +00:00
Patrick Honkonen
f6f630ff8c [PM-32121] Add CookieAcquisition screen and ViewModel (#6523)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-17 20:44:44 +00:00
David Perez
bd0640e5b4 PM-32353: Archive and Unarchive buttons should honor MP reprompt (#6546) 2026-02-17 18:55:46 +00:00
Patrick Honkonen
436ae9333c [PM-29885] Implement SSO cookie vending authentication flow (#6522)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-17 18:41:07 +00:00
Patrick Honkonen
9b13cd4498 [PM-30703] Introduce CXF payload parser and update to alpha05 (#6347)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-17 18:20:52 +00:00
Ignacio
f6cd94485a [PM-32022] Fix browser autofill dialog showing for non-default browsers (#6514) 2026-02-17 16:57:12 +00:00
David Perez
222bc44c99 PM-32354: Filter out archived items from CXP (#6547) 2026-02-17 15:34:38 +00:00
github-actions[bot]
275d90bb61 Update Google privileged browsers list (#6538)
Co-authored-by: GitHub Actions Bot <actions@github.com>
2026-02-17 14:40:56 +00:00
David Perez
a23183597c PM-32252: Update View Item date information layout (#6544) 2026-02-17 14:30:00 +00:00
David Perez
e3ab4f3d68 Update AGP to v9.0.1 (#6543) 2026-02-17 14:27:58 +00:00
bw-ghapp[bot]
34a7c4455c Update SDK to 2.0.0-5210-4ffddfe5 (#6533)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-02-17 13:34:52 +00:00
renovate[bot]
4a68c2343d [deps]: Update com.google.devtools.ksp to v2.3.5 (#6541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-16 16:36:43 +00:00
André Bispo
fb9d16730e [PM-30870] Fix editing blocked autofill URIs (#6532) 2026-02-16 15:51:10 +00:00
renovate[bot]
5c348ac360 [deps]: Lock file maintenance (#6542)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-16 15:47:20 +00:00
bw-ghapp[bot]
3985817c16 Crowdin Pull (#6539)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-02-16 15:45:56 +00:00
Patrick Honkonen
8664ce4614 [PM-32251] Decouple SDK token repository from network module (#6537)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-13 22:39:31 +00:00
David Perez
f3c746fd49 Update Anroidx dependencies (#6536) 2026-02-13 22:27:54 +00:00
aj-rosado
ce3f0acf74 [PM-31614] feat: Added new UI for the Email verification on sends (#6488) 2026-02-13 22:19:09 +00:00
bw-ghapp[bot]
b20622e7d6 Update SDK to 2.0.0-5131-c0c3ee5f (#6531)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-02-13 12:20:38 +00:00
David Perez
e939b20a82 PM-31664: Add new SnackbarRelay type specific for the View Screen (#6528) 2026-02-12 21:10:27 +00:00
David Perez
a8e77a5abc PM-32146: Add back 'parent' param to webAuthn url (#6527) 2026-02-12 18:57:41 +00:00
aj-rosado
afa9c28341 [PM-31615] feat: Updated Send network models to support email verification (#6519) 2026-02-12 16:43:05 +00:00
bw-ghapp[bot]
bb44586d76 Update SDK to 2.0.0-5087-3e8a45eb (#6521)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-02-12 11:40:57 +00:00
Patrick Honkonen
4cdd0b8422 [PM-32029] Implement SDK interfaces for cookie management (#6517)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <SaintPatrck@users.noreply.github.com>
2026-02-11 21:02:46 +00:00
David Perez
5a4973d678 PM-31925: Replace 'android' reference with logic in LibraryExtension (#6520) 2026-02-11 17:17:23 +00:00
Patrick Honkonen
a914d12e6f [PM-80371] Enhance CLAUDE.md using bitwarden-init plugin (#6368)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-02-11 17:03:11 +00:00
David Perez
e5875cd8fe PM-31922: Remove deprecated Android block where possible (#6512) 2026-02-11 15:55:33 +00:00
bw-ghapp[bot]
a3aefd369a Update SDK to 2.0.0-5064-8700dc73 (#6513)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <SaintPatrck@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2026-02-11 15:25:41 +00:00
Mick Letofsky
60a1265c5d Slim down and align with our current practices (#6518) 2026-02-11 13:07:02 +00:00
David Perez
95272d9692 Update Kover to v0.9.7 (#6516) 2026-02-10 23:31:48 +00:00
Patrick Honkonen
3be5bead89 [PM-32011] Add cookie callback flow to AuthRepository (#6510)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-10 22:33:32 +00:00
David Perez
31d480d6b4 PM-31953: Support multiple schemes for Duo, WebAuthn, and SSO callbacks (#6498) 2026-02-10 20:21:40 +00:00
bw-ghapp[bot]
43940102ff Update SDK to 2.0.0-5046-d59280a3 (#6511)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-02-10 18:41:45 +00:00
Patrick Honkonen
253f0d7ec4 [PM-31993] Add cookie vendor deep link intent filter (#6507)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-10 18:26:06 +00:00
David Perez
d7428a15bc PM-31924: Remove the 'android.dependency.useConstraints' gradle property (#6509) 2026-02-10 18:24:30 +00:00
Patrick Honkonen
5d84df9d31 [PM-31993] Add deep link utilities for cookie vendor callbacks (#6506)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-10 16:17:54 +00:00
Patrick Honkonen
d8c69a3243 [PM-31982] Add CookieDiskSource for cookie persistence (#6504)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <SaintPatrck@users.noreply.github.com>
2026-02-10 15:39:16 +00:00
Eran Boudjnah
f0837f7668 [PM-22523] PM-19476: Allow empty string as word separator (#5334) 2026-02-10 14:00:46 +00:00
Marc Nguyen
f094430d6c [PM-31980] Fix passkeys on some browsers by fixing JSON parsing (#6502) 2026-02-10 13:44:14 +00:00
Patrick Honkonen
cf3660a5aa [PM-31954] Add server communication models to ConfigResponseJson (#6500) 2026-02-10 13:17:34 +00:00
bw-ghapp[bot]
5300386ce3 Update SDK to 2.0.0-5021-f954d14b (#6495)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-02-10 12:20:00 +00:00
David Perez
eb24a50baa Update to Kotlin v2.3.10 (#6499) 2026-02-10 09:12:26 +00:00
David Perez
4d31dccc74 Update the gradlew Wrapper to v9.3.1 (#6496) 2026-02-09 22:20:54 +00:00
David Perez
8ee721c8ae PM-31927: Pre-emptively patch Brave browser Autofill bug (#6497) 2026-02-09 21:32:19 +00:00
David Perez
c0907b867b PM-31926: Add Autofill reminder for Vivaldi browser (#6494) 2026-02-09 21:26:04 +00:00
David Perez
6eba9ecd4b Update Firebase BOM to v34.9.0 (#6493) 2026-02-09 21:25:43 +00:00
David Perez
594cb507df Update the ZonedDateTimeSerializer to be more lenient when deserializing (#6489) 2026-02-09 14:58:09 +00:00
bw-ghapp[bot]
e615bdbea5 Update SDK to 2.0.0-5002-7f4059e7 (#6481)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-02-09 14:15:16 +00:00
bw-ghapp[bot]
071d3c8cd5 Crowdin Pull (#6491)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-02-09 14:08:41 +00:00
David Perez
ad3a9a6c2e Update AGP to v9.0.0 (#6479) 2026-02-06 20:55:11 +00:00
David Perez
cbe13d2015 PM-31735: Add the archivedDate property to the updateCipher API (#6483) 2026-02-05 20:23:18 +00:00
Patrick Honkonen
f728c15794 Configure Claude to use the Bitwarden marketplace (#6484) 2026-02-05 18:16:08 +00:00
David Perez
586f24ffec PM-31734: Add archived item filtering for passkeys (#6482) 2026-02-05 17:05:42 +00:00
Patrick Honkonen
8e8367a82f [PM-31775] Refactor popUpToCompleteRegistration to use type-safe KClass reference (#6480) 2026-02-05 16:44:01 +00:00
David Perez
47b9509062 Update build optimizations (#6433) 2026-02-04 20:08:15 +00:00
David Perez
29648e03c8 Update protobuf to v4.33.5 (#6478) 2026-02-04 17:36:20 +00:00
David Perez
15e217bc49 PM-31656, PM-31658, PM-31659: Address Archive feature bugs (#6473) 2026-02-04 17:33:29 +00:00
bw-ghapp[bot]
7ec4faf424 Update SDK to 2.0.0-4872-065ef30b (#6464)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-02-04 17:30:35 +00:00
Patrick Honkonen
e31fa46a73 [PM-30279] Extract credential provider handling to dedicated activity (#6472)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:05:28 +00:00
David Perez
aff8b0347b Update test tools (#6468) 2026-02-03 21:54:42 +00:00
renovate[bot]
f4d34e4649 [deps]: Update androidx.credentials:credentials to v1.6.0-rc01 (#6455)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 21:35:42 +00:00
David Perez
2e18b079f8 Update Androidx dependencies (#6467) 2026-02-03 18:29:31 +00:00
aj-rosado
b0eea88af2 [PM-31613] Add send email verification feature flag (#6470) 2026-02-03 17:09:15 +00:00
Patrick Honkonen
4cac4d6a6e Add comprehensive tests for Unlock feature (#6426)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 14:33:58 +00:00
David Perez
a2ec99fb05 Remove the configuration cache to avoid play store build issues (#6466) 2026-02-02 19:10:20 +00:00
Patrick Honkonen
d49629de9e Add Android testing skill for Claude (#6370)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 18:42:01 +00:00
renovate[bot]
c85cbb70a1 [deps]: Lock file maintenance (#6460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-02 18:33:13 +00:00
github-actions[bot]
e482820201 Update Google privileged browsers list (#6452)
Co-authored-by: GitHub Actions Bot <actions@github.com>
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
2026-02-02 17:59:57 +00:00
Shamim Shahrier Emon
74d45c3906 [PM-31393] Sends: UI/UX inconsistency of the password field (#6435) 2026-02-02 17:58:19 +00:00
Lucas
12eb42097c [PM-30259] Add iodéOS browser to community FIDO2 privileged list (#6298) 2026-02-02 17:34:29 +00:00
David Perez
0811d14606 PM-31603: Add toast when resetpassword succeeds (#6465) 2026-02-02 17:26:01 +00:00
Ruyut
365067e5be [PM-31583] Fix typos in authentication-related KDoc comments (#6461) 2026-02-02 15:29:31 +00:00
bw-ghapp[bot]
9652c7e049 Crowdin Pull (#6453)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-02-02 14:14:55 +00:00
bw-ghapp[bot]
6cc519bc3f Update SDK to 2.0.0-4835-5285d3fc (#6446)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-02-02 14:07:02 +00:00
aj-rosado
9f82b42e36 [BWA-182] Add mTLS support for Glide image loading (#6125)
Co-authored-by: David Perez <david@livefront.com>
2026-01-30 19:57:59 +00:00
Patrick Honkonen
5531b478d3 Add comprehensive tests for FileManagerImpl (#6425)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 19:18:53 +00:00
Ruyut
fe5b61bf25 [PM-31445] Fix minor KDoc typos and wording issues. (#6441) 2026-01-30 19:15:03 +00:00
Patrick Honkonen
92ba38c831 [PM-31446] fix:Append assetlinks.json path to DAL URLs (#6447) 2026-01-30 18:22:00 +00:00
Patrick Honkonen
675b346666 Add comprehensive tests for ExportViewModel (#6442)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:16:24 +00:00
Patrick Honkonen
0f087b7d15 Add comprehensive tests for AuthenticatorRepositoryImpl (#6424)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 15:41:21 +00:00
Álison Fernandes
99a6dd7647 [PM-31436] Consolidate Feature categories in release notes and add labels (#6439) 2026-01-30 14:01:08 +00:00
bw-ghapp[bot]
ea4df7dde9 Update SDK to 2.0.0-4818-c1e4bb66 (#6444)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-30 12:08:39 +00:00
Álison Fernandes
f541919d39 [PM-31292] ci: update renovate config to remove gradle group and ignore sdk updates (#6437) 2026-01-29 21:06:49 +00:00
Amy Galles
3d1f46983a use option to determine if release will be marked latest (#6417) 2026-01-29 18:41:36 +00:00
David Perez
b0084d2f1f Set cache problem to warning (#6436) 2026-01-29 16:35:10 +00:00
David Perez
0d0a5cb292 Item migration flow has been moved into a graph (#6427) 2026-01-29 15:16:02 +00:00
bw-ghapp[bot]
ebfe293c81 Update SDK to 2.0.0-4800-bed92cae (#6431)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-29 13:49:01 +00:00
Patrick Honkonen
254b2cd25b Add comprehensive tests for Import Parsers and UuidManager (#6423)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 21:55:06 +00:00
Patrick Honkonen
3d974d710c [PM-31370] Refactor stringToUri and consolidate FileManager (#6432)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 21:37:24 +00:00
bw-ghapp[bot]
7717a09c06 Update SDK to 2.0.0-4772-490c1be4 (#6395)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-01-28 18:55:02 +00:00
David Perez
674cff1c3c PM-31363: Fix crash caused by a duplicate ID (#6428) 2026-01-28 18:45:57 +00:00
Álison Fernandes
ca9ec45548 [PM-31343] Fix dependencies listed under Maintenance by adding a new fallback section to release.yml (#6420) 2026-01-28 14:59:34 +00:00
David Perez
009136ce1e Minor cleanup of the MigrateToMyItemsScreen (#6421) 2026-01-28 14:59:22 +00:00
David Perez
19a3697605 Remove intialization of NetworkConnectionManager from application class (#6419) 2026-01-28 14:57:10 +00:00
David Perez
954571ff4a Optimize build times (#6418) 2026-01-27 19:01:20 +00:00
David Perez
66316e4bd2 Cleanup organizations (#6391) 2026-01-27 17:28:09 +00:00
David Perez
9463cf646b Update Kotlin and associated dependencies (#6408) 2026-01-27 17:14:39 +00:00
David Perez
e81710c24f GradlewWrapper updates (#6415) 2026-01-27 17:14:24 +00:00
David Perez
71466405fa Update testing tools (#6407) 2026-01-27 15:21:52 +00:00
David Perez
618bdc7424 Update protobufs to v4.33.4 (#6414) 2026-01-27 15:21:30 +00:00
David Perez
0f05e30997 Update the Compose BOM to v2026.01.00 (#6401) 2026-01-27 15:21:13 +00:00
David Perez
006a13d5ac Update Sonarqube to v7.2.2.6593 (#6406) 2026-01-26 21:48:01 +00:00
David Perez
1d35004999 Update the Gradle Wrapper to the latest version (#6405) 2026-01-26 17:42:53 +00:00
David Perez
85249987aa Update app version name to 2026.2.0 (#6409) 2026-01-26 17:42:27 +00:00
bw-ghapp[bot]
f05cf773fb Crowdin Pull (#6412)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-01-26 14:54:34 +00:00
Patrick Honkonen
2e311b6c4a [PM-30899] Store account keys upon SSO user creation (#6384) 2026-01-23 19:51:25 +00:00
David Perez
ee5ed77bc1 Update to Junit v6.0.2 (#6402) 2026-01-23 18:43:33 +00:00
aj-rosado
04a3cd227e [PM-30644] Removing special circumstance validation from MigrateToMyItems route (#6358) 2026-01-23 17:06:31 +00:00
aj-rosado
ec28dde6d2 [PM-31081] Added snackbar when items are successfully migrated (#6394) 2026-01-23 16:51:41 +00:00
David Perez
319872ccf9 PM-29693: Add introducing archive action card to vault screen (#6390) 2026-01-23 16:50:43 +00:00
aj-rosado
9f1fad8be0 [PM-28990] Skipping vault migration on Network or Timeout error (#6393) 2026-01-23 16:06:17 +00:00
aj-rosado
0395d489c2 [PM-31069] Add OrganizationId support for Vault Migration operations (#6397) 2026-01-23 16:05:55 +00:00
David Perez
2acf429f67 PM-29696: Add action card for lapsed premium subscription (#6389) 2026-01-23 15:24:00 +00:00
David Perez
721fbbb82c PM-31162: Update copy on the snackbar for archive feature (#6399) 2026-01-23 15:07:27 +00:00
David Perez
6d198bd8c9 Update to Firebase v34.8.0 (#6396) 2026-01-23 15:07:09 +00:00
Álison Fernandes
8658f1d42c [PM-14880] ci: Address automated PR labeling workflow feedback (#6400) 2026-01-22 21:25:09 +00:00
Shamim Shahrier Emon
acc3e24d65 [PM-30664] Unlock with PIN doesn’t appear as enabled after enabling ‘Require master password on app restart’ (#6344) 2026-01-21 18:42:02 +00:00
bw-ghapp[bot]
40c8346bf7 Update SDK to 2.0.0-4676-0544ddec (#6388)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-21 17:24:18 +00:00
aj-rosado
a7badf8b0b [PM-28470] Implement revoke from organization (#6383)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-21 16:53:26 +00:00
David Perez
c52910e74a PM-31043: Add unarchive button to overflow menus (#6387) 2026-01-21 16:50:30 +00:00
David Perez
afc1ff4d7a PM-31042: Add overflow archive button (#6385) 2026-01-21 14:50:00 +00:00
bw-ghapp[bot]
8cb4fab1de Update SDK to 2.0.0-4672-b3e4ea24 (#6371)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-01-21 11:24:07 +00:00
David Perez
f79113aa7f Fix minor typos (#6386) 2026-01-20 21:49:38 +00:00
renovate[bot]
7d814df04e [deps]: Lock file maintenance (#6382)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 18:45:36 +00:00
David Perez
49b208f013 PM-29697: Finish View and Edit Cipher UI for archive (#6377) 2026-01-20 18:43:14 +00:00
bw-ghapp[bot]
8d33e6660a Crowdin Pull (#6380)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-01-20 15:48:58 +00:00
David Perez
27a0f5172c Move Vault Listing Dialog clicks to VaultItemListingHandlers (#6375) 2026-01-16 19:50:26 +00:00
David Perez
3e470ebc25 PM-30868: Archive Banner on Edit Item Screen (#6367) 2026-01-16 19:48:15 +00:00
aj-rosado
eb18ca04a0 [PM-28471] Migrate individual vault to organization (#6352) 2026-01-16 19:11:43 +00:00
David Perez
759e0563a9 PM-30897: Add archive and unarchive button on Edit Cipher Screen (#6372) 2026-01-16 17:17:19 +00:00
David Perez
757f444493 PM-29694: Update archive empty state (#6369) 2026-01-15 18:29:16 +00:00
David Perez
98ba1690bf PM-30807: Add archived header to ViewItem Screen (#6362) 2026-01-15 15:45:01 +00:00
David Perez
44274a888e PM-30795: Update cipher filtering logic for archive (#6359) 2026-01-15 15:14:26 +00:00
bw-ghapp[bot]
77cc0d5fba Update SDK to 2.0.0-4524-513f18bf (#6361)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-15 15:13:40 +00:00
Álison Fernandes
026393384b [PM-30823] ci: Fix BWA Play Store publishing for rc cherry picks and update upload step names (#6360) 2026-01-15 14:42:45 +00:00
Patrick Honkonen
7daeaca63e refactor(claude): Refine reviewing-changes skill description for clarity and usage (#6366) 2026-01-15 13:59:58 +00:00
Gavin Gui
353e7e9a4e [PM-30394] PM-29960: Skip biometric prompt on Xiaomi HyperOS (#6316) 2026-01-14 16:08:57 +00:00
bw-ghapp[bot]
2d824f96f5 Update SDK to 2.0.0-4505-df9bd639 (#6355)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-14 16:00:14 +00:00
David Perez
a9b1623f8b PM-30774: Add archiving and unarchiving network requests (#6356) 2026-01-14 14:49:01 +00:00
David Perez
6d72d3a1c9 PM-30767: Add archive row to Vault Screen (#6354) 2026-01-13 21:41:45 +00:00
David Perez
f6edc19595 Remove the unused showDivider flag from BitwardenGroupItem (#6353) 2026-01-13 16:42:51 +00:00
David Perez
45125a94c2 Update archive string with noun suffix (#6351) 2026-01-13 16:38:05 +00:00
David Perez
66900f71df End subtext and end icon support to BitwardenGroupItem (#6349) 2026-01-13 15:27:32 +00:00
bw-ghapp[bot]
d12c546c9a Update SDK to 2.0.0-4498-7681828f (#6350)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-13 13:08:36 +00:00
David Perez
be365eec1c PM-30708: Add archive item navigation (#6348) 2026-01-12 21:30:04 +00:00
Álison Fernandes
d86959b375 [PM-14880] ci: Update feature labels (#6346) 2026-01-12 18:45:23 +00:00
bw-ghapp[bot]
282cce8ce0 Update SDK to 2.0.0-4479-ad9fb51d (#6345)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-01-12 17:00:34 +00:00
bw-ghapp[bot]
e8eaf4e68c Crowdin Pull (#6342)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-01-12 15:06:13 +00:00
Patrick Honkonen
41dfc2b6e8 Improve KDoc on StateFlowExtensions (#6338) 2026-01-09 16:38:43 +00:00
Patrick Honkonen
7bfd4b5a6c Document best practices for Clock/Time handling (#6340) 2026-01-09 14:53:38 +00:00
bw-ghapp[bot]
557b667dab Update SDK to 2.0.0-4441-c5a3b833 (#6333)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-09 14:37:39 +00:00
aj-rosado
eff4ce7abb [PM-28468] Updated validation and navigation for MigrateToMyItems (#6279)
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2026-01-08 21:29:28 +00:00
David Perez
577e3c04e3 Add the Archive items feature flag (#6337) 2026-01-08 20:55:04 +00:00
David Perez
203313eb1d Improve clock usage patterns (#6336) 2026-01-08 20:54:33 +00:00
David Perez
5d308aa95f PM-30522: Add support for processing app links for Duo, WebAuthn, and SSO (#6332) 2026-01-07 19:45:04 +00:00
David Perez
c4a94cf5d1 Add concrete FlightRecorderDiskSource (#6281) 2026-01-07 19:30:53 +00:00
Lucas
5245a7a0c7 [PM-30258] Remove CalyxOS Chromium from the FIDO2 privileged list (#6297) 2026-01-07 17:13:59 +00:00
bw-ghapp[bot]
9432df6ff4 Update SDK to 2.0.0-4408-ef987b96 (#6331)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-07 16:33:15 +00:00
David Perez
461e1e1ff9 Update generated SSO uri to be typed (#6329) 2026-01-06 21:02:36 +00:00
David Perez
a8ef32ae76 Allow trailing commas in JSON (#6326) 2026-01-06 15:40:37 +00:00
Patrick Honkonen
769bfc83af [VULN-362] Move Compose tooling dependency to debugImplementation (#6327) 2026-01-06 15:25:20 +00:00
Patrick Honkonen
29d84d69f5 [PM-28271] Rename validatePin to validatePinUserKey and update SDK usage (#6323) 2026-01-05 22:08:21 +00:00
David Perez
05d003edb2 Update Firebase BOM to latest versions (#6324) 2026-01-05 21:22:59 +00:00
bw-ghapp[bot]
03562a8605 Update SDK to 2.0.0-4373-3c666766 (#6311)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2026-01-05 18:27:32 +00:00
Lucas
e6c46169fb [PM-30260] Add WebLibre to the FIDO2 privileged community list (#6299) 2026-01-05 18:24:21 +00:00
David Perez
7d4d7a25b5 Update Androidx dependencies (#6322) 2026-01-05 17:06:28 +00:00
renovate[bot]
1cb37b8458 [deps]: Lock file maintenance (#6320)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 17:05:52 +00:00
David Perez
3c7b70f325 PM-30389: Allow for different auth tab schemes (#6315) 2026-01-05 16:36:17 +00:00
David Perez
9a8c504c8b Move TestHelpers to core test-fixtures module (#6314) 2026-01-05 15:57:13 +00:00
bw-ghapp[bot]
b07a92f7d6 Crowdin Pull (#6317)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-01-05 15:29:42 +00:00
Mick Letofsky
674cde9869 Revert review Code Triggered by labeled event (#6310) 2025-12-31 16:20:51 +00:00
Patrick Honkonen
28c9637655 [deps]: Update Google ProtoBuf dependencies (#6308) 2025-12-30 19:01:39 +00:00
bw-ghapp[bot]
2d228b8496 Update SDK to 2.0.0-4254-6c954013 (#6218)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-12-30 18:12:39 +00:00
bw-ghapp[bot]
3bc538c1f8 Crowdin Pull (#6286)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-12-30 17:58:21 +00:00
Mick Letofsky
99717ab5d5 Review Code Triggered by labeled event (#6307) 2025-12-30 16:56:06 +00:00
Álison Fernandes
d98e459129 [PM-14880] Add pull-request trigger to PR Labeling workflow and address test findings (#6305) 2025-12-30 14:39:00 +00:00
Álison Fernandes
ebed1bd3cd [PM-14880] Label updates to fido2 privileged apps lists (#6304) 2025-12-29 21:00:16 +00:00
Álison Fernandes
f4e23e85d2 [PM-14880] ci: Update labels of automated PRs; set labels for PRs created by the crowdin-pull.yml workflow (#6303) 2025-12-29 20:17:14 +00:00
Álison Fernandes
474acc05a6 [PM-14880] ci: Adds categories for automated release notes (#6302) 2025-12-29 20:16:01 +00:00
aj-rosado
87faba6824 Updated sdk to a version that fixes the password protected export issues (1.0.0-4328-km-fix-cherry-pick) (#6300) 2025-12-29 15:29:22 +00:00
renovate[bot]
89fb9c92d3 [deps]: Lock file maintenance (#6292)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 15:17:45 +00:00
renovate[bot]
77a58f344d [deps]: Update actions/upload-artifact action to v6 (#6290)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 15:17:08 +00:00
renovate[bot]
dda32075d0 [deps]: Update actions/checkout action to v6 (#6289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 15:16:08 +00:00
renovate[bot]
038931312d [deps]: Update actions/cache action to v5 (#6288)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 15:15:11 +00:00
Patrick Honkonen
7cd0e2c176 PM-29843: Record item org migration events (#6275) 2025-12-29 14:18:10 +00:00
Álison Fernandes
0975144342 [PM-29913] ci: Fix release notes fetch failure while creating GitHub Releases (#6282) 2025-12-19 20:59:01 +00:00
Patrick Honkonen
07415844ee [PM-29947] Remove ResetMasterPassword property from token response model (#6285) 2025-12-19 15:34:48 +00:00
David Perez
913d877737 Remove flaky tests (#6278) 2025-12-18 21:47:12 +00:00
Katherine Bertelsen
c16da5090e [PM-29911] Update cron jobs to run at midnight on Sundays (#6280) 2025-12-18 14:50:32 +00:00
David Perez
b79aca7338 Move extensions to common module (#6276) 2025-12-17 16:19:20 +00:00
David Perez
7834d5bf27 PM-29827: Move FlightRecorderManager to common data module (#6274) 2025-12-16 17:37:51 +00:00
Patrick Honkonen
7c929c3713 [PM-29842] Add organization event types for item migration acceptance and rejection (#6273) 2025-12-16 15:38:16 +00:00
Patrick Honkonen
7f032a8732 PM-29824: Add bulk share ciphers network layer implementation (#6271) 2025-12-16 14:12:33 +00:00
David Perez
ef6714fa17 PM-29806: Move FlightRecorderWriter to the data module (#6270) 2025-12-15 21:43:17 +00:00
Patrick Honkonen
d09945d80b [PM-29297] Add MigrateToMyItemsScreen (#6239)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-15 20:49:43 +00:00
David Perez
30ce512091 PM-29442: Change 2fa field to not be a password field (#6269) 2025-12-15 18:58:44 +00:00
David Perez
bdbcd5bdc2 PM-29795: Move FileManager to data module (#6268) 2025-12-15 18:19:32 +00:00
David Perez
b4414073c7 Update Mockk and Kover (#6260) 2025-12-12 16:40:34 +00:00
David Perez
1594de39c1 Update Androidx Camera to v1.5.2 (#6259) 2025-12-12 16:39:00 +00:00
David Perez
f0c5c8f421 Update to AGP v8.13.2 (#6258) 2025-12-12 16:38:15 +00:00
bw-ghapp[bot]
2a343555bf Crowdin Pull (#6261)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-12-12 15:38:39 +00:00
David Perez
dff6a13cd7 Update OkHttp to v5.3.2 (#6257) 2025-12-11 19:33:29 +00:00
Patrick Honkonen
e415145c53 PM-29491: Implement LeaveOrganizationScreen (#6253)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 16:35:15 +00:00
Patrick Honkonen
54ea921b25 Update STYLE_AND_BEST_PRACTICES.md to clarify KDoc requirements and fix whitespace (#6256) 2025-12-11 16:18:23 +00:00
gitclonebrian
e87ffa3902 [BRE-1333] Added permissions to token generation step to limit token scope (#6171) 2025-12-10 22:36:10 +00:00
David Perez
00cded3a02 PM-1908: Push notifications for non-active accounts prompt for future sync (#6252) 2025-12-10 15:27:09 +00:00
David Perez
1503e3f769 PM-29172: Update Authenticator biometric encryption (#6240) 2025-12-10 14:54:44 +00:00
aj-rosado
6840a6c207 [PM-28836] Add AndroidManifest permission for HEADSET_CAMERA (#6251) 2025-12-10 11:09:08 +00:00
Patrick Honkonen
d32e767c62 [PM-28504] Add testharness build workflow with dynamic versioning (#6181)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-09 21:32:11 +00:00
aj-rosado
4a874668f2 [PM-28468] Added service methods to migration to MyItems validation (#6248) 2025-12-09 15:58:23 +00:00
David Perez
cd27fe339d Move BiometricsEncryptionManager into the AuthRepository (#6249) 2025-12-09 15:32:25 +00:00
David Perez
2eb8ad4221 PM-28355: Clear pin data on hard-logout or security stamp (#6232) 2025-12-08 16:51:18 +00:00
renovate[bot]
28db795790 [deps]: Update actions/checkout action to v6 (#6247)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 14:34:31 +00:00
David Perez
8c6782dcb1 Move MissingPropertyException to common location (#6237) 2025-12-05 19:08:39 +00:00
David Perez
127809b8df Address several small lint warning throughout the app (#6233) 2025-12-05 17:47:52 +00:00
aj-rosado
ca13e615ec [PM-28442] Added feature flag for migrate myvault to myitems (#6235) 2025-12-05 16:50:30 +00:00
bw-ghapp[bot]
5e3e8a04aa Crowdin Pull (#6234)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-12-05 14:53:29 +00:00
Patrick Honkonen
8077895eb8 Update ZXing library version (#6230) 2025-12-04 19:52:24 +00:00
Patrick Honkonen
33e9313c6c Update SonarQube plugin version (#6231) 2025-12-04 19:19:54 +00:00
Patrick Honkonen
593bfbf8cf [PM-28352] Add logging to Credential Manager and Origin Manager flows (#6229) 2025-12-04 18:22:45 +00:00
Patrick Honkonen
4905358adb [PM-28467] Add revisionDate to policy JSON model (#6228) 2025-12-04 18:22:23 +00:00
renovate[bot]
02733f785b [deps]: Lock file maintenance (#6197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 18:01:23 +00:00
Patrick Honkonen
8baa4bf041 [PM-29096] Update Fastlane and Gemfile dependencies (#6216) 2025-12-04 16:20:21 +00:00
David Perez
4d20453d0f PM-25632: Ensure that we use lowercase email addresses when creating a fingerprint (#6227) 2025-12-04 15:34:17 +00:00
David Perez
4b951a1df2 PM-28634: Update Autofill terms to support other languages better (#6226) 2025-12-04 14:55:23 +00:00
André Bispo
9349b235bc [PM-27290] Remove password unlock method (#6176) 2025-12-04 10:53:40 +00:00
Patrick Honkonen
e9ab5f2def [PM-29097] Fix privacy statement alignment in landscape mode (#6225) 2025-12-03 22:10:58 +00:00
David Perez
3bef282426 Update Androidx dependencies to the latest versions (#6224) 2025-12-03 21:25:50 +00:00
Patrick Honkonen
e1bb3a4b5d [PM-27118] Restrict Credential Exchange import based on Personal Ownership policy (#6220) 2025-12-03 20:15:53 +00:00
David Perez
1904c4ffb9 PM-28522: Update the LoginWithDevice ui (#6221) 2025-12-03 19:41:34 +00:00
aj-rosado
26e7178300 [PM-28835] Added validations to prevent duplicate press on buttons (#6209) 2025-12-03 17:46:03 +00:00
David Perez
2c01abda46 [deps]: Update ksp (#6217) 2025-12-02 18:20:51 +00:00
bw-ghapp[bot]
b86cbfcd87 Update SDK to 1.0.0-3958-7f09fd2f (#6213)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-12-02 14:57:18 +00:00
aj-rosado
3f303d3f39 [BWA-179] Added clarification of functionality on Authenticator's ExportScreen (#6190) 2025-12-02 10:01:00 +00:00
David Perez
ca7a65fc95 PM-28522: Update the Login With Device Screen (#6184) 2025-12-01 16:25:30 +00:00
bw-ghapp[bot]
f02b374e98 Update SDK to 1.0.0-3928-2cca3d46 (#6205)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-12-01 14:26:56 +00:00
bw-ghapp[bot]
1a90860080 Crowdin Pull (#6206)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-12-01 14:16:24 +00:00
Patrick Honkonen
adf83cd315 [PM-28157] Revert "Add string extension to prefix URIs with www" (#6192)
Co-authored-by: Álison Fernandes <vvolkgang@users.noreply.github.com>
2025-12-01 14:12:14 +00:00
Patrick Honkonen
489c0ea8d6 Enhance code review skill documentation with TOCs and missing severity categories (#6186)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-27 19:31:25 +00:00
bw-ghapp[bot]
9831358a8b Update SDK to 1.0.0-3908-4b0d1280 (#6201)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-11-26 23:55:56 +00:00
bw-ghapp[bot]
8bdbccd8de Update SDK to 1.0.0-3896-f75a58cd (#6198)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2025-11-25 00:57:36 +00:00
bw-ghapp[bot]
a75d904317 Update SDK to 1.0.0-3674-c60a5d79 (#6064)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-11-21 22:07:08 +00:00
Patrick Honkonen
a395f28eba [PM-28086] Add testharness for Credential Manager and Autofill testing (#6159)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-21 19:56:24 +00:00
bw-ghapp[bot]
53e358d7b3 Crowdin Pull (#6189)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-11-21 13:16:39 +00:00
David Perez
663eb3641f PM-28545: Remove the compatibility mode toggle from the Autofill screen (#6188) 2025-11-21 13:15:40 +00:00
David Perez
ab305b2631 PM-28525: Update the LoginApprovalScreen ui (#6187) 2025-11-20 22:34:36 +00:00
aj-rosado
946b0784e0 [PM-27816] Not clearing the fingerprint on requests that don't return fingerprint on LoginWithDevice (#6185) 2025-11-20 19:57:04 +00:00
Patrick Honkonen
167a46a073 [PM-21391] Remove debug credential provider configuration (#6182)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 19:15:35 +00:00
Patrick Honkonen
7b491d3c3c [PM-28157] Add string extension to prefix URIs with www (#6183)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 15:32:54 +00:00
David Perez
7918abdccf PM-28492: Replace Authenticator Toasts with Snackbars (#6180) 2025-11-19 22:04:28 +00:00
Nailik
5ec0a1986d [PM-24148] add credential manager provider for create passwords (#5579)
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
2025-11-19 22:00:53 +00:00
renovate[bot]
839e9e8a1a [deps]: Update actions/checkout action to v5 (#6144)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-19 17:38:13 +00:00
David Perez
979237b751 PM-28408: Update CameraPreview composable to address flakey test (#6178) 2025-11-19 15:43:02 +00:00
Dev Sharma
621f97d161 [PM-27869] fix/[PM-26241] : draw out keyboard on talkback click (#6129) 2025-11-18 18:51:15 +00:00
André Bispo
d81b0005ee [PM-27150] React to device changes on device screen unlock method (#6103) 2025-11-18 16:02:35 +00:00
David Perez
794b27a750 Update logic for handling the pin protected user key (#6169) 2025-11-18 15:32:39 +00:00
David Perez
169b21cfdb PM-28053: Ensure any exception thrown during re-auth is an IO exception (#6175) 2025-11-17 20:24:48 +00:00
Álison Fernandes
4623a4f079 [PM-14880] ci: Add automated PR labelling based on file paths and title patterns (#6157) 2025-11-17 20:19:12 +00:00
bw-ghapp[bot]
21afa81322 Crowdin Pull (#6167)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-11-14 15:34:06 +00:00
David Perez
55c7ab4cee Update RTL transitions to go the correct direction (#6166) 2025-11-13 16:19:07 +00:00
Mick Letofsky
7a40bfe522 [PM-27181] - Grant additional permissions for review code (#6165) 2025-11-13 13:55:31 +00:00
Álison Fernandes
0c234ee0aa [PM-28029] ci: add missing permission to fdroid job to fix f-droid build failures (#6163) 2025-11-13 00:28:25 +00:00
David Perez
7b0b93a204 Update Autofill to detect url bar webDomain for certain browsers (#6141) 2025-11-12 22:04:03 +00:00
David Perez
473416c1b4 Update App version name to '2025.11.1' (#6162) 2025-11-12 22:03:36 +00:00
renovate[bot]
f3646790e3 [deps]: Update actions/upload-artifact action to v5 (#6145)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-12 17:26:16 +00:00
David Perez
35b87f4390 Replace blank account name with null (#6158) 2025-11-12 15:11:00 +00:00
David Perez
7120eefc94 PM-28056: Consolidate IntentManager extensions (#6156) 2025-11-11 19:48:58 +00:00
aj-rosado
5eb56cafaa [PM-27119] Prevent import card data when ITEM_RESTRICT_TYPES policy is active (#6123) 2025-11-11 19:24:38 +00:00
renovate[bot]
b2d94fae40 [deps]: Update gradle minor (#6143)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 15:21:39 +00:00
Álison Fernandes
ad748eef7f [PM-28029] Address Sonar Cloud and Linter errors (#6151) 2025-11-11 14:16:55 +00:00
David Perez
8010e8d6c3 All tests should assert if a value is displayed (#6153) 2025-11-11 14:16:22 +00:00
Álison Fernandes
7a6a493f24 [PM-28041] Remove SDK Update PR changelog list size limit (#6152) 2025-11-10 21:58:01 +00:00
Álison Fernandes
4032d2bb5c [PM-27901] Add f-droid fastlane metadata (#6134)
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2025-11-10 20:53:40 +00:00
Patrick Honkonen
e2c9aae9c1 Bump JUnit from 6.0.0 to 6.0.1 (#6149) 2025-11-10 16:48:33 +00:00
Patrick Honkonen
56566b958c Update OkHttp to version 5.3.0 (#6148) 2025-11-10 15:42:51 +00:00
Patrick Honkonen
06bf603ec8 Update Firebase BoM to 34.5.0 (#6150) 2025-11-10 15:06:51 +00:00
Patrick Honkonen
79d6da0a61 Exclude Bitwarden Android SDK from Renovate auto-updates (#6147) 2025-11-10 14:23:58 +00:00
renovate[bot]
2d2ea9acee [deps]: Lock file maintenance (#6146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 13:49:13 +00:00
David Perez
05d6fe1ba1 Fix a test that was not running (#6140) 2025-11-07 21:34:18 +00:00
David Perez
1024f77ddf Clarify package id parsing and AutofillView creation (#6138) 2025-11-07 18:07:20 +00:00
bw-ghapp[bot]
50e50eb08c Crowdin Pull (#6135)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-11-07 15:28:24 +00:00
Patrick Honkonen
bd98df6eb9 PM-27902: Logout user after successful master password reset (#6133)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-07 14:33:07 +00:00
Patrick Honkonen
9baec6e6a5 Enforce strict brevity in reviewing-changes skill output (#6131)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-07 07:18:07 +00:00
David Perez
efd15b027c PM-27845: Move SnackbarRelayManager to ui module (#6127) 2025-11-06 18:50:06 +00:00
aj-rosado
b715a51188 [PM-27806] Reverted changes to order of StorePolicies after sync (#6130) 2025-11-06 16:54:22 +00:00
Patrick Honkonen
94ed32790f [PM-27752] Add certificate signature verification to AuthenticatorBridge (#6126)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-06 13:15:26 +00:00
Patrick Honkonen
dca97e0c8e Refine reviewing-changes skill to eliminate verbosity and praise (#6128)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-05 21:15:40 +00:00
David Perez
aceb96d18f PM-27846: Move DispatcherManager to core module (#6124) 2025-11-05 18:26:44 +00:00
Álison Fernandes
510072b34f [PM-27834] Use Authenticator Bridge as a project reference (#5793) 2025-11-05 14:46:35 +00:00
Patrick Honkonen
7324be04f4 Reduce verbosity in reviewing-changes skill for clean PRs (#6121)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-05 07:05:21 +00:00
David Perez
bfbe47f48f PM-27597: Update Yubikey illustration to match the rest of the app (#6087) 2025-11-04 22:15:40 +00:00
David Perez
2bb06063c7 PM-27817: Consolidate totp parsing with TotpUriUtils (#6122) 2025-11-04 21:51:01 +00:00
David Perez
ed47ff4d18 PM-27771: Improve TOTP parsing (#6119) 2025-11-04 20:17:03 +00:00
David Perez
448ba97ae2 PM-24277: Add language selector to Authenticator (#6120) 2025-11-04 19:09:20 +00:00
David Perez
14e833247d PM-27770: Update error parsing when creating or updating a cipher (#6118) 2025-11-04 19:08:10 +00:00
David Perez
4b7fcdb6ea PM-27756: Create common ExitManager (#6117) 2025-11-04 18:08:50 +00:00
André Bispo
0959284e6f [PM-26736] Fix push notification logout reason serialization (#6116) 2025-11-03 20:33:56 +00:00
David Perez
1f24ca7de1 PM-27755: Create a common LocalQrCodeAnalyzer for both apps (#6115) 2025-11-03 20:13:39 +00:00
André Bispo
dc79176274 [PM-27657] KDF silent update with MasterPasswordUnlock data (#6113) 2025-11-03 19:51:26 +00:00
Patrick Honkonen
a631d6822a [PM-24971] Sanitize passkey attestation options from AliExpress (#6106) 2025-11-03 18:15:00 +00:00
David Perez
845a5dec22 PM-27703: Update Authenticator navigation (#6109) 2025-11-03 16:28:23 +00:00
renovate[bot]
b1195b5f46 [deps]: Update org.sonarqube to v7 (#6082)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 15:07:41 +00:00
Patrick Honkonen
8de3a07715 Optimize reviewing-changes skill (#6099)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2025-11-01 07:28:18 +00:00
David Perez
9c4bd2ee14 Remove unused xml colors from Authenticator (#6108) 2025-10-31 19:59:10 +00:00
David Perez
6dad8c4def PM-27705: Enable filterTouchesWhenObscured in Authenticator for security (#6105) 2025-10-31 19:16:13 +00:00
David Perez
bc74337eae Add Push chevron to Block autofill button (#6102) 2025-10-31 17:37:04 +00:00
bw-ghapp[bot]
d86443c6dd Crowdin Pull (#6101)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-10-31 14:04:47 +00:00
aj-rosado
d07b119802 [PM-27120] cxp hide user account when remove individual export is enabled (#6089) 2025-10-31 10:08:24 +00:00
David Perez
dbf2e9f68a Update Readme compatibility docs (#6100) 2025-10-30 21:50:44 +00:00
David Perez
9ddfd376a9 Fix topAppBar flicker when text is long (#6098) 2025-10-30 20:32:13 +00:00
Patrick Honkonen
dd1dbd0b97 Update androidx.credentials to 1.6.0-beta03 (#6097) 2025-10-30 17:59:28 +00:00
renovate[bot]
f6be363e98 [deps]: Update com.google.devtools.ksp to v2.3.0 (#6080)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2025-10-30 14:48:30 +00:00
David Perez
600744538d Fix deprecation within the app (#6096) 2025-10-29 21:02:03 +00:00
David Perez
de33ba021b Update the Google Protobuf library (#6095) 2025-10-29 21:01:35 +00:00
David Perez
290f59441f Update Kotlin, ksp, and kover to the latest versions (#6094) 2025-10-29 19:27:06 +00:00
David Perez
94c51cacf9 Update Androidx dependencies (#6093) 2025-10-29 16:53:48 +00:00
Dev Sharma
6f27642a30 [PM-27589] [PM-27158] fix : Sub folders always show 0 items (#6092) 2025-10-29 15:54:15 +00:00
Dev Sharma
2ad3014da2 [PM-27516] [PM 27157] Custom text field edit multiline fix (#6088) 2025-10-29 15:44:44 +00:00
renovate[bot]
e6dc8e02f8 [deps]: Lock file maintenance (#6083)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 21:34:02 +00:00
David Perez
c16d31fb33 PM-27494: Update custom vault timeout UI (#6085) 2025-10-27 21:13:36 +00:00
David Perez
43d7b84d0a PM-27136: Update Snackbar font when there is no header (#6086) 2025-10-27 19:53:31 +00:00
André Bispo
c0f8307361 [PM-26420] FlightRecorder vault unlock method (#6084) 2025-10-27 17:55:51 +00:00
mpbw2
064a98f86b [PM-22157] independent version names in build workflows (#6074) 2025-10-27 17:51:56 +00:00
David Perez
e3b111c383 PM-19302: Add support for a typed vault timeout policy (#6078) 2025-10-27 16:28:01 +00:00
Mick Letofsky
52304a266e Implement reusable Claude code review workflow (#6072)
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
2025-10-27 14:19:37 +00:00
David Perez
51c23ec464 Minor clean up for the Account Security Screen (#6076) 2025-10-24 15:55:50 +00:00
André Bispo
7d7951d4ca [PM-27176] Switch to using SDK's init crypto with MasterPasswordUnlock (#6073) 2025-10-24 13:56:44 +00:00
bw-ghapp[bot]
78b1676745 Crowdin Pull (#6077)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-10-24 13:20:49 +00:00
aj-rosado
be27c76bd3 [PM-27092] Changing screen capture flow from event based to state based on Authenticator (#6062) 2025-10-24 09:26:53 +00:00
David Perez
38bdda0a41 Create reusable supporting content composable (#6075) 2025-10-23 20:07:04 +00:00
David Perez
c61fec176a PM-27271: Update selection button disabled state (#6071) 2025-10-23 16:23:06 +00:00
Patrick Honkonen
bb11b17823 [PM-26810] Clear password input after successful OTP verification (#6070) 2025-10-23 13:09:29 +00:00
1569 changed files with 110473 additions and 21093 deletions

View File

@@ -1,105 +1,137 @@
# Claude Guidelines
# Bitwarden Android - Claude Code Configuration
Core directives for maintaining code quality and consistency in the Bitwarden Android project.
Official Android application for Bitwarden Password Manager and Bitwarden Authenticator, providing secure password management, two-factor authentication, and credential autofill services with zero-knowledge encryption.
## Core Directives
## Overview
**You MUST follow these directives at all times.**
- 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
1. **Adhere to Architecture**: All code modifications MUST follow patterns in `docs/ARCHITECTURE.md`
2. **Follow Code Style**: ALWAYS follow `docs/STYLE_AND_BEST_PRACTICES.md`
3. **Error Handling**: Use Result types and sealed classes per architecture guidelines
4. **Best Practices**: Follow Kotlin idioms (immutability, appropriate data structures, coroutines)
5. **Document Everything**: All public APIs require KDoc documentation
6. **Dependency Management**: Use Hilt DI patterns as established in the project
7. **Use Established Patterns**: Leverage existing components before creating new ones
8. **File References**: Use file:line_number format when referencing code
### Key Concepts
## Code Quality Standards
- **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)
- **Result Types**: Custom sealed classes for operation results (never throw exceptions from data layer)
- **UDF (Unidirectional Data Flow)**: State flows down, actions flow up through ViewModels
### Module Organization
---
**Core Library Modules:**
- **`:core`** - Common utilities and managers shared across multiple modules
- **`:data`** - Data sources, database, data repositories
- **`:network`** - Networking interfaces, API clients, network utilities
- **`:ui`** - Reusable Bitwarden Composables, theming, UI utilities
## Architecture
**Application Modules:**
- **`:app`** - Password Manager application, feature screens, ViewModels, DI setup
- **`:authenticator`** - Authenticator application for 2FA/TOTP code generation
```
User Request (UI Action)
|
Screen (Compose)
|
ViewModel (State/Action/Event)
|
Repository (Business Logic)
|
+----+----+----+
| | | |
Disk Network SDK
| | |
Room Retrofit Bitwarden
DB APIs Rust SDK
```
**Specialized Library Modules:**
- **`:authenticatorbridge`** - Communication bridge between :authenticator and :app
- **`:annotation`** - Custom annotations for code generation (Hilt, Room, etc.)
- **`:cxf`** - Android Credential Exchange (CXF/CXP) integration layer
### Key Principles
### Patterns Enforcement
1. **No Exceptions from Data Layer**: All suspending functions return `Result<T>` or custom sealed classes
2. **State Hoisting to ViewModel**: All state that affects behavior must live in the ViewModel's state
3. **Interface-Based DI**: All implementations use interface/`...Impl` pairs with Hilt injection
4. **Encryption by Default**: All sensitive data encrypted via SDK before storage
- **MVVM + UDF**: ViewModels with StateFlow, Compose UI
- **Hilt DI**: Interface injection, @HiltViewModel, @Inject constructor
- **Testing**: JUnit 5, MockK, Turbine for Flow testing
- **Error Handling**: Sealed Result types, no throws in business logic
### Core Patterns
## Security Requirements
- **BaseViewModel**: Enforces UDF with State/Action/Event pattern. See `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt`.
- **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()`.
**Every change must consider:**
- Zero-knowledge architecture preservation
- Proper encryption key handling (Android Keystore)
- Input validation and sanitization
- Secure data storage patterns
- Threat model implications
> For complete architecture patterns, code templates, and module organization, see `docs/ARCHITECTURE.md`.
## Workflow Practices
---
### Before Implementation
## Development Guide
1. Read relevant architecture documentation
2. Search for existing patterns to follow
3. Identify affected modules and dependencies
4. Consider security implications
### Workflow Skills
### During Implementation
> **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. Follow existing code style in surrounding files
2. Write tests alongside implementation
3. Add KDoc to all public APIs
4. Validate against architecture guidelines
## Skills & Commands
### After Implementation
| 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" |
1. Ensure all tests pass
2. Verify compilation succeeds
3. Review security considerations
4. Update relevant documentation
| 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 |
---
## 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.
---
## Code Style & Standards
- **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 (`\"` `\'`). 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`.
---
## Anti-Patterns
**Avoid these:**
- Creating new patterns when established ones exist
- Exception-based error handling in business logic
- Direct dependency access (use DI)
- Mutable state in ViewModels (use StateFlow)
- Missing null safety handling
- Undocumented public APIs
- Tight coupling between modules
In addition to the Key Principles above, follow these rules:
## Communication & Decision-Making
### DO
- Map async results to internal actions before updating state
- Inject `Clock` for time-dependent operations
- Return early to reduce nesting
Always clarify ambiguous requirements before implementing. Use specific questions:
- "Should this use [Approach A] or [Approach B]?"
- "This affects [X]. Proceed or review first?"
- "Expected behavior for [specific requirement]?"
### DON'T
- Update state directly inside coroutines (use internal actions)
- Use `any` types or suppress null safety
- Catch generic `Exception` (catch specific types)
- Use `e.printStackTrace()` (use Timber logging)
- Create new patterns when established ones exist
- Skip KDoc for public APIs
Defer high-impact decisions to the user:
- Architecture/module changes, public API modifications
- Security mechanisms, database migrations
- Third-party library additions
---
## Reference Documentation
## Quick Reference
Critical resources:
- `docs/ARCHITECTURE.md` - Architecture patterns and principles
- `docs/STYLE_AND_BEST_PRACTICES.md` - Code style guidelines
**Do not duplicate information from these files - reference them instead.**
- **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
View 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.

View 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.

View 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.

View 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.

View File

@@ -1,51 +0,0 @@
#!/usr/bin/env python3
"""
PostToolUse Hook - Log tool uses and subagent invocations
Runs after each tool completes successfully.
Logs all tool uses with special handling for Task tool (subagent invocations).
"""
import json
import sys
from pathlib import Path
# Add hooks directory to path
hooks_dir = Path(__file__).parent
sys.path.insert(0, str(hooks_dir))
from logging_utils import get_logger
def main():
"""Log tool use."""
try:
# Read hook input from stdin
hook_input = json.load(sys.stdin)
# Get logger instance
logger = get_logger(hook_input)
# Extract tool information
tool_name = hook_input.get("tool_name", "Unknown")
tool_input = hook_input.get("tool_input", {})
tool_response = hook_input.get("tool_response", {})
# Log the tool use
logger.append_event("ToolUse", {
"tool_name": tool_name,
"tool_input": tool_input,
"tool_response": tool_response
})
# Success
sys.exit(0)
except Exception as e:
# Don't block tool execution on logging errors
print(f"PostToolUse hook error: {e}", file=sys.stderr)
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -1,219 +0,0 @@
# Claude Code Session Logging Hooks
Comprehensive logging hooks that capture all Claude Code session activity for retrospective analysis.
## Overview
These hooks automatically log all session activity to `.claude/skills/retrospecting/logs/` in two formats:
- **NDJSON** (`.ndjson`): Compact, machine-readable logs for Claude/subagent processing
- **Markdown** (`.md`): Human-readable logs for manual review
## Log Formats
### NDJSON Format (Machine-Readable)
Newline-delimited JSON with compact event codes for space efficiency. Each line is a JSON object:
```json
{"e":"start","sid":"session-123","t":"2025-01-15T10:00:00","cwd":"/path"}
{"t":"2025-01-15T10:00:05","e":"up","d":{"prompt":"user message"}}
{"t":"2025-01-15T10:00:10","e":"tu","d":{"tool_name":"Read","tool_input":{...}}}
{"t":"2025-01-15T10:00:15","e":"cr","d":{"response":"claude response"}}
{"t":"2025-01-15T10:01:00","e":"end"}
```
**Event Codes:**
- `start`: Session start
- `up`: User prompt
- `cr`: Claude response
- `tu`: Tool use
- `ss`: Subagent stop
- `end`: Session end
**Fields:**
- `e`: Event type (code)
- `t`: Timestamp (ISO 8601)
- `d`: Event data
- `sid`: Session ID (start event only)
- `cwd`: Working directory (start event only)
### Markdown Format (Human-Readable)
Chronological session log with formatted sections for easy review:
```markdown
# Claude Code Session Log
**Session ID**: `session-123`
**Started**: 2025-01-15 10:00:00
**Working Directory**: `/path/to/project`
---
## [10:00:05] UserPrompt
**User**:
\```
user message here
\```
---
## [10:00:10] ToolUse
**Tool**: `Read`
...
```
## Installed Hooks
### SessionStart
- **Trigger**: Session begins or resumes
- **Action**: Initialize empty log files with session metadata
- **Logs**: Session start event with ID, timestamp, working directory
### UserPromptSubmit
- **Trigger**: User submits a prompt
- **Action**: Log user's message
- **Logs**: Full user prompt text
### PostToolUse
- **Trigger**: After each tool completes successfully
- **Action**: Log tool name, inputs, and outputs
- **Logs**: All tool uses including:
- File operations (Read, Write, Edit)
- Shell commands (Bash)
- Web operations (WebFetch, WebSearch)
- **Subagent invocations (Task tool)** - special handling with subagent type and prompt
### Stop
- **Trigger**: Claude finishes responding
- **Action**: Parse transcript and log Claude's response
- **Logs**: Claude's complete response text
### SubagentStop
- **Trigger**: Subagent finishes executing
- **Action**: Log subagent completion
- **Logs**: Subagent completion event
### SessionEnd
- **Trigger**: Session ends/cleanup
- **Action**: Finalize logs and add end timestamp
- **Logs**: Session end event
## Log Files
Logs are stored in `.claude/skills/retrospeccting/logs/` with filename format:
```
YYYY-MM-DD_HH-MM-SS_<session-id>.ndjson
YYYY-MM-DD_HH-MM-SS_<session-id>.md
```
Example:
```
.claude/logs/
├── 2025-01-15_10-00-00_session-abc123.ndjson
├── 2025-01-15_10-00-00_session-abc123.md
├── 2025-01-15_14-30-00_session-def456.ndjson
└── 2025-01-15_14-30-00_session-def456.md
```
## Usage with Retrospective Skill
The retrospective skill (`/.claude/skills/retrospective/`) uses these logs for session analysis:
```
# User triggers retrospective
User: "Run a retrospective on this session"
# Retrospective skill reads logs
- Parses NDJSON logs for quantitative analysis
- References Markdown logs for qualitative review
- Generates comprehensive retrospective report
```
## Processing NDJSON Logs
To parse NDJSON logs in Python:
```python
import json
def read_ndjson_log(log_path):
events = []
with open(log_path, 'r') as f:
for line in f:
events.append(json.loads(line.strip()))
return events
# Example: Count tool uses
def count_tool_uses(events):
return sum(1 for e in events if e.get('e') == 'tu')
```
To parse in bash:
```bash
# Count user prompts
grep -c '"e":"up"' session.ndjson
# Extract all tool names
grep '"e":"tu"' session.ndjson | jq -r '.d.tool_name'
# Find subagent invocations
grep '"tool_name":"Task"' session.ndjson | jq '.d.tool_input'
```
## Maintenance
### Cleaning Old Logs
Logs accumulate over time. Clean up old logs periodically:
```bash
# Remove logs older than 30 days
find .claude/logs -name "*.ndjson" -mtime +30 -delete
find .claude/logs -name "*.md" -mtime +30 -delete
```
### Disabling Logging
To temporarily disable logging, remove execute permissions:
```bash
chmod -x .claude/hooks/SessionStart
chmod -x .claude/hooks/UserPromptSubmit
chmod -x .claude/hooks/PostToolUse
chmod -x .claude/hooks/Stop
chmod -x .claude/hooks/SessionEnd
chmod -x .claude/hooks/SubagentStop
```
To re-enable:
```bash
chmod +x .claude/hooks/*
```
## Error Handling
All hooks use fail-safe error handling:
- Errors are logged to stderr but **never block** session activity
- If logging fails, the session continues normally
- Best-effort approach ensures reliability
## Implementation Details
- **Language**: Python 3
- **Dependencies**: Standard library only (json, os, datetime, pathlib)
- **Shared Utility**: `logging_utils.py` provides SessionLogger class
- **Format**: NDJSON for efficiency, Markdown for readability
- **Safety**: All hooks exit(0) even on errors to avoid blocking
## Security Considerations
- Logs contain full session transcripts including prompts and responses
- Logs are stored locally in `.claude/skills/retrospecting/logs/`
- Add `.claude/skills/retrospecting/logs/` to `.gitignore` if sensitive data is involved
- Consider log rotation/cleanup for long-running projects

View File

@@ -1,226 +0,0 @@
# Session Logging Implementation
## What Was Built
A comprehensive session logging system for Claude Code that automatically captures all session activity for retrospective analysis.
## Components
### 1. Core Logging Infrastructure
- **`logging_utils.py`**: Shared SessionLogger class
- NDJSON writer (compact, machine-readable, append-friendly)
- Markdown writer (human-readable, formatted)
- Transcript parser for extracting conversation data
### 2. Event Hooks (6 hooks)
- **`SessionStart`**: Initialize log files when session begins
- **`UserPromptSubmit`**: Log every user message
- **`PostToolUse`**: Log all tool invocations (Read, Write, Edit, Bash, WebFetch, Task, etc.)
- **`Stop`**: Log Claude's responses by parsing transcript
- **`SubagentStop`**: Log subagent completion events
- **`SessionEnd`**: Finalize logs when session ends
### 3. Documentation & Testing
- **`README.md`**: Complete usage documentation
- **`VERIFICATION.md`**: Step-by-step verification guide
- **`test_hooks.sh`**: Automated test suite (all tests pass ✅)
- **`SUMMARY.md`**: This file
## Log Formats
### NDJSON (Machine-Readable)
```json
{"e":"start","sid":"session-123","t":"2025-10-23T10:00:00","cwd":"/path"}
{"t":"2025-10-23T10:00:05","e":"up","d":{"prompt":"user message"}}
{"t":"2025-10-23T10:00:10","e":"tu","d":{"tool_name":"Read","tool_input":{...}}}
```
**Optimizations**:
- Newline-delimited (append-only, no file rewrites)
- Compact field names (`e`, `t`, `d`)
- Event codes (`up`, `cr`, `tu`, `ss`, `end`)
- No whitespace (except newlines)
### Markdown (Human-Readable)
```markdown
# Claude Code Session Log
**Session ID**: `session-123`
**Started**: 2025-10-23 10:00:00
---
## [10:00:05] UserPrompt
**User**:
```
user message here
```
---
```
## Integration with Retrospective Skill
The retrospective skill (`.claude/skills/retrospective/`) is designed to use these logs:
1. **User triggers**: "Run a retrospective on this session"
2. **Skill reads logs**: Parses NDJSON for analysis, references Markdown for context
3. **Generates report**: Comprehensive retrospective with metrics and insights
### Data Sources for Retrospective
-**Git history**: session-analytics.md provides guidance
-**Claude logs**: ✅ NOW AVAILABLE via hooks (`.ndjson` files)
-**Project files**: File analysis already supported
-**User feedback**: Skill prompts for direct input
-**Sub-agent feedback**: Skill can invoke agents for feedback
## How It Works
### During a Claude Code Session:
1. **Session starts** → SessionStart hook → Creates empty `.ndjson` and `.md` files
2. **User sends message** → UserPromptSubmit hook → Appends user prompt to logs
3. **Claude uses tool** → PostToolUse hook → Appends tool use to logs
- Special handling for Task tool (subagent invocations)
4. **Claude responds** → Stop hook → Parses transcript, appends response to logs
5. **Subagent finishes** → SubagentStop hook → Appends completion event
6. **Session ends** → SessionEnd hook → Appends end event, finalizes logs
### Log Files Created:
```
.claude/logs/
├── 2025-10-23_10-00-00_session-abc123.ndjson (compact, for parsing)
└── 2025-10-23_10-00-00_session-abc123.md (readable, for review)
```
## Testing Results
**Automated tests**: ✅ All 10 tests pass
```
✓ Test 1: Check hook executability
✓ Test 2: Check logging utilities
✓ Test 3: Test SessionStart hook
✓ Test 4: Test UserPromptSubmit hook
✓ Test 5: Test PostToolUse hook
✓ Test 6: Test SubagentStop hook
✓ Test 7: Test SessionEnd hook
✓ Test 8: Verify log format
✓ Test 9: Validate NDJSON format
✓ Test 10: Cleanup test logs
```
## Key Features
### Reliability
- **Fail-safe**: All hooks exit 0 even on errors (never blocks session)
- **Best-effort logging**: Errors logged to stderr but don't interrupt workflow
- **Robust error handling**: Graceful degradation if transcript unavailable
### Performance
- **Append-only**: NDJSON format requires no file rewrites (~1-2ms per event)
- **Compact encoding**: Short field names and event codes minimize size
- **Minimal overhead**: <5ms total per event
### Completeness
- **Full conversation capture**: Every user prompt, Claude response, tool use
- **Subagent tracking**: Special handling for Task tool invocations
- **Timestamp precision**: ISO 8601 timestamps for all events
- **Context preservation**: Session ID, working directory, full event data
## Usage
### Automatic (No Action Required)
Hooks activate automatically when you use Claude Code. Just use Claude normally and logs will accumulate in `.claude/logs/`.
### Manual Analysis
```bash
# View latest session log
cat .claude/logs/$(ls -t .claude/logs/*.md | head -1)
# Parse for metrics
LATEST=$(ls -t .claude/logs/*.ndjson | head -1)
echo "Tool uses: $(grep -c '"e":"tu"' "$LATEST")"
echo "User prompts: $(grep -c '"e":"up"' "$LATEST")"
```
### Via Retrospective Skill
```
User: "Run a retrospective on this session"
Claude: [Invokes retrospective skill]
[Reads .ndjson logs]
[Analyzes patterns and metrics]
[Generates comprehensive report]
```
## Files Created
```
.claude/hooks/
├── logging_utils.py (7.2 KB - shared logging library)
├── SessionStart (968 B - initialize logs)
├── UserPromptSubmit (945 B - log user messages)
├── PostToolUse (1.2 KB - log tool uses)
├── Stop (1.9 KB - log Claude responses)
├── SubagentStop (882 B - log subagent completion)
├── SessionEnd (846 B - finalize logs)
├── README.md (5.4 KB - usage documentation)
├── VERIFICATION.md (4.9 KB - verification guide)
├── test_hooks.sh (3.2 KB - automated tests)
└── SUMMARY.md (this file)
```
All hooks are executable and ready to use.
## Benefits
### For Users
- **Session awareness**: Review what happened during complex sessions
- **Learning**: Understand workflow patterns and improve over time
- **Accountability**: Complete audit trail of all session activity
- **Debugging**: Trace issues back to specific interactions
### For Retrospective Analysis
- **Quantitative metrics**: Tool usage counts, timing data, event frequencies
- **Qualitative insights**: Full conversation context for pattern analysis
- **Subagent tracking**: Understand subagent invocations and coordination
- **Workflow optimization**: Identify bottlenecks and inefficiencies
### For Development
- **Machine-readable**: NDJSON format easy to parse programmatically
- **Human-readable**: Markdown format for manual review
- **Extensible**: Easy to add new event types or data fields
- **Reusable**: Hooks work for any Claude Code project
## Next Steps
1. **Start using Claude Code normally** - hooks log automatically
2. **Let sessions accumulate** - logs build up in `.claude/logs/`
3. **Run retrospectives** - use the retrospective skill to analyze sessions
4. **Iterate and improve** - apply learnings to optimize workflow
## Marketplace Readiness
This implementation is ready for Claude Plugin Marketplace:
**Follows best practices**: Official hook structure and conventions
**Complete documentation**: README, verification guide, test suite
**Robust error handling**: Fail-safe, never blocks sessions
**Performance optimized**: Minimal overhead, efficient storage
**Integration ready**: Works with retrospective skill
**Self-contained**: No external dependencies (Python stdlib only)
**Tested**: Automated test suite with 100% pass rate
## Support
- **Documentation**: See `README.md` for detailed usage
- **Verification**: See `VERIFICATION.md` for troubleshooting
- **Testing**: Run `test_hooks.sh` to verify installation
- **Issues**: Check hook executable permissions and `.claude/logs/` directory
---
**Status**: ✅ Complete and Ready to Use
The logging system is fully implemented, tested, and documented. Hooks will activate automatically in the next Claude Code session and begin logging all activity for retrospective analysis.

View File

@@ -1,42 +0,0 @@
#!/usr/bin/env python3
"""
SessionEnd Hook - Finalize session logs
Runs when the Claude Code session ends.
Adds end event to logs and finalizes log files.
"""
import json
import sys
from pathlib import Path
# Add hooks directory to path
hooks_dir = Path(__file__).parent
sys.path.insert(0, str(hooks_dir))
from logging_utils import get_logger
def main():
"""Finalize session logs."""
try:
# Read hook input from stdin
hook_input = json.load(sys.stdin)
# Get logger instance
logger = get_logger(hook_input)
# Finalize logs
logger.finalize_logs()
# Success
sys.exit(0)
except Exception as e:
# Log error but don't block session end
print(f"SessionEnd hook error: {e}", file=sys.stderr)
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -1,42 +0,0 @@
#!/usr/bin/env python3
"""
SessionStart Hook - Initialize session logs
Runs when Claude Code starts a new session or resumes an existing session.
Creates empty log files (JSON and Markdown) for the session.
"""
import json
import sys
from pathlib import Path
# Add hooks directory to path to import logging_utils
hooks_dir = Path(__file__).parent
sys.path.insert(0, str(hooks_dir))
from logging_utils import get_logger
def main():
"""Initialize session logs."""
try:
# Read hook input from stdin
hook_input = json.load(sys.stdin)
# Get logger instance
logger = get_logger(hook_input)
# Initialize log files
logger.initialize_logs()
# Success
sys.exit(0)
except Exception as e:
# Log error but don't block session start
print(f"SessionStart hook error: {e}", file=sys.stderr)
sys.exit(0) # Exit 0 to not block session
if __name__ == "__main__":
main()

View File

@@ -1,121 +0,0 @@
#!/usr/bin/env python3
"""
Stop Hook - Log Claude's response
Runs when Claude finishes responding.
Reads the transcript to extract Claude's latest response and logs it.
"""
import json
import sys
from pathlib import Path
# Add hooks directory to path
hooks_dir = Path(__file__).parent
sys.path.insert(0, str(hooks_dir))
from logging_utils import get_logger
def extract_latest_response(transcript_data):
"""Extract Claude's most recent response from transcript entry."""
if not transcript_data:
return ""
# Handle JSONL format - transcript_data is a single entry with a "message" field
if "message" in transcript_data:
msg = transcript_data.get("message", {})
if msg.get("role") == "assistant":
content = msg.get("content", [])
if isinstance(content, list):
text_parts = [c.get("text", "") for c in content if c.get("type") == "text"]
return "\n".join(text_parts)
elif isinstance(content, str):
return content
# Fallback: Handle old format with "messages" array
elif "messages" in transcript_data:
messages = transcript_data.get("messages", [])
for msg in reversed(messages):
if msg.get("role") == "assistant":
content = msg.get("content", [])
if isinstance(content, list):
text_parts = [c.get("text", "") for c in content if c.get("type") == "text"]
return "\n".join(text_parts)
elif isinstance(content, str):
return content
return ""
def main():
"""Log Claude's response."""
try:
# Read hook input from stdin
hook_input = json.load(sys.stdin)
# Debug: Write hook input to a debug file
debug_log = Path(__file__).parent.parent / "logs" / "stop_hook_debug.json"
debug_log.parent.mkdir(parents=True, exist_ok=True)
with open(debug_log, "a") as f:
f.write(json.dumps({
"timestamp": __import__('datetime').datetime.now().isoformat(),
"hook_input_keys": list(hook_input.keys()),
"transcript_path": hook_input.get("transcript_path"),
"has_transcript": bool(hook_input.get("transcript_path"))
}) + "\n")
# Get logger instance
logger = get_logger(hook_input)
# Read transcript to get Claude's response
transcript_path = hook_input.get("transcript_path")
if transcript_path:
transcript_data = logger.read_transcript(transcript_path)
# Debug: Log transcript parsing results
with open(debug_log, "a") as f:
f.write(json.dumps({
"stage": "after_read_transcript",
"has_transcript_data": bool(transcript_data),
"transcript_keys": list(transcript_data.keys()) if transcript_data else None
}) + "\n")
if transcript_data:
response = extract_latest_response(transcript_data)
# Debug: Log response extraction results
with open(debug_log, "a") as f:
f.write(json.dumps({
"stage": "after_extract_response",
"has_response": bool(response),
"response_length": len(response) if response else 0
}) + "\n")
if response:
logger.append_event("ClaudeResponse", {"response": response})
else:
with open(debug_log, "a") as f:
f.write(json.dumps({"error": "response extracted but empty"}) + "\n")
else:
with open(debug_log, "a") as f:
f.write(json.dumps({"error": "transcript_data is None or empty"}) + "\n")
else:
# Debug: Log that transcript_path was missing
with open(debug_log, "a") as f:
f.write(json.dumps({"error": "transcript_path missing"}) + "\n")
# Success
sys.exit(0)
except Exception as e:
# Don't block Claude's response on logging errors
debug_log = Path(__file__).parent.parent / "logs" / "stop_hook_debug.json"
with open(debug_log, "a") as f:
f.write(json.dumps({"error": str(e)}) + "\n")
print(f"Stop hook error: {e}", file=sys.stderr)
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -1,42 +0,0 @@
#!/usr/bin/env python3
"""
SubagentStop Hook - Log subagent completion
Runs when a subagent finishes executing.
Logs the subagent completion event.
"""
import json
import sys
from pathlib import Path
# Add hooks directory to path
hooks_dir = Path(__file__).parent
sys.path.insert(0, str(hooks_dir))
from logging_utils import get_logger
def main():
"""Log subagent completion."""
try:
# Read hook input from stdin
hook_input = json.load(sys.stdin)
# Get logger instance
logger = get_logger(hook_input)
# Log subagent stop event
logger.append_event("SubagentStop", {})
# Success
sys.exit(0)
except Exception as e:
# Don't block subagent completion on logging errors
print(f"SubagentStop hook error: {e}", file=sys.stderr)
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -1,43 +0,0 @@
#!/usr/bin/env python3
"""
UserPromptSubmit Hook - Log user prompts
Runs when users submit prompts to Claude.
Logs the user's message to both JSON and Markdown logs.
"""
import json
import sys
from pathlib import Path
# Add hooks directory to path
hooks_dir = Path(__file__).parent
sys.path.insert(0, str(hooks_dir))
from logging_utils import get_logger
def main():
"""Log user prompt."""
try:
# Read hook input from stdin
hook_input = json.load(sys.stdin)
# Get logger instance
logger = get_logger(hook_input)
# Log the user prompt
prompt = hook_input.get("prompt", "")
logger.append_event("UserPrompt", {"prompt": prompt})
# Success
sys.exit(0)
except Exception as e:
# Don't block user prompts on logging errors
print(f"UserPromptSubmit hook error: {e}", file=sys.stderr)
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -1,215 +0,0 @@
# Verification Guide: Claude Code Logging Hooks
## Quick Verification
### 1. Run Automated Tests
```bash
.claude/hooks/test_hooks.sh
```
Expected output: All tests pass ✅
### 2. Verify Hooks are Active in Real Session
The hooks are **already logging this current session**! Check for logs:
```bash
ls -lah .claude/logs/
```
You should see `.ndjson` and `.md` files with today's timestamp.
### 3. Inspect Current Session Logs
**View machine-readable log (NDJSON)**:
```bash
# Find latest log
LATEST_LOG=$(ls -t .claude/logs/*.ndjson | head -1)
cat "$LATEST_LOG"
```
**View human-readable log (Markdown)**:
```bash
# Find latest log
LATEST_LOG=$(ls -t .claude/logs/*.md | head -1)
cat "$LATEST_LOG"
```
### 4. Verify Specific Events Are Being Logged
**Check for user prompts**:
```bash
grep '"e":"up"' .claude/logs/*.ndjson | tail -3
```
**Check for tool uses**:
```bash
grep '"e":"tu"' .claude/logs/*.ndjson | tail -5
```
**Check for subagent invocations** (Task tool):
```bash
grep '"tool_name":"Task"' .claude/logs/*.ndjson
```
**Check for Claude responses**:
```bash
grep '"e":"cr"' .claude/logs/*.ndjson | tail -3
```
## What Should Be Logged
This current session should have logged:
1.**SessionStart**: When this session began
2.**UserPrompt**: All your messages (including "Review @.claude/agents/retrospective-agent.md", "Let's try creating hooks", etc.)
3.**ToolUse**: All tool calls (Read, Write, Edit, Bash, WebFetch, etc.)
4.**ClaudeResponse**: All of Claude's responses
5.**SubagentStop**: If any subagents were invoked (Task tool)
## Manual Verification Steps
### Check Event Counts
```bash
LATEST_LOG=$(ls -t .claude/logs/*.ndjson | head -1)
echo "Session events in current log:"
echo "- Start events: $(grep -c '"e":"start"' "$LATEST_LOG")"
echo "- User prompts: $(grep -c '"e":"up"' "$LATEST_LOG")"
echo "- Tool uses: $(grep -c '"e":"tu"' "$LATEST_LOG")"
echo "- Claude responses: $(grep -c '"e":"cr"' "$LATEST_LOG")"
echo "- Subagent stops: $(grep -c '"e":"ss"' "$LATEST_LOG")"
echo "- Total events: $(wc -l < "$LATEST_LOG")"
```
### Verify Markdown Format
```bash
LATEST_MD=$(ls -t .claude/logs/*.md | head -1)
head -50 "$LATEST_MD"
```
Should show:
- Session header with ID and timestamp
- Chronological events with `[HH:MM:SS]` timestamps
- Formatted sections for each event type
### Test Subagent Logging
To verify subagent logging works, invoke a subagent and check logs:
```bash
# After invoking a subagent via Task tool in Claude...
grep -A 10 '"tool_name":"Task"' .claude/logs/*.ndjson | tail -20
```
Should show Task tool invocations with subagent type and prompt.
## Troubleshooting
### No Logs Created
**Check 1**: Hooks are executable
```bash
ls -l .claude/hooks/SessionStart .claude/hooks/UserPromptSubmit .claude/hooks/PostToolUse
```
All should show `-rwxr-xr-x` (executable).
**Fix**:
```bash
chmod +x .claude/hooks/*
```
**Check 2**: Logs directory exists
```bash
ls -ld .claude/logs/
```
**Fix**:
```bash
mkdir -p .claude/logs
```
### Logs Are Empty or Missing Events
**Check**: Run test script to verify hooks work in isolation
```bash
.claude/hooks/test_hooks.sh
```
If tests pass but real sessions don't log, hooks may not be registered with Claude Code.
### Hooks Not Triggered
Claude Code automatically discovers and runs hooks in `.claude/hooks/` with matching event names. Verify:
1. Hook files have exact names: `SessionStart`, `UserPromptSubmit`, `PostToolUse`, `Stop`, `SubagentStop`, `SessionEnd`
2. Hook files are executable (`chmod +x`)
3. Hook files have proper shebang (`#!/usr/bin/env python3`)
### JSON Validation Errors
```bash
# Validate all NDJSON logs
for log in .claude/logs/*.ndjson; do
echo "Validating $log"
cat "$log" | while read line; do
echo "$line" | python3 -m json.tool > /dev/null || echo "Invalid: $line"
done
done
```
## Performance Impact
The hooks are designed to be lightweight:
- **NDJSON append**: ~1-2ms per event (no file rewrites)
- **Markdown append**: ~1-2ms per event
- **Total overhead**: <5ms per event (negligible)
To verify performance is acceptable:
```bash
# Check log file sizes
du -h .claude/logs/*.ndjson .claude/logs/*.md
```
If logs grow too large (>10MB), consider implementing log rotation.
## Success Criteria
**Hooks are working correctly if**:
1. `.claude/logs/` directory contains `.ndjson` and `.md` files
2. Files are named with today's date/time
3. NDJSON files contain at least: start event, user prompts, tool uses
4. Markdown files are human-readable with formatted sections
5. Test script passes all tests
6. Current session's interactions appear in logs
## Next Steps
Once verified, you can:
1. **Use the retrospective skill**: Ask Claude to "run a retrospective on this session"
2. **Analyze logs manually**: Review `.md` files for session insights
3. **Process logs programmatically**: Parse `.ndjson` for quantitative analysis
4. **Customize logging**: Modify `logging_utils.py` to adjust what's logged
## Quick Verification Command
Run this one-liner to verify everything is working:
```bash
echo "Checking hooks..." && \
ls -l .claude/hooks/{SessionStart,UserPromptSubmit,PostToolUse,Stop,SubagentStop,SessionEnd} && \
echo "Checking logs..." && \
ls -lh .claude/logs/*.{ndjson,md} 2>/dev/null | tail -5 && \
echo "Event counts:" && \
LATEST=$(ls -t .claude/logs/*.ndjson | head -1) && \
echo " UserPrompts: $(grep -c '"e":"up"' "$LATEST" 2>/dev/null || echo 0)" && \
echo " ToolUses: $(grep -c '"e":"tu"' "$LATEST" 2>/dev/null || echo 0)" && \
echo " ClaudeResponses: $(grep -c '"e":"cr"' "$LATEST" 2>/dev/null || echo 0)" && \
echo "✅ Hooks verified!"
```

View File

@@ -1,203 +0,0 @@
#!/usr/bin/env python3
"""
Shared logging utilities for Claude Code session logging.
This module provides functions for writing session logs in both JSON and Markdown formats.
All hooks use these utilities to ensure consistent logging across the session.
"""
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
class SessionLogger:
"""Handles logging for Claude Code sessions."""
def __init__(self, session_id: str, cwd: str):
"""
Initialize session logger.
Args:
session_id: Unique session identifier
cwd: Current working directory
"""
self.session_id = session_id
self.cwd = cwd
self.logs_dir = Path(cwd) / ".claude" / "skills" / "retrospecting" / "logs"
self.logs_dir.mkdir(parents=True, exist_ok=True)
# Sanitize session_id for filename
safe_session_id = session_id.replace("/", "_").replace(":", "-")
# Look for existing log files for this session
existing_ndjson = list(self.logs_dir.glob(f"*_{safe_session_id}.ndjson"))
existing_md = list(self.logs_dir.glob(f"*_{safe_session_id}.md"))
if existing_ndjson and existing_md:
# Reuse existing log files
self.ndjson_log_path = existing_ndjson[0]
self.md_log_path = existing_md[0]
else:
# Create new log files with timestamp
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
self.ndjson_log_path = self.logs_dir / f"{timestamp}_{safe_session_id}.ndjson"
self.md_log_path = self.logs_dir / f"{timestamp}_{safe_session_id}.md"
def initialize_logs(self) -> None:
"""Initialize empty log files for the session."""
# Initialize NDJSON log with session start event (compact, append-friendly)
start_event = {"e":"start","sid":self.session_id,"t":datetime.now().isoformat(),"cwd":self.cwd}
with open(self.ndjson_log_path, "w") as f:
f.write(json.dumps(start_event, separators=(',', ':')) + '\n')
# Initialize Markdown log with header
with open(self.md_log_path, "w") as f:
f.write(f"# Claude Code Session Log\n\n")
f.write(f"**Session ID**: `{self.session_id}`\n")
f.write(f"**Started**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"**Working Directory**: `{self.cwd}`\n\n")
f.write("---\n\n")
def append_event(self, event_type: str, event_data: Dict[str, Any]) -> None:
"""
Append an event to both JSON and Markdown logs.
Args:
event_type: Type of event (UserPrompt, ClaudeResponse, ToolUse, etc.)
event_data: Event-specific data
"""
timestamp = datetime.now().isoformat()
# Append to JSON log
self._append_json_event(event_type, event_data, timestamp)
# Append to Markdown log
self._append_markdown_event(event_type, event_data, timestamp)
def _append_json_event(self, event_type: str, event_data: Dict[str, Any], timestamp: str) -> None:
"""Append event to NDJSON log file (newline-delimited, compact, efficient)."""
try:
# Map event types to short codes for space efficiency
event_codes = {"UserPrompt":"up","ClaudeResponse":"cr","ToolUse":"tu","SubagentStop":"ss","SessionEnd":"end"}
event_code = event_codes.get(event_type, event_type)
# Create compact event record
event_record = {"t":timestamp,"e":event_code,"d":event_data}
# Append as single line (NDJSON format)
with open(self.ndjson_log_path, "a") as f:
f.write(json.dumps(event_record, separators=(',', ':')) + '\n')
except Exception as e:
# Best effort - log errors but don't fail
pass
def _append_markdown_event(self, event_type: str, event_data: Dict[str, Any], timestamp: str) -> None:
"""Append event to Markdown log file."""
with open(self.md_log_path, "a") as f:
time_str = datetime.fromisoformat(timestamp).strftime("%H:%M:%S")
f.write(f"## [{time_str}] {event_type}\n\n")
if event_type == "UserPrompt":
f.write(f"**User**:\n```\n{event_data.get('prompt', '')}\n```\n\n")
elif event_type == "ClaudeResponse":
response = event_data.get('response', '')
f.write(f"**Claude**:\n{response}\n\n")
elif event_type == "ToolUse":
tool_name = event_data.get('tool_name', 'Unknown')
f.write(f"**Tool**: `{tool_name}`\n\n")
if tool_name == "Task":
# Special handling for subagent invocations
tool_input = event_data.get('tool_input', {})
f.write(f"**Subagent Type**: `{tool_input.get('subagent_type', 'N/A')}`\n")
f.write(f"**Description**: {tool_input.get('description', 'N/A')}\n")
f.write(f"**Prompt**:\n```\n{tool_input.get('prompt', 'N/A')}\n```\n\n")
tool_response = event_data.get('tool_response', {})
if tool_response:
f.write(f"**Response**:\n```\n{json.dumps(tool_response, indent=2)}\n```\n\n")
else:
# Regular tool use
tool_input = event_data.get('tool_input', {})
f.write(f"**Input**:\n```json\n{json.dumps(tool_input, indent=2)}\n```\n\n")
tool_response = event_data.get('tool_response', {})
if tool_response:
# Truncate large responses
response_str = json.dumps(tool_response, indent=2)
if len(response_str) > 1000:
response_str = response_str[:1000] + "\n... (truncated)"
f.write(f"**Response**:\n```json\n{response_str}\n```\n\n")
elif event_type == "SubagentStop":
f.write(f"**Subagent completed**\n\n")
elif event_type == "SessionEnd":
f.write(f"**Session ended**\n")
f.write(f"**Duration**: {event_data.get('duration', 'N/A')}\n\n")
f.write("---\n\n")
def finalize_logs(self) -> None:
"""Finalize logs at session end."""
# Append end event to NDJSON log
try:
end_event = {"t":datetime.now().isoformat(),"e":"end"}
with open(self.ndjson_log_path, "a") as f:
f.write(json.dumps(end_event, separators=(',', ':')) + '\n')
except Exception:
pass # Best effort
# Add footer to Markdown log
with open(self.md_log_path, "a") as f:
f.write(f"\n---\n\n")
f.write(f"**Session ended**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
def read_transcript(self, transcript_path: str) -> Optional[Dict[str, Any]]:
"""
Read and parse the conversation transcript.
Args:
transcript_path: Path to transcript JSON or JSONL file
Returns:
Parsed transcript data or None if file doesn't exist
"""
try:
with open(transcript_path, "r") as f:
# Try regular JSON first
try:
f.seek(0)
return json.load(f)
except json.JSONDecodeError:
# If that fails, try JSONL format (newline-delimited JSON)
f.seek(0)
lines = [line.strip() for line in f if line.strip()]
if not lines:
return None
# Parse the last line which contains the most recent state
last_entry = json.loads(lines[-1])
return last_entry
except (FileNotFoundError, json.JSONDecodeError):
return None
def get_logger(hook_input: Dict[str, Any]) -> SessionLogger:
"""
Get a SessionLogger instance from hook input.
Args:
hook_input: Hook input JSON data
Returns:
Configured SessionLogger instance
"""
session_id = hook_input.get("session_id", "unknown")
cwd = hook_input.get("cwd", os.getcwd())
return SessionLogger(session_id, cwd)

View File

@@ -1,205 +0,0 @@
#!/bin/bash
# Test script to verify hooks are working correctly
set -e # Exit on error
echo "🧪 Testing Claude Code Logging Hooks"
echo "===================================="
echo ""
# Test 1: Verify all hooks are executable
echo "✓ Test 1: Check hook executability"
for hook in SessionStart UserPromptSubmit PostToolUse Stop SubagentStop SessionEnd; do
if [ -x ".claude/hooks/$hook" ]; then
echo "$hook is executable"
else
echo "$hook is NOT executable"
exit 1
fi
done
echo ""
# Test 2: Verify logging_utils.py is executable
echo "✓ Test 2: Check logging utilities"
if [ -x ".claude/hooks/logging_utils.py" ]; then
echo " ✓ logging_utils.py is executable"
else
echo " ✗ logging_utils.py is NOT executable"
exit 1
fi
echo ""
# Test 3: Test SessionStart hook with mock input
echo "✓ Test 3: Test SessionStart hook"
TEST_INPUT='{"session_id":"test-session","cwd":"'$(pwd)'","hook_event_name":"SessionStart"}'
echo "$TEST_INPUT" | .claude/hooks/SessionStart
if [ $? -eq 0 ]; then
echo " ✓ SessionStart executed successfully"
# Check if log files were created
LOG_COUNT=$(find .claude/logs -name "*test-session*.ndjson" 2>/dev/null | wc -l)
if [ "$LOG_COUNT" -gt 0 ]; then
echo " ✓ NDJSON log file created"
NDJSON_LOG=$(find .claude/logs -name "*test-session*.ndjson" | head -1)
echo " 📄 Log: $NDJSON_LOG"
# Verify content
if grep -q '"e":"start"' "$NDJSON_LOG"; then
echo " ✓ Start event logged correctly"
else
echo " ✗ Start event NOT found in log"
exit 1
fi
else
echo " ✗ Log files NOT created"
exit 1
fi
MD_COUNT=$(find .claude/logs -name "*test-session*.md" 2>/dev/null | wc -l)
if [ "$MD_COUNT" -gt 0 ]; then
echo " ✓ Markdown log file created"
else
echo " ✗ Markdown log NOT created"
exit 1
fi
else
echo " ✗ SessionStart failed"
exit 1
fi
echo ""
# Test 4: Test UserPromptSubmit hook
echo "✓ Test 4: Test UserPromptSubmit hook"
TEST_INPUT='{"session_id":"test-session","cwd":"'$(pwd)'","hook_event_name":"UserPromptSubmit","prompt":"Test user message"}'
echo "$TEST_INPUT" | .claude/hooks/UserPromptSubmit
if [ $? -eq 0 ]; then
echo " ✓ UserPromptSubmit executed successfully"
# Check all test-session logs for the event
if cat .claude/logs/*test-session*.ndjson 2>/dev/null | grep -q '"e":"up"'; then
echo " ✓ User prompt event logged"
else
echo " ✗ User prompt event NOT found"
exit 1
fi
else
echo " ✗ UserPromptSubmit failed"
exit 1
fi
echo ""
# Test 5: Test PostToolUse hook
echo "✓ Test 5: Test PostToolUse hook"
TEST_INPUT='{"session_id":"test-session","cwd":"'$(pwd)'","hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":"test.txt"},"tool_response":{"content":"test content"}}'
echo "$TEST_INPUT" | .claude/hooks/PostToolUse
if [ $? -eq 0 ]; then
echo " ✓ PostToolUse executed successfully"
if cat .claude/logs/*test-session*.ndjson 2>/dev/null | grep -q '"e":"tu"'; then
echo " ✓ Tool use event logged"
else
echo " ✗ Tool use event NOT found"
exit 1
fi
else
echo " ✗ PostToolUse failed"
exit 1
fi
echo ""
# Test 6: Test SubagentStop hook
echo "✓ Test 6: Test SubagentStop hook"
TEST_INPUT='{"session_id":"test-session","cwd":"'$(pwd)'","hook_event_name":"SubagentStop"}'
echo "$TEST_INPUT" | .claude/hooks/SubagentStop
if [ $? -eq 0 ]; then
echo " ✓ SubagentStop executed successfully"
if cat .claude/logs/*test-session*.ndjson 2>/dev/null | grep -q '"e":"ss"'; then
echo " ✓ Subagent stop event logged"
else
echo " ✗ Subagent stop event NOT found"
exit 1
fi
else
echo " ✗ SubagentStop failed"
exit 1
fi
echo ""
# Test 7: Test SessionEnd hook
echo "✓ Test 7: Test SessionEnd hook"
TEST_INPUT='{"session_id":"test-session","cwd":"'$(pwd)'","hook_event_name":"SessionEnd"}'
echo "$TEST_INPUT" | .claude/hooks/SessionEnd
if [ $? -eq 0 ]; then
echo " ✓ SessionEnd executed successfully"
if cat .claude/logs/*test-session*.ndjson 2>/dev/null | grep -q '"e":"end"'; then
echo " ✓ End event logged"
else
echo " ✗ End event NOT found"
exit 1
fi
else
echo " ✗ SessionEnd failed"
exit 1
fi
echo ""
# Test 8: Verify log content and format
echo "✓ Test 8: Verify log format"
echo " 📊 All NDJSON log contents:"
echo " --------------------------"
cat .claude/logs/*test-session*.ndjson | while read line; do
echo " $line"
done
echo ""
echo " 📝 Markdown log preview (first file):"
echo " -------------------------------------"
MD_LOG=$(find .claude/logs -name "*test-session*.md" | head -1)
head -20 "$MD_LOG" | sed 's/^/ /'
echo " ..."
echo ""
# Count events across all files
EVENT_COUNT=$(cat .claude/logs/*test-session*.ndjson | wc -l)
echo " ✓ Total events logged: $EVENT_COUNT"
echo ""
# Test 9: Verify NDJSON is valid JSON per line
echo "✓ Test 9: Validate NDJSON format"
INVALID=0
cat .claude/logs/*test-session*.ndjson | while IFS= read -r line; do
if ! echo "$line" | python3 -m json.tool > /dev/null 2>&1; then
echo " ✗ Invalid JSON line: $line"
INVALID=1
fi
done
if [ $INVALID -eq 0 ]; then
echo " ✓ All NDJSON lines are valid JSON"
else
echo " ✗ Some NDJSON lines are invalid"
exit 1
fi
echo ""
# Cleanup
echo "✓ Test 10: Cleanup test logs"
rm -f .claude/logs/*test-session*
echo " ✓ Test logs removed"
echo ""
echo "===================================="
echo "✅ All tests passed!"
echo ""
echo "Next steps:"
echo "1. Use Claude Code normally - hooks will log automatically"
echo "2. Check .claude/logs/ for session logs"
echo "3. Use retrospective skill to analyze sessions"
echo ""
echo "To verify in real session:"
echo " ls -lah .claude/logs/"
echo " cat .claude/logs/<latest>.ndjson"
echo " cat .claude/logs/<latest>.md"

View 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/

View 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"
}
}

View 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
});
});

View 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));
}

View 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),
};
}

View 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);
});
});
});

View 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';
}

View File

@@ -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,
};
}

View 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);
});

View File

@@ -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

View 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');
}
}
});
});

View 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;
}

View 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();
}
});
});

View 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;
}

View 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;

View File

@@ -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 } };
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;
}
}

View 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"]
}

View File

@@ -1,20 +1,3 @@
Use the `reviewing-changes` skill to review this pull request.
The PR branch is already checked out in the current working directory.
Provide a comprehensive review including:
- Summary of changes since last review
- Critical issues found (be thorough)
- Suggested improvements (be thorough)
- Good practices observed (be concise - list only the most notable items without elaboration)
- Action items for the author
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets
When reviewing subsequent commits:
- Track status of previously identified issues (fixed/unfixed/reopened)
- Identify NEW problems introduced since last review
- Note if fixes introduced new issues
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively.

14
.claude/settings.json Normal file
View File

@@ -0,0 +1,14 @@
{
"attribution": {
"commit": "",
"pr": ""
},
"extraKnownMarketplaces": {
"bitwarden-marketplace": {
"source": {
"source": "github",
"repo": "bitwarden/ai-plugins"
}
}
}
}

View 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

View File

@@ -0,0 +1,516 @@
---
name: implementing-android-code
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.
---
# Implementing Android Code - Bitwarden Quick Reference
**This skill provides tactical guidance for Bitwarden-specific patterns.** For comprehensive architecture decisions and complete code style rules, consult `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
---
## Critical Patterns Reference
### A. ViewModel Implementation (State-Action-Event Pattern)
All ViewModels follow the **State-Action-Event (SAE)** pattern via `BaseViewModel<State, Event, Action>`.
**Key Requirements:**
- Annotate with `@HiltViewModel`
- State class MUST be `@Parcelize data class : Parcelable`
- Implement `handleAction(action: A)` - MUST be synchronous
- Post internal actions from coroutines using `sendAction()`
- Save/restore state via `SavedStateHandle[KEY_STATE]`
- Private action handlers: `private fun handle*` naming convention
**Template**: See [ViewModel template](templates.md#viewmodel-template-state-action-event-pattern)
**Pattern Summary:**
```kotlin
@HiltViewModel
class ExampleViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: ExampleRepository,
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
initialState = savedStateHandle[KEY_STATE] ?: ExampleState(),
) {
init {
stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope)
}
override fun handleAction(action: ExampleAction) {
// Synchronous dispatch only
when (action) {
is Action.Click -> handleClick()
is Action.Internal.DataReceived -> handleDataReceived(action)
}
}
private fun handleClick() {
viewModelScope.launch {
val result = repository.fetchData()
sendAction(Action.Internal.DataReceived(result)) // Post internal action
}
}
private fun handleDataReceived(action: Action.Internal.DataReceived) {
mutableStateFlow.update { it.copy(data = action.result) }
}
}
```
**Reference:**
- `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` (see `handleAction` method)
- `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt` (see class declaration)
**Critical Gotchas:**
-**NEVER** update `mutableStateFlow` directly inside coroutines
-**ALWAYS** post internal actions from coroutines, update state in `handleAction()`
-**NEVER** forget `@IgnoredOnParcel` for sensitive data (causes security leak)
-**ALWAYS** use `@Parcelize` on state classes for process death recovery
- ✅ State restoration happens automatically if properly saved to `SavedStateHandle`
---
### B. Navigation Implementation (Type-Safe)
All navigation uses **type-safe routes** with kotlinx.serialization.
**Pattern Structure:**
1. `@Serializable` route data class with parameters
2. `...Args` helper class for extracting from `SavedStateHandle`
3. `NavGraphBuilder.{screen}Destination()` extension for adding screen to graph
4. `NavController.navigateTo{Screen}()` extension for navigation calls
**Template**: See [Navigation template](templates.md#navigation-template-type-safe-routes)
**Pattern Summary:**
```kotlin
@Serializable
data class ExampleRoute(val userId: String, val isEditMode: Boolean = false)
data class ExampleArgs(val userId: String, val isEditMode: Boolean)
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
val route = this.toRoute<ExampleRoute>()
return ExampleArgs(userId = route.userId, isEditMode = route.isEditMode)
}
fun NavController.navigateToExample(
userId: String,
isEditMode: Boolean = false,
navOptions: NavOptions? = null,
) {
this.navigate(route = ExampleRoute(userId, isEditMode), navOptions = navOptions)
}
fun NavGraphBuilder.exampleDestination(onNavigateBack: () -> Unit) {
composableWithSlideTransitions<ExampleRoute> {
ExampleScreen(onNavigateBack = onNavigateBack)
}
}
```
**Reference:** `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt` (see `LoginRoute` and extensions)
**Key Benefits:**
- ✅ Type safety: Compile-time errors for missing parameters
- ✅ No string literals in navigation code
- ✅ Automatic serialization/deserialization
- ✅ Clear contract for screen dependencies
---
### C. Screen/Compose Implementation
All screens follow consistent Compose patterns.
**Template**: See [Screen/Compose template](templates.md#screencompose-template)
**Key Patterns:**
```kotlin
@Composable
fun ExampleScreen(
onNavigateBack: () -> Unit,
viewModel: ExampleViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
ExampleEvent.NavigateBack -> onNavigateBack()
}
}
BitwardenScaffold(
topBar = {
BitwardenTopAppBar(
title = stringResource(R.string.title),
navigationIcon = rememberVectorPainter(BitwardenDrawable.ic_back),
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
)
},
) {
// UI content
}
}
```
**Reference:** `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt` (see `LoginScreen` composable)
**Essential Requirements:**
- ✅ Use `hiltViewModel()` for dependency injection
- ✅ Use `collectAsStateWithLifecycle()` for state (not `collectAsState()`)
- ✅ Use `EventsEffect(viewModel)` for one-shot events
- ✅ Use `Bitwarden*` prefixed components from `:ui` module
**State Hoisting Rules:**
- **ViewModel state**: Data that needs to survive process death or affects business logic
- **UI-only state**: Temporary UI state (scroll position, text field focus) using `remember` or `rememberSaveable`
---
### D. Data Layer Implementation
The data layer follows strict patterns for repositories, managers, and data sources.
**Interface + Implementation Separation (ALWAYS)**
**Template**: See [Data Layer template](templates.md#data-layer-template-repository--hilt-module)
**Pattern Summary:**
```kotlin
// Interface (injected via Hilt)
interface ExampleRepository {
suspend fun fetchData(id: String): ExampleResult
val dataFlow: StateFlow<DataState<ExampleData>>
}
// Implementation (NOT directly injected)
class ExampleRepositoryImpl(
private val exampleDiskSource: ExampleDiskSource,
private val exampleService: ExampleService,
) : ExampleRepository {
override suspend fun fetchData(id: String): ExampleResult {
// NO exceptions thrown - return Result or sealed class
return exampleService.getData(id).fold(
onSuccess = { ExampleResult.Success(it.toModel()) },
onFailure = { ExampleResult.Error(it.message) },
)
}
}
// Sealed result class (domain-specific)
sealed class ExampleResult {
data class Success(val data: ExampleData) : ExampleResult()
data class Error(val message: String?) : ExampleResult()
}
// Hilt Module
@Module
@InstallIn(SingletonComponent::class)
object ExampleRepositoryModule {
@Provides
@Singleton
fun provideExampleRepository(
exampleDiskSource: ExampleDiskSource,
exampleService: ExampleService,
): ExampleRepository = ExampleRepositoryImpl(exampleDiskSource, exampleService)
}
```
**Reference:**
- `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt`
- `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
**Three-Layer Data Architecture:**
1. **Data Sources** - Raw data access (network, disk, SDK). Return `Result<T>`, never throw.
2. **Managers** - Single responsibility business logic. Wrap OS/external services.
3. **Repositories** - Aggregate sources/managers. Return domain-specific sealed classes.
**Critical Rules:**
-**NEVER** throw exceptions in data layer
-**ALWAYS** use interface + `...Impl` pattern
-**ALWAYS** inject interfaces, never implementations
- ✅ Data sources return `Result<T>`, repositories return domain sealed classes
- ✅ Use `StateFlow` for continuously observed data
---
### E. UI Components
**Use Existing Components First:**
The `:ui` module provides reusable `Bitwarden*` prefixed components. Search before creating new ones.
**Common Components:**
- `BitwardenFilledButton` - Primary action buttons
- `BitwardenOutlinedButton` - Secondary action buttons
- `BitwardenTextField` - Text input fields
- `BitwardenPasswordField` - Password input with show/hide
- `BitwardenSwitch` - Toggle switches
- `BitwardenTopAppBar` - Toolbar/app bar
- `BitwardenScaffold` - Screen container with scaffold
- `BitwardenBasicDialog` - Simple dialogs
- `BitwardenLoadingDialog` - Loading indicators
**Component Discovery:**
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
- Component needs consistent theming across app
- Component has semantic meaning (accessibility)
- Component has complex state management
**New Component Requirements:**
- Prefix with `Bitwarden`
- Accept themed colors/styles from `BitwardenTheme`
- Include preview composables for testing
- Support accessibility (content descriptions, semantics)
**String Resources:**
New strings belong in the `:ui` module: `ui/src/main/res/values/strings.xml`
- Use typographic apostrophes and quotes to avoid escape characters: `youll` not `you\'ll`, `“word”` not `\"word\"`
- Reference strings via generated `BitwardenString` resource IDs
- Do not add strings to other modules unless explicitly instructed
---
### F. Security Patterns
**Encrypted vs Unencrypted Storage:**
**Template**: See [Security templates](templates.md#security-templates)
**Pattern Summary:**
```kotlin
class ExampleDiskSourceImpl(
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
@UnencryptedPreferences sharedPreferences: SharedPreferences,
) : BaseEncryptedDiskSource(
encryptedSharedPreferences = encryptedSharedPreferences,
sharedPreferences = sharedPreferences,
),
ExampleDiskSource {
fun storeAuthToken(token: String) {
putEncryptedString(KEY_TOKEN, token) // Sensitive — uses base class method
}
fun storeThemePreference(isDark: Boolean) {
putBoolean(KEY_THEME, isDark) // Non-sensitive — uses base class method
}
}
```
**Android Keystore (Biometric Keys):**
- User-scoped encryption keys: `BiometricsEncryptionManager`
- Keys stored in Android Keystore (hardware-backed when available)
- Integrity validation on biometric state changes
**Input Validation:**
```kotlin
// Validation returns boolean, NEVER throws
interface RequestValidator {
fun validate(request: Request): Boolean
}
// Sanitization removes dangerous content
fun String?.sanitizeTotpUri(issuer: String?, username: String?): String? {
if (this.isNullOrBlank()) return null
// Sanitize and return safe value
}
```
**Security Checklist:**
- ✅ Use `@EncryptedPreferences` for credentials, keys, tokens
- ✅ Use `@UnencryptedPreferences` for UI state, preferences
- ✅ Use `@IgnoredOnParcel` for sensitive ViewModel state
-**NEVER** log sensitive data (passwords, tokens, vault items)
- ✅ Validate all user input before processing
- ✅ Use Timber for non-sensitive logging only
---
### G. Testing Patterns
**ViewModel Testing:**
**Template**: See [Testing templates](templates.md#testing-templates)
**Pattern Summary:**
```kotlin
class ExampleViewModelTest : BaseViewModelTest() {
private val mockRepository: ExampleRepository = mockk()
@Test
fun `ButtonClick should fetch data and update state`() = runTest {
val expectedResult = ExampleResult.Success(data = "test")
coEvery { mockRepository.fetchData(any()) } returns expectedResult
val viewModel = createViewModel()
viewModel.trySendAction(ExampleAction.ButtonClick)
viewModel.stateFlow.test {
assertEquals(EXPECTED_STATE.copy(data = "test"), awaitItem())
}
}
private fun createViewModel(): ExampleViewModel = ExampleViewModel(
savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to EXPECTED_STATE)),
repository = mockRepository,
)
}
```
**Reference:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
**Key Testing Patterns:**
- ✅ Extend `BaseViewModelTest` for proper dispatcher management
- ✅ Use `runTest` from `kotlinx.coroutines.test`
- ✅ Use Turbine's `.test { awaitItem() }` for Flow assertions
- ✅ Use MockK: `coEvery` for suspend functions, `every` for sync
- ✅ Test both state changes and event emissions
- ✅ Test both success and failure Result paths
**Flow Testing with Turbine:**
```kotlin
// Test state and events simultaneously
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
viewModel.trySendAction(ExampleAction.Submit)
assertEquals(ExpectedState.Loading, stateFlow.awaitItem())
assertEquals(ExampleEvent.ShowSuccess, eventFlow.awaitItem())
}
```
**MockK Quick Reference:**
```kotlin
coEvery { repository.fetchData(any()) } returns Result.success("data") // Suspend
every { diskSource.getData() } returns "cached" // Sync
coVerify { repository.fetchData("123") } // Verify
```
---
### H. Clock/Time Handling
All code needing current time must inject `Clock` for testability.
**Key Requirements:**
- ✅ Inject `Clock` via Hilt in ViewModels
- ✅ Pass `Clock` as parameter in extension functions
- ✅ Use `clock.instant()` to get current time
- ❌ Never call `Instant.now()` or `DateTime.now()` directly
- ❌ Never use `mockkStatic` for datetime classes in tests
**Pattern Summary:**
```kotlin
// ViewModel with Clock
class MyViewModel @Inject constructor(
private val clock: Clock,
) {
val timestamp = clock.instant()
}
// Extension function with Clock parameter
fun State.getTimestamp(clock: Clock): Instant =
existingTime ?: clock.instant()
// Test with fixed clock
val FIXED_CLOCK = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
```
**Reference:**
- `docs/STYLE_AND_BEST_PRACTICES.md` (see Time and Clock Handling section)
- `core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt` (see `provideClock` function)
**Critical Gotchas:**
-`Instant.now()` creates hidden dependency, non-testable
-`mockkStatic(Instant::class)` is fragile, can leak between tests
-`Clock.fixed(...)` provides deterministic test behavior
---
### 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:
**NEVER update ViewModel state directly in coroutines**
- Post internal actions, update state synchronously in `handleAction()`
**NEVER inject `...Impl` classes**
- Only inject interfaces via Hilt
**NEVER create navigation without `@Serializable` routes**
- No string-based navigation, always type-safe
**NEVER use raw `Result<T>` in repositories**
- Use domain-specific sealed classes for better error handling
**NEVER make state classes without `@Parcelize`**
- All ViewModel state must survive process death
**NEVER skip `SavedStateHandle` persistence for ViewModels**
- Users lose form progress on process death
**NEVER forget `@IgnoredOnParcel` for passwords/tokens**
- Causes security vulnerability (sensitive data in parcel)
**NEVER use generic `Exception` catching**
- Catch specific exceptions only (`RemoteException`, `IOException`)
**NEVER call `Instant.now()` or `DateTime.now()` directly**
- Inject `Clock` via Hilt, use `clock.instant()` for testability
---
## Quick Reference
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)

View File

@@ -0,0 +1,636 @@
# Code Templates - Bitwarden Android
Copy-pasteable templates derived from actual codebase patterns. Replace `Example` with your feature name.
---
## ViewModel Template (State-Action-Event Pattern)
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt`
### State Class
```kotlin
@Parcelize
data class ExampleState(
val isLoading: Boolean = false,
val data: String? = null,
@IgnoredOnParcel val sensitiveInput: String = "", // Sensitive data excluded from parcel
val dialogState: DialogState? = null,
) : Parcelable {
/**
* Dialog states for the Example screen.
*/
sealed class DialogState : Parcelable {
@Parcelize
data class Error(
val title: Text? = null,
val message: Text,
val error: Throwable? = null,
) : DialogState()
@Parcelize
data class Loading(val message: Text) : DialogState()
}
}
```
### Event Sealed Class
```kotlin
/**
* One-shot UI events for the Example screen.
*/
sealed class ExampleEvent {
data object NavigateBack : ExampleEvent()
data class ShowToast(val message: Text) : ExampleEvent()
}
```
### Action Sealed Class (with Internal)
```kotlin
/**
* User and system actions for the Example screen.
*/
sealed class ExampleAction {
data object BackClick : ExampleAction()
data object SubmitClick : ExampleAction()
data class InputChanged(val input: String) : ExampleAction()
data object ErrorDialogDismiss : ExampleAction()
/**
* Internal actions dispatched by the ViewModel from coroutines.
*/
sealed class Internal : ExampleAction() {
data class ReceiveDataState(
val dataState: DataState<ExampleData>,
) : Internal()
data class ReceiveDataResult(
val result: ExampleResult,
) : Internal()
}
}
```
### ViewModel
```kotlin
private const val KEY_STATE = "state"
/**
* ViewModel for the Example screen.
*/
@HiltViewModel
class ExampleViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val exampleRepository: ExampleRepository,
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
initialState = savedStateHandle[KEY_STATE]
?: run {
val args = savedStateHandle.toExampleArgs()
ExampleState(
data = args.itemId,
)
},
) {
init {
// Persist state for process death recovery
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
// Collect repository flows as internal actions
exampleRepository.dataFlow
.map { ExampleAction.Internal.ReceiveDataState(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: ExampleAction) {
when (action) {
ExampleAction.BackClick -> handleBackClick()
ExampleAction.SubmitClick -> handleSubmitClick()
ExampleAction.ErrorDialogDismiss -> handleErrorDialogDismiss()
is ExampleAction.InputChanged -> handleInputChanged(action)
is ExampleAction.Internal.ReceiveDataState -> {
handleReceiveDataState(action)
}
is ExampleAction.Internal.ReceiveDataResult -> {
handleReceiveDataResult(action)
}
}
}
private fun handleBackClick() {
sendEvent(ExampleEvent.NavigateBack)
}
private fun handleErrorDialogDismiss() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleSubmitClick() {
viewModelScope.launch {
val result = exampleRepository.submitData(state.data.orEmpty())
sendAction(ExampleAction.Internal.ReceiveDataResult(result))
}
}
private fun handleInputChanged(action: ExampleAction.InputChanged) {
mutableStateFlow.update { it.copy(sensitiveInput = action.input) }
}
private fun handleReceiveDataState(
action: ExampleAction.Internal.ReceiveDataState,
) {
when (action.dataState) {
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
data = action.dataState.data.toString(),
)
}
}
is DataState.Loading -> {
mutableStateFlow.update { it.copy(isLoading = true) }
}
is DataState.Error -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
dialogState = ExampleState.DialogState.Error(
message = BitwardenString.generic_error_message.asText(),
error = action.dataState.error,
),
)
}
}
else -> Unit
}
}
private fun handleReceiveDataResult(
action: ExampleAction.Internal.ReceiveDataResult,
) {
when (val result = action.result) {
is ExampleResult.Success -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
data = result.data,
)
}
}
is ExampleResult.Error -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
dialogState = ExampleState.DialogState.Error(
message = result.message?.asText()
?: BitwardenString.generic_error_message.asText(),
),
)
}
}
}
}
}
```
---
## Navigation Template (Type-Safe Routes)
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt`
```kotlin
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.feature.example
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.toRoute
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
* Route for the Example screen.
*/
@Serializable
@OmitFromCoverage
data class ExampleRoute(
val itemId: String,
val isEditMode: Boolean = false,
)
/**
* Args extracted from [SavedStateHandle] for the Example screen.
*/
@OmitFromCoverage
data class ExampleArgs(
val itemId: String,
val isEditMode: Boolean,
)
/**
* Extracts [ExampleArgs] from the [SavedStateHandle].
*/
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
val route = this.toRoute<ExampleRoute>()
return ExampleArgs(
itemId = route.itemId,
isEditMode = route.isEditMode,
)
}
/**
* Navigate to the Example screen.
*/
fun NavController.navigateToExample(
itemId: String,
isEditMode: Boolean = false,
navOptions: NavOptions? = null,
) {
this.navigate(
route = ExampleRoute(
itemId = itemId,
isEditMode = isEditMode,
),
navOptions = navOptions,
)
}
/**
* Add the Example screen destination to the navigation graph.
*/
fun NavGraphBuilder.exampleDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions<ExampleRoute> {
ExampleScreen(
onNavigateBack = onNavigateBack,
)
}
}
```
---
## Screen/Compose Template
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt`
```kotlin
package com.x8bit.bitwarden.ui.feature.example
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
/**
* The Example screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExampleScreen(
onNavigateBack: () -> Unit,
viewModel: ExampleViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
ExampleEvent.NavigateBack -> onNavigateBack()
is ExampleEvent.ShowToast -> {
// Handle toast
}
}
}
// Dialogs
ExampleDialogs(
dialogState = state.dialogState,
onDismissRequest = { viewModel.trySendAction(ExampleAction.ErrorDialogDismiss) },
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = BitwardenString.example),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
)
},
) {
ExampleScreenContent(
state = state,
onInputChanged = { viewModel.trySendAction(ExampleAction.InputChanged(it)) },
onSubmitClick = { viewModel.trySendAction(ExampleAction.SubmitClick) },
modifier = Modifier
.fillMaxSize(),
)
}
}
```
---
## Data Layer Template (Repository + Hilt Module)
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
### Interface
```kotlin
/**
* Provides data operations for the Example feature.
*/
interface ExampleRepository {
/**
* Submits data and returns a typed result.
*/
suspend fun submitData(input: String): ExampleResult
/**
* Continuously observed data stream.
*/
val dataFlow: StateFlow<DataState<ExampleData>>
}
```
### Sealed Result Class
```kotlin
/**
* Domain-specific result for Example operations.
*/
sealed class ExampleResult {
data class Success(val data: String) : ExampleResult()
data class Error(val message: String?) : ExampleResult()
}
```
### Implementation
```kotlin
/**
* Default implementation of [ExampleRepository].
*/
class ExampleRepositoryImpl(
private val exampleDiskSource: ExampleDiskSource,
private val exampleService: ExampleService,
private val dispatcherManager: DispatcherManager,
) : ExampleRepository {
override val dataFlow: StateFlow<DataState<ExampleData>>
get() = // ...
override suspend fun submitData(input: String): ExampleResult {
return exampleService
.postData(input)
.fold(
onSuccess = { ExampleResult.Success(it.toModel()) },
onFailure = { ExampleResult.Error(it.message) },
)
}
}
```
### Hilt Module
```kotlin
@Module
@InstallIn(SingletonComponent::class)
object ExampleRepositoryModule {
@Provides
@Singleton
fun provideExampleRepository(
exampleDiskSource: ExampleDiskSource,
exampleService: ExampleService,
dispatcherManager: DispatcherManager,
): ExampleRepository = ExampleRepositoryImpl(
exampleDiskSource = exampleDiskSource,
exampleService = exampleService,
dispatcherManager = dispatcherManager,
)
}
```
---
## Security Templates
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/di/AuthDiskModule.kt` and `AuthDiskSourceImpl.kt`
### Encrypted Disk Source (Module)
```kotlin
@Module
@InstallIn(SingletonComponent::class)
object ExampleDiskModule {
@Provides
@Singleton
fun provideExampleDiskSource(
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
@UnencryptedPreferences sharedPreferences: SharedPreferences,
json: Json,
): ExampleDiskSource = ExampleDiskSourceImpl(
encryptedSharedPreferences = encryptedSharedPreferences,
sharedPreferences = sharedPreferences,
json = json,
)
}
```
### Encrypted Disk Source (Implementation)
```kotlin
/**
* Disk source for Example data using encrypted and unencrypted storage.
*/
class ExampleDiskSourceImpl(
encryptedSharedPreferences: SharedPreferences,
sharedPreferences: SharedPreferences,
private val json: Json,
) : BaseEncryptedDiskSource(
encryptedSharedPreferences = encryptedSharedPreferences,
sharedPreferences = sharedPreferences,
),
ExampleDiskSource {
private companion object {
const val ENCRYPTED_TOKEN_KEY = "exampleToken"
const val UNENCRYPTED_PREF_KEY = "examplePreference"
}
override var authToken: String?
get() = getEncryptedString(ENCRYPTED_TOKEN_KEY)
set(value) { putEncryptedString(ENCRYPTED_TOKEN_KEY, value) }
override var uiPreference: Boolean
get() = getBoolean(UNENCRYPTED_PREF_KEY) ?: false
set(value) { putBoolean(UNENCRYPTED_PREF_KEY, value) }
}
```
---
## Testing Templates
**Based on**: `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
### ViewModel Test
```kotlin
class ExampleViewModelTest : BaseViewModelTest() {
// Mock dependencies
private val mockRepository = mockk<ExampleRepository>()
private val mutableDataFlow = MutableStateFlow<DataState<ExampleData>>(DataState.Loading)
@BeforeEach
fun setup() {
every { mockRepository.dataFlow } returns mutableDataFlow
}
@Test
fun `initial state should be correct when there is no saved state`() {
val viewModel = createViewModel(state = null)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `initial state should be correct when there is a saved state`() {
val savedState = DEFAULT_STATE.copy(data = "saved")
val viewModel = createViewModel(state = savedState)
assertEquals(savedState, viewModel.stateFlow.value)
}
@Test
fun `SubmitClick should call repository and update state on success`() = runTest {
val expected = ExampleResult.Success(data = "result")
coEvery { mockRepository.submitData(any()) } returns expected
val viewModel = createViewModel()
viewModel.stateFlow.test {
// Initial state
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.SubmitClick)
// Updated state after result
assertEquals(
DEFAULT_STATE.copy(data = "result", isLoading = false),
awaitItem(),
)
}
}
@Test
fun `SubmitClick should show error dialog on failure`() = runTest {
val expected = ExampleResult.Error(message = "Network error")
coEvery { mockRepository.submitData(any()) } returns expected
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.SubmitClick)
val errorState = awaitItem()
assertTrue(errorState.dialogState is ExampleState.DialogState.Error)
}
}
@Test
fun `BackClick should emit NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ExampleAction.BackClick)
assertEquals(ExampleEvent.NavigateBack, awaitItem())
}
}
// Helper to create ViewModel with optional saved state
private fun createViewModel(
state: ExampleState? = DEFAULT_STATE,
): ExampleViewModel = ExampleViewModel(
savedStateHandle = SavedStateHandle(
mapOf(KEY_STATE to state),
),
exampleRepository = mockRepository,
)
companion object {
private val DEFAULT_STATE = ExampleState(
isLoading = false,
data = null,
)
}
}
```
### Flow Testing with stateEventFlow
```kotlin
@Test
fun `SubmitClick should update state and emit event`() = runTest {
coEvery { mockRepository.submitData(any()) } returns ExampleResult.Success("data")
val viewModel = createViewModel()
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
viewModel.trySendAction(ExampleAction.SubmitClick)
// Assert state change
assertEquals(
DEFAULT_STATE.copy(data = "data"),
stateFlow.awaitItem(),
)
// Assert event emission
assertEquals(
ExampleEvent.ShowToast("Success".asText()),
eventFlow.awaitItem(),
)
}
}
```

View 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`

View 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)

View 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

View File

@@ -1,2 +0,0 @@
# Session logs folder
logs/

View File

@@ -1,244 +0,0 @@
---
name: retrospecting
description: Performs comprehensive analysis of Claude Code sessions, examining git history, conversation logs, code changes, and gathering user feedback to generate actionable retrospective reports with insights for continuous improvement.
---
# Session Retrospective Skill
## Purpose
Analyze completed Claude Code sessions to identify successful patterns, problematic areas, and opportunities for improvement. Generate structured retrospective reports that help users understand what worked, what didn't, and how to optimize future sessions.
## Auto-Loaded Context
**Session Analytics**: [session-analytics.md](/.claude/skills/retrospecting/contexts/session-analytics.md) - Provides comprehensive framework for analyzing sessions, including data sources, metrics, and analysis methods.
**Retrospective Templates**: [retrospective-templates.md](/.claude/skills/retrospecting/templates/retrospective-templates.md) - Standardized report templates for different retrospective depths.
## When to Use This Skill
Invoke this skill when users:
- Request a retrospective or post-mortem of a session
- Ask "how did that go?" or "what could be improved?"
- Want to analyze the effectiveness of a completed task
- Request feedback on the session workflow
- Ask for a summary of what was accomplished and lessons learned
## Core Responsibilities
### 1. Multi-Source Data Collection
Systematically gather data from all available sources:
- **Git History**: Commits, diffs, file changes during session timeframe
- **Claude Logs**: Conversation transcripts, tool usage, decision patterns
- **Project Files**: Test coverage, code quality, compilation status
- **User Feedback**: Direct input about goals, satisfaction, pain points
- **Sub-agent Interactions**: When sub-agents were used, gather their feedback
### 2. Quantitative Analysis
Calculate measurable metrics:
- Session scope (duration, tasks completed, files changed)
- Quality indicators (compilation rate, test coverage, standard compliance)
- Efficiency metrics (tool success rate, rework rate, completion rate)
- User experience data (satisfaction, friction points)
### 3. Qualitative Assessment
Identify patterns and insights:
- Successful approaches that led to good outcomes
- Problematic patterns that caused issues or delays
- Reusable solutions worth extracting for future use
- Context-specific learnings applicable to this project type
### 4. Report Generation
Create structured retrospective report using appropriate template:
- **Quick Retrospective**: Brief session wrap-ups (5-10 minutes)
- **Comprehensive Retrospective**: Detailed analysis for significant sessions
- Choose template based on session complexity and user needs
## Working Process
### Step 1: Establish Session Scope
1. Ask user to define session boundaries (time range or commit range)
2. Clarify session goals: "What were you trying to accomplish?"
3. Determine retrospective depth needed (quick vs comprehensive)
### Step 2: Gather Data
Execute data collection from all sources:
**Git Analysis**:
```bash
# Identify session commits
git log --since="<start-time>" --until="<end-time>" --oneline
# Analyze changes
git diff <start-commit>...<end-commit> --stat
git diff <start-commit>...<end-commit> --name-only
```
**Claude Logs**: Read relevant logs from `.claude/logs/` directory
**Project Analysis**: Examine changed files, tests, documentation
**User Feedback**: Prompt for direct feedback on session experience
**Sub-agent Feedback**: If sub-agents were used, invoke them to gather their perspective on the session and their interactions with Claude
### Step 3: Analyze Data
Apply session-analytics.md framework:
- Calculate quantitative metrics
- Identify success and problem indicators
- Extract patterns (successful approaches and anti-patterns)
- Assess communication effectiveness and technical quality
### Step 4: Generate Insights
Synthesize analysis into actionable insights:
- What went well and why (specific evidence)
- What caused problems and their root causes
- Opportunities for improvement (prioritized by impact)
- Patterns to replicate or avoid in future sessions
### Step 5: Create Report
Use appropriate template from retrospective-templates.md:
- Structure findings clearly with evidence
- Include specific file:line references where relevant
- Prioritize recommendations by impact and feasibility
- Make all suggestions actionable and specific
### Step 6: Gather User Validation
Present report and ask:
- Does this match your experience?
- Are there other pain points we missed?
- Which improvements would be most valuable to you?
### Step 7: Suggest Configuration Improvements
If the retrospective identifies areas for improvement in Claude or Agent interactions:
1. Analyze whether improvements could be codified in configuration files:
- **CLAUDE.md**: Core directives, workflow practices, communication patterns
- **SKILL.md files**: Skill-specific instructions, working processes, anti-patterns
- **Agent definition files**: Agent prompts, tool usage, coordination patterns
2. Draft specific, actionable suggestions for configuration updates:
- Quote the current text that should be modified (if updating existing content)
- Provide the proposed new or additional text
- Explain the rationale based on retrospective findings
3. Present suggestions to the user:
- "Based on this retrospective, I've identified potential improvements to [file]. Would you like me to implement these changes?"
- Show the specific changes that would be made
4. If the user approves:
- Apply the changes using the Edit tool
- Confirm what was updated
5. If the user declines:
- Document the suggestions in the retrospective report for future consideration
### Step 8: Cleanup Log Files
After the retrospective report is created and validated:
1. Identify the log files from `.claude/logs/` that correspond to the session being analyzed
2. Ask the user if they want to delete these log files:
- "Would you like me to delete the session log files used for this retrospective?"
- Explain which files will be deleted (both `.md` and `.ndjson` files)
3. If the user confirms:
- Delete the specified log files using the Bash tool
- Confirm deletion to the user
4. If the user declines:
- Keep the log files and inform the user they remain available in `.claude/logs/`
## Output Standards
### Report Quality Requirements
- **Evidence-Based**: Every claim backed by specific examples
- **Actionable**: All recommendations include implementation guidance
- **Specific**: Avoid vague statements; use concrete examples
- **Prioritized**: Clear indication of high vs low impact items
- **Balanced**: Acknowledge successes while identifying improvements
### File References
Use `file:line_number` format when referencing specific code locations.
### Metrics Presentation
Present metrics in clear tables or lists with context for interpretation.
### Recommendations Format
Each recommendation should include:
- **What**: Specific action to take
- **Why**: Root cause or rationale
- **How**: Implementation approach
- **Impact**: Expected benefit
## Integration with Sub-agents
When sub-agents were used during the session:
### Feedback Collection
Invoke each sub-agent that participated with prompts like:
- "What aspects of this session worked well for you?"
- "What instructions or context were unclear?"
- "What tools or capabilities did you need but lack?"
- "How could coordination with Claude be improved?"
### Synthesis
Incorporate sub-agent feedback into retrospective:
- Identify coordination issues or handoff problems
- Note gaps in instruction clarity or context
- Recognize successful collaboration patterns
- Recommend improvements to sub-agent usage
## Anti-Patterns to Avoid
**Don't**:
- Generate retrospectives without gathering actual data
- Make vague, non-actionable recommendations
- Focus only on negatives; acknowledge what worked well
- Ignore user's stated priorities and goals
- Create overly long reports that bury key insights
- Analyze sessions without understanding the context and goals
**Do**:
- Ground analysis in concrete evidence from session data
- Provide specific, actionable recommendations with implementation guidance
- Balance positive recognition with improvement opportunities
- Align recommendations with user's priorities
- Create concise reports that highlight key insights prominently
- Understand session context before analyzing effectiveness
## Success Criteria
A good retrospective should:
1. **Inform**: User learns something new about their workflow
2. **Guide**: Clear next steps for improvement
3. **Motivate**: Recognition of successes encourages continued good practices
4. **Focus**: Prioritization helps user know where to invest effort
5. **Enable**: Provides frameworks/patterns user can apply to future sessions
## Example Usage
**User**: "Can you do a retrospective on what we just accomplished?"
**Skill Response**:
1. Clarify session scope and goals with user
2. Gather data from git history, logs, changed files
3. Analyze using session-analytics.md framework
4. Generate report using appropriate template
5. Present findings with specific examples and recommendations
6. Validate with user and refine based on feedback
7. Suggest configuration improvements to CLAUDE.md, SKILL.md, or agent files if applicable
8. Ask user if they want to delete the session log files and handle accordingly
## Storage
All generated retrospective reports should be stored within the skill's directory:
```
.claude/skills/retrospective/
├── SKILL.md
└── reports/
└── YYYY-MM-DD-session-description-SESSION_ID.md
```
**Path to use when writing reports**: `.claude/skills/retrospective/reports/YYYY-MM-DD-session-description-SESSION_ID.md`
**Filename format**: Include the session ID from the log files to enable traceability between reports and source logs.
**Benefits of this structure:**
- **Self-contained**: All skill outputs in one location
- **Portable**: Easy to share or version the skill with its history
- **Organized**: Reports stored in a dedicated directory
- **Reusable**: Can be copied to other projects without conflicts
This organization enables continuous learning across sessions while keeping the skill modular and portable.

View File

@@ -1,202 +0,0 @@
# Session Analytics Context
**Purpose**: Provides guidance for analyzing Claude Code sessions to generate meaningful retrospectives
**Owner**: Retrospective Skill
**Usage**: Auto-loaded when conducting session retrospectives
---
## Data Sources for Session Analysis
### 1. Git History Analysis
**What to Examine**:
- Commits made during the session (timestamps, messages, changed files)
- Diffs showing actual code changes and their scope
- Branch activity and merge patterns
- File modification frequency and complexity
**Key Metrics**:
- Number of files modified/created/deleted
- Lines of code added/removed
- Commit frequency and granularity
- Commit message quality and clarity
**Commands for Analysis**:
```bash
# Get commits from session time range
git log --since="YYYY-MM-DD HH:MM" --until="YYYY-MM-DD HH:MM" --oneline
# Detailed diff for session
git diff <start-commit>...<end-commit> --stat
# Files changed during session
git diff <start-commit>...<end-commit> --name-only
```
### 2. Claude Logs Analysis
**What to Examine**:
- `.claude/logs/` directory for conversation transcripts
- Tool usage patterns (which tools were called, frequency, success rates)
- Error messages and retry patterns
- Decision-making rationale in responses
**Key Indicators**:
- Repeated tool calls suggesting exploration or confusion
- Error recovery patterns
- Context switches and task transitions
- Clarification requests and user interactions
### 3. Project Files Analysis
**What to Examine**:
- Test coverage changes (new tests added, coverage percentages)
- Code quality indicators (complexity, duplication, adherence to standards)
- Documentation updates (README, inline comments, API docs)
- Build and compilation status
**Key Metrics**:
- Test-to-production code ratio
- Compilation success/failure
- Adherence to project coding standards
- Documentation completeness
### 4. User Feedback
**What to Gather**:
- Session goals and whether they were achieved
- User satisfaction with outcomes
- Pain points or friction during the session
- Specific examples of what worked well or poorly
**Gathering Methods**:
- Direct prompting: "What were your goals for this session?"
- Targeted questions: "Which parts of this session were most/least effective?"
- Outcome validation: "Did the implementation meet your expectations?"
### 5. Sub-agent Interaction Analysis
**What to Examine** (when applicable):
- Which sub-agents were invoked during the session
- Task handoffs between Claude and sub-agents
- Sub-agent success rates and output quality
- Communication clarity in agent instructions
**Feedback Collection**:
- Invoke sub-agents with retrospective prompts
- Ask about instruction clarity, tool availability, context sufficiency
- Gather suggestions for improved coordination
---
## Analysis Framework
### Success Indicators
**Code Quality**:
- Compilation succeeds without errors
- Tests pass with appropriate coverage
- Code follows project standards and patterns
- Security considerations properly addressed
**Workflow Efficiency**:
- Minimal rework or backtracking
- Efficient tool usage (right tool for the task)
- Clear progression toward stated goals
- Effective user-Claude communication
**Learning & Adaptation**:
- Applying lessons from earlier in session
- Recognizing and correcting mistakes
- Adapting approach based on feedback
- Discovering and using existing patterns
### Problem Indicators
**Code Quality Issues**:
- Compilation failures or test failures
- Deviations from project architecture/style
- Security vulnerabilities introduced
- Missing or inadequate documentation
**Workflow Inefficiencies**:
- Repeated failed attempts at same task
- Excessive tool calls without progress
- Misunderstanding requirements (multiple clarifications)
- Creating new patterns when existing ones should be used
**Communication Gaps**:
- Ambiguous instructions leading to wrong implementations
- User frustration or confusion
- Missing context causing incorrect assumptions
- Inadequate status updates or progress visibility
---
## Quantitative Metrics to Track
### Session Scope Metrics
- **Duration**: Total time from session start to completion
- **Task Count**: Number of distinct tasks/subtasks completed
- **File Impact**: Files created, modified, deleted
- **Code Volume**: Lines added, removed, net change
### Quality Metrics
- **Compilation Rate**: % of time code compiled successfully
- **Test Coverage**: Coverage percentage change during session
- **Rework Rate**: % of changes that required revision
- **Standard Compliance**: Adherence to project coding standards
### Efficiency Metrics
- **Tool Success Rate**: % of tool calls that succeeded on first attempt
- **Context Switches**: Number of major topic/task transitions
- **Clarification Rate**: User questions per task completed
- **Completion Rate**: % of stated goals fully achieved
### User Experience Metrics
- **Satisfaction**: User-reported satisfaction (if gathered)
- **Friction Points**: Number of reported pain points
- **Value Delivered**: User assessment of outcome usefulness
- **Would Repeat**: User willingness to use approach again
---
## Qualitative Analysis Areas
### Pattern Recognition
- **Successful Approaches**: What techniques led to good outcomes?
- **Problematic Patterns**: What approaches caused issues?
- **Reusable Solutions**: What can be extracted for future use?
- **Context-Specific Learnings**: What only applies to this project/task type?
### Communication Effectiveness
- **Instruction Clarity**: Were instructions clear and actionable?
- **Context Sufficiency**: Was enough context provided upfront?
- **Feedback Loops**: How well did iterative feedback work?
- **User Engagement**: Appropriate level of user involvement?
### Technical Excellence
- **Architecture Alignment**: Proper use of established patterns?
- **Code Quality**: Maintainable, readable, well-structured code?
- **Testing Rigor**: Appropriate test coverage and quality?
- **Security Awareness**: Proper handling of security considerations?
---
## Retrospective Output Guidelines
### Structure Recommendations
1. **Executive Summary**: High-level overview of session outcomes
2. **Quantitative Metrics**: Data-driven assessment of performance
3. **Qualitative Insights**: Pattern analysis and learnings
4. **Action Items**: Specific, prioritized improvements for future sessions
### Actionability Standards
- Every recommendation should be **specific** (not vague)
- Include **evidence** from session data to support claims
- Provide **implementation guidance** for improvements
- **Prioritize** based on impact and feasibility
### Audience Considerations
- **Users**: Want to know if goals were met, what to improve
- **Future Claude sessions**: Need actionable patterns to replicate or avoid
- **Marketplace consumers**: Need to understand value and use cases
- **Plugin developers**: May extend or integrate with other tools
---
This context provides a comprehensive framework for analyzing Claude Code sessions systematically and generating valuable retrospective insights.

View File

@@ -1,299 +0,0 @@
# Retrospective Report Templates
**Purpose**: Standardized templates for comprehensive session retrospectives
**Owner**: Retrospective Skill
**Storage**: All reports generated in `.claude/retro/` directory
---
## Template 1: Comprehensive Session Retrospective
```markdown
# Session Retrospective - [Project Name] [Session Date]
**Session ID**: [Unique identifier]
**Duration**: [Start time] - [End time] ([Total duration])
**Scope**: [Brief description of session goals]
**Outcome**: [SUCCESS/PARTIAL/NEEDS_FOLLOW_UP]
---
## Executive Summary
### Key Achievements
- [Major accomplishments from the session]
- [Quantitative metrics: lines of code, files created, tests added]
- [Quality outcomes: test coverage %, standard compliance]
### Success Metrics
| Metric | Target | Achieved | Status |
|--------|---------|----------|--------|
| Task Completion Rate | 90% | X% | ✅/⚠️/❌ |
| Compilation Success | 100% | X% | ✅/⚠️/❌ |
| Test Coverage | 80% | X% | ✅/⚠️/❌ |
| Code Quality Score | 8/10 | X/10 | ✅/⚠️/❌ |
---
## What Went Well
### Successful Workflow Patterns
1. **[Pattern Name]**: [Description]
- **Evidence**: [Specific examples from session with file:line references]
- **Impact**: [Quantified benefit - time saved, quality improved, etc.]
- **Replicability**: [How to repeat this success in future sessions]
2. **[Pattern Name]**: [Description]
- **Evidence**: [Specific examples from session]
- **Impact**: [Quantified benefit]
- **Replicability**: [How to repeat success]
### Quality Achievements
- **Architecture Compliance**: [Specific examples of well-implemented patterns]
- **Test Coverage**: [Coverage metrics and thoroughness of tests]
- **Security Standards**: [Security best practices followed]
- **Performance**: [Performance optimizations or considerations]
### Communication & Collaboration
- **Effective Interactions**: [What communication patterns worked well]
- **Clear Requirements**: [Examples of well-defined tasks]
- **Useful Feedback**: [Valuable user input that improved outcomes]
---
## Pain Points & Challenges
### Workflow Friction Areas
1. **[Issue Category]**: [Description]
- **Root Cause**: [Analysis of underlying cause]
- **Impact**: [Quantified impact - time lost, quality affected, etc.]
- **Frequency**: [How often this occurred]
- **Prevention Strategy**: [How to avoid in future sessions]
2. **[Issue Category]**: [Description]
- **Root Cause**: [Analysis of underlying cause]
- **Impact**: [Quantified impact on workflow]
- **Prevention Strategy**: [How to avoid in future]
### Technical Challenges
- **Compilation Issues**: [Number of failures, causes, resolution]
- **Test Failures**: [Test-related problems and how they were resolved]
- **Tool Limitations**: [Constraints or issues with available tools]
- **Integration Problems**: [Difficulties combining components or changes]
### Communication Gaps
- **Unclear Requirements**: [Cases where instructions were ambiguous]
- **Missing Context**: [Information that should have been provided upfront]
- **Assumption Mismatches**: [Differences between expected and actual outcomes]
---
## Improvement Recommendations
### Immediate Actions (Next Session)
1. **[High Priority Item]**: [Specific action with expected impact]
2. **[High Priority Item]**: [Specific action with expected impact]
3. **[High Priority Item]**: [Specific action with expected impact]
### Short-term Enhancements (1-2 weeks)
1. **[Enhancement Area]**: [Description and implementation approach]
2. **[Enhancement Area]**: [Description and implementation approach]
### Long-term Vision (1-3 months)
1. **[Strategic Improvement]**: [Long-term enhancement with roadmap]
2. **[Strategic Improvement]**: [Long-term enhancement with roadmap]
---
## Workflow Optimization Analysis
### Cycle Time Analysis
- **Average Task Duration**: [Time from start to completion]
- **Rework Frequency**: [% of tasks requiring significant revision]
- **Bottlenecks**: [Where workflow got stuck or slowed]
- **Efficiency Wins**: [What made progress faster]
### Quality Progression
- **Code Quality Trend**: [Quality improvement over session duration]
- **Test Coverage Trend**: [Coverage changes over time]
- **Standard Compliance**: [Adherence to project guidelines]
- **Learning Evidence**: [Signs of improving effectiveness during session]
### Tool & Resource Usage
- **Tool Effectiveness**: [Which tools were most/least effective]
- **Context Sufficiency**: [Was enough context available]
- **Documentation Quality**: [Usefulness of available documentation]
---
## Patterns for Future Reference
### Successful Patterns to Replicate
1. **[Pattern Name]**: [Description and replication instructions]
2. **[Pattern Name]**: [Description and replication instructions]
### Anti-Patterns to Avoid
1. **[Anti-Pattern Name]**: [Description and prevention strategy]
2. **[Anti-Pattern Name]**: [Description and prevention strategy]
### Context-Specific Learnings
- **Project Type**: [Insights specific to this type of project]
- **Task Complexity**: [Approaches that worked for this complexity level]
- **Tech Stack**: [Technology-specific patterns discovered]
---
## Sub-agent Feedback (if applicable)
### [Sub-agent Name] Feedback
- **Strengths**: [What worked well in this sub-agent's tasks]
- **Challenges**: [What caused difficulty or confusion]
- **Suggestions**: [Recommendations for improved coordination]
### [Sub-agent Name] Feedback
- **Strengths**: [What worked well]
- **Challenges**: [What caused difficulty]
- **Suggestions**: [Recommendations for improvement]
---
## User Experience Summary
### Most Valuable Aspects
- [What the user found most helpful about this session]
- [Specific features or approaches that delivered value]
### Friction Points
- [What caused user frustration or confusion]
- [Process inefficiencies from user perspective]
### Desired Improvements
- [User suggestions for enhancement]
- [Features or capabilities user wishes were available]
---
## Next Session Preparation
### Workflow Optimizations to Implement
- [ ] [Specific workflow change based on learnings]
- [ ] [Process improvement to apply next session]
- [ ] [Tool usage optimization to implement]
### Context Enhancements
- [ ] [Additional context to provide upfront]
- [ ] [Documentation updates needed]
- [ ] [Reference materials to prepare]
### Success Criteria
- [ ] [Define clear goals for next session]
- [ ] [Establish metrics to track]
- [ ] [Identify resources needed]
---
**Report Generated**: [Date/Time]
**Generated By**: Retrospective Skill
**Next Review**: [Scheduled follow-up date]
```
---
## Template 2: Quick Retrospective
```markdown
# Quick Retrospective - [Session Topic]
**Date**: [Date]
**Duration**: [Duration]
**Scope**: [One-line description of what was accomplished]
## Highlights ✅
- [Top success with specific example]
- [Key achievement with measurable outcome]
- [Quality win or best practice followed]
## Challenges ⚠️
- [Main technical challenge and resolution]
- [Process friction point]
- [Communication or clarity issue]
## Key Learnings 💡
- [Important pattern discovered]
- [Approach to replicate in future]
- [Anti-pattern to avoid next time]
## Action Items 🚀
- [ ] [Immediate improvement to implement]
- [ ] [Follow-up task for next session]
- [ ] [Documentation or context update needed]
**Quick Assessment**: [1-2 sentence overall evaluation of session effectiveness]
```
---
## Template Usage Guidelines
### When to Use Each Template
**Comprehensive Template**:
- End of major feature implementations
- Completion of multi-day or multi-task sessions
- When significant learnings emerged
- User requests detailed analysis
- Sessions with noteworthy successes or challenges
**Quick Template**:
- Regular session wrap-ups
- Single-task completions
- Brief work sessions (< 1 hour)
- Routine maintenance or updates
- User requests lightweight summary
### Customization Guidelines
**Project-Specific Adaptations**:
- Add sections relevant to specific project types (e.g., mobile, web, backend)
- Include project-specific quality metrics
- Adapt success criteria to match project goals
- Reference project-specific documentation and standards
**Metric Customization**:
- Adjust target percentages based on project maturity
- Add domain-specific metrics (e.g., accessibility scores, performance benchmarks)
- Include team or organization KPIs
- Track custom success indicators
**Context Adaptation**:
- Include relevant architectural patterns for the project
- Reference project coding standards
- Note security requirements specific to domain
- Consider team composition and expertise levels
### Storage and Organization
```
.claude/retro/
├── session-retrospectives/
│ ├── YYYY-MM-DD-brief-description.md
│ └── YYYY-MM-DD-another-session.md
└── patterns-library/
├── successful-patterns.md
└── anti-patterns-to-avoid.md
```
**Naming Convention**: `YYYY-MM-DD-brief-description.md`
- Use ISO date format for chronological sorting
- Keep description concise (3-5 words)
- Use hyphens for readability
**Pattern Library Maintenance**:
- Extract reusable patterns from retrospectives
- Update patterns as new evidence emerges
- Organize by project type, technology, or problem domain
- Reference specific retrospectives as evidence
---
These templates provide structured frameworks for capturing session insights and generating actionable recommendations for continuous improvement.

View File

@@ -1,110 +1,81 @@
---
name: reviewing-changes
description: Performs comprehensive code reviews for Bitwarden Android projects, verifying architecture compliance, style guidelines, compilation safety, test coverage, and security requirements. Use when reviewing pull requests, checking commits, analyzing code changes, verifying Bitwarden coding standards, evaluating MVVM patterns, checking Hilt DI usage, reviewing security implementations, or assessing test coverage. Automatically invoked by CI pipeline or manually for interactive code reviews.
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
# Reviewing Changes - Android Additions
This skill provides Android-specific workflow additions that complement the base `bitwarden-code-reviewer` agent standards.
## Instructions
Follow this process to review code changes for Bitwarden Android:
**IMPORTANT**: Work systematically through each step before providing feedback. Each checklist file includes structured thinking guidance for its review passes.
### Step 1: Understand Context
### Step 1: Retrieve Additional Details
Start with high-level assessment of the change's purpose and approach. Read PR/commit descriptions and understand what problem is being solved.
Retrieve any additional information linked to the pull request using available tools (JIRA MCP, GitHub API).
### Step 2: Verify Compliance
If pull request title and message do not provide enough context, request additional details from the reviewer:
- Link a JIRA ticket
- Associate a GitHub issue
- Link to another pull request
- Add more detail to the PR title or body
Systematically check each area against Bitwarden standards documented in `CLAUDE.md`:
**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
1. **Architecture**: Follow patterns in `docs/ARCHITECTURE.md`
- MVVM + UDF (ViewModels with `StateFlow`, Compose UI)
- Hilt DI (interface injection, `@HiltViewModel`)
- Repository pattern and proper data flow
### Step 2: Detect Change Type with Android Refinements
2. **Style**: Adhere to `docs/STYLE_AND_BEST_PRACTICES.md`
- Naming conventions, code organization, formatting
- Kotlin idioms (immutability, null safety, coroutines)
Use the base change type detection from the agent, with Android-specific refinements:
3. **Compilation**: Analyze for potential build issues
- Import statements and dependencies
- Type safety and null safety
- API compatibility and deprecation warnings
- Resource references and manifest requirements
**Android-specific patterns:**
- **Feature Addition**: New `ViewModel`, new `Repository`, new `@Composable` functions, new `*Screen.kt` files
- **UI Refinement**: Changes only in `*Screen.kt`, `*Composable.kt`, `ui/` package files
- **Infrastructure**: Changes to `.github/workflows/`, `gradle/`, `build.gradle.kts`, `libs.versions.toml`
- **Dependency Update**: Changes only to `libs.versions.toml` or `build.gradle.kts` with version bumps
4. **Testing**: Verify appropriate test coverage
- Unit tests for business logic and utility functions
- Integration tests for complex workflows
- UI tests for user-facing features when applicable
- Test coverage for edge cases and error scenarios
### Step 3: Load Appropriate Checklist
5. **Security**: Given Bitwarden's security-focused nature
- Proper handling of sensitive data
- Secure storage practices (Android Keystore)
- Authentication and authorization patterns
- Data encryption and decryption flows
- Zero-knowledge architecture preservation
Based on detected type, read the relevant checklist file:
### Step 3: Document Findings
- **Dependency Update** → `checklists/dependency-update.md` (expedited review)
- **Bug Fix** → `checklists/bug-fix.md` (focused review)
- **Feature Addition** → `checklists/feature-addition.md` (comprehensive review)
- **UI Refinement** → `checklists/ui-refinement.md` (design-focused review)
- **Refactoring** → `checklists/refactoring.md` (pattern-focused review)
- **Infrastructure** → `checklists/infrastructure.md` (tooling-focused review)
Identify specific violations with `file:line_number` references. Be precise about locations.
The checklist provides:
- Multi-pass review strategy
- Type-specific focus areas
- What to check and what to skip
- Structured thinking guidance
### Step 4: Provide Recommendations
### Step 4: Execute Review Following Checklist
Give actionable recommendations for improvements. Explain why changes are needed and suggest specific solutions.
Follow the checklist's multi-pass strategy, thinking through each pass systematically.
### Step 5: Flag Critical Issues
### Step 5: Consult Android Reference Materials As Needed
Highlight issues that must be addressed before merge. Distinguish between blockers and suggestions.
Load reference files only when needed for specific questions:
### Step 6: Acknowledge Quality
- **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)
- **Security questions (quick reference)** → `reference/security-patterns.md` (common patterns and anti-patterns)
- **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 (project-specific)** → `reference/style-patterns.md` (Kotlin rules enforced in review)
- **Style questions (general)** → `docs/STYLE_AND_BEST_PRACTICES.md`
Note well-implemented patterns (briefly, without elaboration). Keep positive feedback concise.
## Core Principles
## Review Anti-Patterns (DO NOT)
- Be nitpicky about linter-catchable style issues
- Review without understanding context - ask for clarification first
- Focus only on new code - check surrounding context for issues
- Request changes outside the scope of this changeset
## Examples
### Good Review Format
```markdown
## Summary
This PR adds biometric authentication to the login flow, implementing MVVM pattern with proper state management.
## Critical Issues
- `app/login/LoginViewModel.kt:45` - Mutable state exposed; use `StateFlow` instead of `MutableStateFlow`
- `data/auth/BiometricRepository.kt:120` - Missing null safety check on `biometricPrompt` result
## Suggested Improvements
- Consider extracting biometric prompt logic to separate use case class
- Add integration tests for biometric failure scenarios
- `app/login/LoginScreen.kt:89` - Consider using existing `BitwardenButton` component
## Good Practices
- Proper Hilt DI usage throughout
- Comprehensive unit test coverage
- Clear separation of concerns
## Action Items
1. Fix mutable state exposure in `LoginViewModel`
2. Add null safety check in `BiometricRepository`
3. Consider adding integration tests for error flows
```
### What to Focus On
**DO focus on:**
- Architecture violations (incorrect patterns)
- Security issues (data handling, encryption)
- Missing tests for critical paths
- Compilation risks (type safety, null safety)
**DON'T focus on:**
- Minor formatting (handled by linters)
- Personal preferences without architectural basis
- Issues outside the changeset scope
- **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
- **Efficient reviews**: Use multi-pass strategy, skip what's not relevant
- **Android patterns**: Validate MVVM, Hilt DI, Compose conventions, Kotlin idioms

View File

@@ -0,0 +1,137 @@
# Bug Fix Review Checklist
## Multi-Pass Strategy
### First Pass: Understand the Bug
**1. Understand root cause:**
- What was the broken behavior?
- What caused it?
- How does this fix address the root cause?
**2. Assess scope:**
- How many files changed?
- Is this a targeted fix or broader refactoring?
- Does this affect multiple features?
**3. Check for side effects:**
- Could this break other features?
- Are there edge cases not considered?
### Second Pass: Verify the Fix
**4. Code changes:**
- Does the fix make sense?
- Is it the simplest solution?
- Any unnecessary changes included?
**5. Testing:**
- Is there a regression test?
- Does test verify the bug is fixed?
- Are edge cases covered?
**6. Related code:**
- Same pattern in other places that might have same bug?
- Should other similar code be fixed too?
## What to CHECK
**Root Cause Analysis**
- Does the fix address the root cause or just symptoms?
- Is the explanation in PR/commit clear?
**Regression Testing**
- Is there a new test that would fail without this fix?
- Does test cover the reported bug scenario?
- Are related edge cases tested?
**Side Effects**
- Could this break existing functionality?
- Are there similar code paths that need checking?
- Does this change behavior in unexpected ways?
**Fix Scope**
- Is the fix appropriately scoped (not too broad, not too narrow)?
- Are all instances of the bug fixed?
- Any related bugs discovered during investigation?
## What to SKIP
**Full Architecture Review** - Unless fix reveals architectural problems
**Comprehensive Testing Review** - Focus on regression tests, not entire test suite
**Major Refactoring Suggestions** - Unless directly related to preventing similar bugs
## Red Flags
🚩 **No test for the bug** - How will we prevent regression?
🚩 **Fix doesn't match root cause** - Is this fixing symptoms?
🚩 **Broad changes beyond the bug** - Should this be split into separate PRs?
🚩 **Similar patterns elsewhere** - Should those be fixed too?
## Key Questions to Ask
Use `reference/review-psychology.md` for phrasing:
- "Can we add a test that would fail without this fix?"
- "I see this pattern in [other file] - does it have the same issue?"
- "Is this fixing the root cause or masking the symptom?"
- "Could this change affect [related feature]?"
## Prioritizing Findings
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
## Example Review
```markdown
**Overall Assessment:** APPROVE
See inline comments for suggested improvements.
```
**Inline comment examples:**
```
**data/auth/BiometricRepository.kt:120** - SUGGESTED: Extract null handling
<details>
<summary>Details</summary>
Root cause analysis: BiometricPrompt result was nullable but code assumed non-null, causing crash on cancellation (PM-12345).
Consider extracting null handling pattern:
\```kotlin
private fun handleBiometricResult(result: BiometricPrompt.AuthenticationResult?): AuthResult {
return result?.let { AuthResult.Success(it) } ?: AuthResult.Cancelled
}
\```
This pattern could be reused if we add other biometric auth points.
</details>
```
```
**app/auth/BiometricViewModel.kt:89** - SUGGESTED: Add regression test
<details>
<summary>Details</summary>
Add test for cancellation scenario to prevent regression:
\```kotlin
@Test
fun `when biometric cancelled then returns cancelled state`() = runTest {
coEvery { repository.authenticate() } returns Result.failure(CancelledException())
viewModel.onBiometricAuth()
assertEquals(AuthState.Cancelled, viewModel.state.value)
}
\```
This prevents regression of the bug just fixed.
</details>
```

View File

@@ -0,0 +1,139 @@
# Dependency Update Review Checklist
## Multi-Pass Strategy
### First Pass: Identify and Assess
**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?
- Single dependency or multiple?
**2. Check compilation safety:**
- Any imports in codebase that might break?
- Any deprecated APIs we're currently using?
- Check if this is a breaking change version
### Second Pass: Deep Analysis
**3. Review release notes** (if available):
- Breaking changes mentioned?
- Security fixes included?
- New features we should know about?
- Deprecations that affect our usage?
**4. Verify consistency:**
- If updating androidx library, are related libraries updated consistently?
- BOM (Bill of Materials) consistency if applicable?
- Test dependencies updated alongside main dependencies?
## What to CHECK
**Compilation Safety**
- Look for API deprecations in our codebase
- Check if import statements still valid
- Major version bumps require extra scrutiny
- Beta/alpha versions need stability assessment
**Security Implications** (if applicable)
- Security-related libraries (crypto, auth, networking)?
- Check for CVEs addressed in release notes
- Review security advisories for this library
**Testing Implications**
- Does this affect test utilities?
- Are there breaking changes in test APIs?
- Do existing tests still cover the same scenarios?
**Changelog Review**
- Read release notes for breaking changes
- Note any behavioral changes
- Check migration guides if major version
## What to SKIP
**Full Architecture Review** - No code changed, patterns unchanged
**Code Style Review** - No code to review
**New Test Requirements** - Unless API changed significantly
**Security Deep-Dive** - Unless crypto/auth/networking library
**Performance Analysis** - Unless release notes mention performance changes
## Red Flags (Escalate to Full Review)
🚩 **Major version bump** (e.g., 1.x → 2.0) - Read `checklists/feature-addition.md`
🚩 **Security/crypto library** - Read `reference/architectural-patterns.md` and `docs/ARCHITECTURE.md#security`
🚩 **Breaking changes in release notes** - Read relevant code sections carefully
🚩 **Multiple dependency updates at once** - Check for interaction risks
🚩 **Beta/Alpha versions** - Assess stability concerns and rollback plan
If any red flags present, escalate to more comprehensive review using appropriate checklist.
## Prioritizing Findings
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
## Example Reviews
### Example 1: Simple Patch Version (No Critical Issues)
```markdown
**Overall Assessment:** APPROVE
See inline comments for all issue details.
```
**Inline comment example:**
```
**libs.versions.toml:45** - SUGGESTED: Beta version in production
<details>
<summary>Details</summary>
androidx.credentials updated from 1.5.0 to 1.6.0-beta03
Monitor for stability issues - beta releases may have unexpected behavior in production.
Changelog: Adds support for additional credential types, internal bug fixes.
</details>
```
### Example 2: Major Version with Breaking Changes (With Critical Issues)
```markdown
**Overall Assessment:** REQUEST CHANGES
**Critical Issues:**
- Breaking API changes in Retrofit 3.0.0 (network/api/BitwardenApiService.kt)
- Breaking API changes in Retrofit 3.0.0 (network/api/VaultApiService.kt)
See inline comments for migration details.
```
**Inline comment example:**
```
**network/api/BitwardenApiService.kt:15** - CRITICAL: Breaking API changes
<details>
<summary>Details and fix</summary>
Retrofit 3.0.0 removes `Call<T>` return type. Migration required:
\```kotlin
// Before
fun getUser(): Call<UserResponse>
// After
suspend fun getUser(): Response<UserResponse>
\```
Update all API service interfaces to use suspend functions, update call sites to use coroutines instead of enqueue/execute, and update tests accordingly.
Consider creating a separate PR for this migration due to scope.
Reference: https://github.com/square/retrofit/blob/master/CHANGELOG.md#version-300
</details>
```

View File

@@ -0,0 +1,211 @@
# Feature Addition Review Checklist
## Multi-Pass Strategy
### First Pass: High-Level Assessment
**1. Understand the feature:**
- Read PR description - what problem does this solve?
- Identify user-facing changes vs internal changes
- Note any security implications (auth, encryption, data handling)
**2. Scan file structure:**
- Which modules affected? (app, data, network, ui, core?)
- Are files organized correctly per module structure?
- Any new public APIs introduced?
**3. Initial risk assessment:**
- Does this touch sensitive data or security-critical paths?
- Does this affect existing features or only add new ones?
- Are there obvious compilation or null safety issues?
### Second Pass: Architecture Deep-Dive
**4. MVVM + UDF Pattern Compliance:**
- ViewModels properly structured?
- State management using StateFlow?
- Business logic in correct layer?
**5. Dependency Injection:**
- Hilt DI used correctly?
- Dependencies injected, not manually instantiated?
- Proper scoping applied?
**6. Module Organization:**
- Code placed in correct modules?
- No circular dependencies introduced?
- Proper separation of concerns?
**7. Error Handling:**
- Using Result types, not exception-based handling?
- Errors propagated correctly through layers?
### Third Pass: Details and Quality
**8. Testing:**
- Unit tests for ViewModels and repositories?
- Test coverage for edge cases and error scenarios?
- Tests verify behavior, not implementation?
**9. Code Quality:**
- Null safety handled properly?
- Public APIs have KDoc documentation?
- Naming follows project conventions?
**10. Security:**
- Sensitive data encrypted properly?
- Authentication/authorization handled correctly?
- Zero-knowledge architecture preserved?
## Architecture Review
Read `reference/architectural-patterns.md` for full patterns and code examples.
**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
Reference: `docs/ARCHITECTURE.md#security`
**Critical Security Checks:**
- **Sensitive data encrypted**: Passwords, keys, tokens use Android Keystore or EncryptedSharedPreferences
- **No plaintext secrets**: No passwords/keys in logs, memory dumps, or SharedPreferences
- **Input validation**: All user-provided data validated and sanitized
- **Authentication tokens**: Securely stored and transmitted
- **Zero-knowledge architecture**: Encryption happens client-side, server never sees plaintext
**Red Flags:**
```kotlin
// ❌ CRITICAL - Plaintext storage
sharedPreferences.edit {
putString("pin", userPin) // Must use EncryptedSharedPreferences
}
// ❌ CRITICAL - Logging sensitive data
Log.d("Auth", "Password: $password") // Never log sensitive data
// ❌ CRITICAL - Weak encryption
val cipher = Cipher.getInstance("DES") // Use AES-256-GCM
// ✅ GOOD - Keystore encryption
val encryptedData = keystoreManager.encrypt(sensitiveData)
secureStorage.store(encryptedData)
```
**If security concerns found, classify as CRITICAL using `reference/priority-framework.md`**
## Testing Review
Reference: `reference/testing-patterns.md`
**Required Test Coverage:**
- **ViewModels**: Unit tests for state transitions, actions, error scenarios
- **Repositories**: Unit tests for data transformations, error handling
- **Business logic**: Unit tests for complex algorithms, calculations
- **Edge cases**: Null inputs, empty states, network failures, concurrent operations
**Test Quality:**
```kotlin
// ✅ GOOD - Tests behavior
@Test
fun `when login succeeds then state updates to success`() = runTest {
val viewModel = LoginViewModel(mockRepository)
coEvery { mockRepository.login(any(), any()) } returns Result.success(User())
viewModel.onLoginClicked("user", "pass")
viewModel.state.test {
assertEquals(LoginState.Success, awaitItem())
}
}
// ❌ BAD - Tests implementation
@Test
fun `repository is called with correct parameters`() {
// This is testing internal implementation, not behavior
}
```
**Testing Frameworks:**
- JUnit 5 for test structure
- MockK for mocking
- Turbine for Flow testing
- Kotlinx-coroutines-test for coroutine testing
## Code Quality
### Null Safety
- No `!!` (non-null assertion) without clear safety guarantee
- Platform types (from Java) handled with explicit nullability
- Nullable types have proper null checks or use safe operators (`?.`, `?:`)
```kotlin
// ❌ BAD - Unsafe assertion
val result = apiService.getData()!! // Could crash
// ✅ GOOD - Safe handling
val result = apiService.getData() ?: return State.Error("No data")
// ❌ BAD - Platform type unchecked
val intent: Intent = getIntent() // Could be null from Java
intent.getStringExtra("key") // Potential NPE
// ✅ GOOD - Explicit nullability
val intent: Intent? = getIntent()
intent?.getStringExtra("key")
```
### Documentation
- **Public APIs**: Have KDoc comments explaining purpose, parameters, return values
- **Complex algorithms**: Explained in comments
- **Non-obvious behavior**: Documented with rationale
```kotlin
// ✅ GOOD - Documented public API
/**
* Encrypts the given data using AES-256-GCM with a key from Android Keystore.
*
* @param plaintext The data to encrypt
* @return Result containing encrypted data or encryption error
*/
suspend fun encrypt(plaintext: ByteArray): Result<EncryptedData>
```
### Style Compliance
Reference: `docs/STYLE_AND_BEST_PRACTICES.md`
Only flag style issues if:
- Not caught by linters (Detekt, ktlint)
- Have architectural implications
- Significantly impact readability
Skip minor formatting (spaces, line breaks, etc.) - linters handle this.
## Prioritizing Findings
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
## Providing Feedback
Use `reference/review-psychology.md` for phrasing guidance.
**Key principles:**
- **Ask questions** for design decisions: "Can we use the existing BitwardenTextField component here?"
- **Be prescriptive** for clear violations: "Change MutableStateFlow to StateFlow (MVVM pattern requirement)"
- **Explain rationale**: "This exposes mutable state, violating unidirectional data flow"
- **Use I-statements**: "It's hard for me to understand this logic without comments"
- **Avoid condescension**: Don't use "just", "simply", "obviously"
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.

View File

@@ -0,0 +1,223 @@
# Infrastructure Review Checklist
## Multi-Pass Strategy
### First Pass: Understand the Change
**1. Identify the goal:**
- What problem does this solve?
- Is this optimization, fix, or new capability?
- What's the expected impact?
**2. Assess risk:**
- Does this affect production builds?
- Could this break CI/CD pipelines?
- Impact on developer workflow?
**3. Performance implications:**
- Will builds be faster or slower?
- CI time impact?
- Resource usage changes?
### Second Pass: Verify Implementation
**4. Configuration correctness:**
- Syntax valid?
- References correct?
- Secrets/credentials handled securely?
**5. Impact analysis:**
- What workflows/builds are affected?
- Rollback plan if this breaks?
- Documentation for team?
**6. Testing strategy:**
- How can this be tested before merge?
- Canary/gradual rollout possible?
- Monitoring for issues post-merge?
## What to CHECK
**Configuration Correctness**
- YAML/Groovy syntax valid
- File references correct
- Version numbers/tags valid
- Conditional logic sound
**Security**
- No hardcoded secrets or credentials
- GitHub secrets used properly
- Permissions appropriately scoped
- No sensitive data in logs
**Performance Impact**
- Build time implications understood
- CI queue time impact assessed
- Resource usage reasonable
**Rollback Plan**
- Can this be reverted easily?
- Dependencies on other changes?
- Gradual rollout possible?
**Documentation**
- Changes documented for team?
- README or CONTRIBUTING updated?
- Breaking changes clearly noted?
## What to SKIP
**Bikeshedding Configuration** - Unless clear performance/maintenance benefit
**Over-Optimization** - Unless current system has proven problems
**Suggesting Major Rewrites** - Unless current approach is fundamentally broken
## Red Flags
🚩 **Hardcoded secrets** - Use GitHub secrets or secure storage
🚩 **No rollback plan** - Critical infrastructure should be revertible
🚩 **Untested changes** - CI changes should be validated
🚩 **Breaking changes without notice** - Team needs advance warning
🚩 **Performance regression** - Builds shouldn't get significantly slower
## Key Questions to Ask
Use `reference/review-psychology.md` for phrasing:
- "What's the rollback plan if this breaks CI?"
- "Can we test this on a feature branch before main?"
- "Will this impact build times? By how much?"
- "Should this be documented in CONTRIBUTING.md?"
## Common Infrastructure Patterns
### GitHub Actions
```yaml
# ✅ GOOD - Secure, clear, tested
name: Build and Test
on:
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30 # Prevent runaway builds
steps:
- uses: actions/checkout@v4
- name: Run tests
env:
API_KEY: ${{ secrets.API_KEY }} # Secure secret usage
run: ./gradlew test
# ❌ BAD - Insecure, unclear
name: Build
on: push # Too broad, runs on all branches
jobs:
build:
runs-on: ubuntu-latest
# No timeout - could run forever
steps:
- run: |
export API_KEY="hardcoded_key_here" # Hardcoded secret!
./gradlew test
```
### Gradle Configuration
```kotlin
// ✅ GOOD - Clear, maintainable
dependencies {
implementation(libs.androidx.core.ktx) // Version catalog
implementation(libs.hilt.android)
testImplementation(libs.junit5)
testImplementation(libs.mockk)
}
// ❌ BAD - Hardcoded versions
dependencies {
implementation("androidx.core:core-ktx:1.12.0") // Hardcoded version
implementation("com.google.dagger:hilt-android:2.48")
}
```
### Build Optimization
```kotlin
// ✅ GOOD - Parallel, cached
tasks.register("checkAll") {
dependsOn("detekt", "ktlintCheck", "testStandardDebug")
group = "verification"
description = "Run all checks in parallel"
// Enable caching for faster builds
outputs.upToDateWhen { false }
}
// ❌ BAD - Sequential, no caching
tasks.register("checkAll") {
doLast {
exec { commandLine("./gradlew", "detekt") }
exec { commandLine("./gradlew", "ktlintCheck") } // Sequential
exec { commandLine("./gradlew", "test") }
}
}
```
## Prioritizing Findings
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
## Example Review
```markdown
## Summary
Optimizes CI build by parallelizing test execution and caching dependencies
Impact: Estimated 40% reduction in CI time (12 min → 7 min per build)
## Critical Issues
None
## Suggested Improvements
**.github/workflows/build.yml:23** - Add timeout for safety
```yaml
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30 # Prevent builds from hanging
steps:
# ...
```
This prevents runaway builds if something goes wrong.
**.github/workflows/build.yml:45** - Consider matrix strategy for module tests
Can we run module tests in parallel using a matrix strategy?
```yaml
strategy:
matrix:
module: [app, data, network, ui]
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: ./gradlew :${{ matrix.module }}:test
```
This could further reduce CI time.
**build.gradle.kts:12** - Document caching strategy
Can we add a comment explaining the caching configuration?
Future maintainers will appreciate understanding why these specific cache keys are used.
## Rollback Plan
If CI breaks:
- Revert commit: `git revert [commit-hash]`
- Previous workflow available at: `.github/workflows/build.yml@main^`
- Monitor CI times at: https://github.com/[org]/[repo]/actions
```

View File

@@ -0,0 +1,267 @@
# Refactoring Review Checklist
## Multi-Pass Strategy
### First Pass: Understand the Refactoring
**1. Understand the goal:**
- What pattern is being improved?
- Why is this refactoring needed?
- What's the scope of changes?
**2. Assess completeness:**
- Are all instances refactored or just some?
- Are there related areas that should also change?
- Is the migration complete or partial?
**3. Risk assessment:**
- Does this change behavior?
- How many files affected?
- Are tests updated to reflect changes?
### Second Pass: Verify Consistency
**4. Pattern consistency:**
- Is the new pattern applied consistently throughout?
- Are there missed instances of the old pattern?
- Does this match established project patterns?
**5. Migration completeness:**
- Old pattern fully removed or deprecated?
- All usages updated?
- Documentation updated?
**6. Test coverage:**
- Do tests still pass?
- Are tests refactored to match?
- Does behavior remain unchanged?
## What to CHECK
**Pattern Consistency**
- New pattern applied consistently across all touched code
- Follows established project patterns (MVVM, DI, error handling)
- No mix of old and new patterns
**Migration Completeness**
- All instances of old pattern updated?
- Deprecated methods removed or marked @Deprecated?
- Related code also updated (tests, docs)?
**Behavior Preservation**
- Refactoring doesn't change behavior
- Tests still pass
- Edge cases still handled
**Deprecation Strategy** (if applicable)
- Old APIs marked @Deprecated with migration guidance
- Replacement clearly documented
- Timeline for removal specified
## What to SKIP
**Suggesting Additional Refactorings** - Unless directly related to current changes
**Scope Creep** - Don't request refactoring of untouched code
**Perfection** - Better code is better than perfect code
## Red Flags
🚩 **Incomplete migration** - Mix of old and new patterns
🚩 **Behavior changes** - Refactoring shouldn't change behavior
🚩 **Broken tests** - Tests should be updated to match refactoring
🚩 **Undocumented pattern** - New pattern should be clear to team
## Key Questions to Ask
Use `reference/review-psychology.md` for phrasing:
- "I see the old pattern still used in [file:line] - should that be updated too?"
- "Can we add @Deprecated to the old method with migration guidance?"
- "How do we ensure this behavior remains the same?"
- "Should this pattern be documented in ARCHITECTURE.md?"
## Common Refactoring Patterns
### Extract Interface/Repository
```kotlin
// ✅ GOOD - Complete migration
interface FeatureRepository {
suspend fun getData(): Result<Data>
}
class FeatureRepositoryImpl @Inject constructor(
private val apiService: FeatureApiService
) : FeatureRepository {
override suspend fun getData(): Result<Data> = runCatching {
apiService.fetchData()
}
}
// All usages updated to inject interface
class FeatureViewModel @Inject constructor(
private val repository: FeatureRepository // Interface
) : ViewModel()
// ❌ BAD - Incomplete migration
// Some files still inject FeatureRepositoryImpl directly
```
### Modernize Error Handling
```kotlin
// ✅ GOOD - Complete migration
// Old exception-based removed
suspend fun fetchData(): Result<Data> = runCatching {
apiService.getData()
}
// All call sites updated
repository.fetchData().fold(
onSuccess = { /* handle */ },
onFailure = { /* handle */ }
)
// ❌ BAD - Mixed patterns
// Some functions use Result, others still throw exceptions
```
### Extract Reusable Component
```kotlin
// ✅ GOOD - Complete extraction
// Component moved to :ui module
@Composable
fun BitwardenButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
)
// All usages updated to use new component
// Old inline button implementations removed
// ❌ BAD - Incomplete extraction
// Some screens use new component, others still have inline implementation
```
## Prioritizing Findings
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
## Example Reviews
### Example 1: Refactoring with Incomplete Migration
**Context**: Refactoring authentication to Repository pattern, but one ViewModel still uses old pattern
**Summary Comment:**
```markdown
**Overall Assessment:** REQUEST CHANGES
**Critical Issues:**
- Incomplete migration (app/vault/VaultViewModel.kt:89)
See inline comments for details.
```
**Inline Comment 1** (on `app/vault/VaultViewModel.kt:89`):
```markdown
**IMPORTANT**: Incomplete migration
<details>
<summary>Details and fix</summary>
This ViewModel still injects AuthManager directly. Should it use AuthRepository like the other 11 ViewModels?
\```kotlin
// Current (old pattern)
class VaultViewModel @Inject constructor(
private val authManager: AuthManager
)
// Should be (new pattern)
class VaultViewModel @Inject constructor(
private val authRepository: AuthRepository
)
\```
This is the only ViewModel still using the old pattern.
</details>
```
**Inline Comment 2** (on `data/auth/AuthManager.kt:1`):
```markdown
**SUGGESTED**: Add deprecation notice
<details>
<summary>Details</summary>
Can we add @Deprecated to AuthManager to guide future development?
\```kotlin
@Deprecated(
message = "Use AuthRepository interface instead",
replaceWith = ReplaceWith("AuthRepository"),
level = DeprecationLevel.WARNING
)
class AuthManager @Inject constructor(...)
\```
This helps prevent new code from using the old pattern.
</details>
```
---
### Example 2: Clean Refactoring (No Issues)
**Context**: Refactoring with complete migration, all patterns followed correctly, tests passing
**Review Comment:**
```markdown
**Overall Assessment:** APPROVE
Clean refactoring moving ExitManager to :ui module. Follows established patterns, eliminates duplication, tests updated correctly.
```
**Token count:** ~30 tokens (vs ~800 for verbose format)
**Why this works:**
- 3 lines total
- Clear approval decision
- Briefly notes what was done
- No elaborate sections, checkmarks, or excessive praise
- Author gets immediate green light to merge
**What NOT to do for clean refactorings:**
```markdown
❌ DO NOT create these sections:
## Summary
This PR successfully refactors ExitManager into shared code...
## Key Strengths
- ✅ Follows established module organization patterns
- ✅ Removes code duplication between apps
- ✅ Improves test coverage
- ✅ Maintains consistent behavior
[...20 more checkmarks...]
## Code Quality & Architecture
**Architectural Compliance:** ✅
- Correctly places manager in :ui module
- Follows established pattern for UI-layer managers
[...detailed analysis...]
## Changes
- ✅ Moved ExitManager interface from app → ui module
- ✅ Moved ExitManagerImpl from app → ui module
[...listing every file...]
```
This is excessive. **For clean PRs: 2-3 lines maximum.**

View File

@@ -0,0 +1,206 @@
# UI Refinement Review Checklist
## Multi-Pass Strategy
### First Pass: Visual Changes
**1. Understand the changes:**
- What visual/UX problem is being solved?
- Are there designs or screenshots to reference?
- Is this a bug fix or enhancement?
**2. Component usage:**
- Using existing components from `:ui` module?
- Any new custom components created?
- Could existing components be reused?
### Second Pass: Implementation Review
**3. Compose best practices:**
- Composables properly structured?
- State hoisted correctly?
- Preview composables included?
**4. Accessibility:**
- Content descriptions for images/icons?
- Semantic properties for screen readers?
- Touch targets meet minimum size (48dp)?
**5. Design consistency:**
- Using theme colors, spacing, typography?
- Consistent with other screens?
- Responsive to different screen sizes?
## What to CHECK
**Compose Best Practices**
- Composables are stateless where possible
- State hoisting follows patterns
- Side effects (LaunchedEffect, DisposableEffect) used correctly
- Preview composables provided for development
**Component Reuse**
- Using existing BitwardenButton, BitwardenTextField, etc.?
- Could custom UI be replaced with existing components?
- New reusable components placed in `:ui` module?
**Accessibility**
- `contentDescription` for icons and images
- `semantics` for custom interactions
- Sufficient contrast ratios
- Touch targets ≥ 48dp minimum
**Design Consistency**
- Using `BitwardenTheme` colors (not hardcoded)
- Using `BitwardenTheme` spacing (16.dp, 8.dp, etc.)
- Using `BitwardenTheme` typography styles
- Consistent with existing screen patterns
**Responsive Design**
- Handles different screen sizes?
- Scrollable content where appropriate?
- Landscape orientation considered?
## What to SKIP
**Deep Architecture Review** - Unless ViewModel changes are substantial
**Business Logic Review** - Focus is on presentation, not logic
**Security Review** - Unless UI exposes sensitive data improperly
## Red Flags
🚩 **Duplicating existing components** - Should reuse from `:ui` module
🚩 **Hardcoded colors/dimensions** - Should use theme
🚩 **Missing accessibility properties** - Critical for screen readers
🚩 **State management in UI** - Should be hoisted to ViewModel
## Key Questions to Ask
Use `reference/review-psychology.md` for phrasing:
- "Can we use BitwardenButton here instead of this custom button?"
- "Should this color come from BitwardenTheme instead of being hardcoded?"
- "How will this look on a small screen?"
- "Is there a contentDescription for this icon?"
## Common Patterns
### Composable Structure
```kotlin
// ✅ GOOD - Stateless, hoisted state
@Composable
fun FeatureScreen(
state: FeatureState,
onActionClick: () -> Unit,
modifier: Modifier = Modifier
) {
// UI rendering only
}
// ❌ BAD - Business state in composable
@Composable
fun FeatureScreen() {
var userData by remember { mutableStateOf<User?>(null) } // Business state should be in ViewModel
var isLoading by remember { mutableStateOf(false) } // App state should be in ViewModel
// ...
}
// ✅ OK - UI-local state in composable
@Composable
fun LoginForm(onSubmit: (String, String) -> Unit) {
var username by remember { mutableStateOf("") } // UI-local input state is fine
var password by remember { mutableStateOf("") }
// Hoist only as high as needed
}
```
### Theme Usage
```kotlin
// ✅ GOOD - Using theme
Text(
text = "Title",
style = BitwardenTheme.typography.titleLarge,
color = BitwardenTheme.colorScheme.primary
)
// Design system uses 4.dp increments (4, 8, 12, 16, 24, 32, etc.)
Spacer(modifier = Modifier.height(16.dp))
// ❌ BAD - Hardcoded
Text(
text = "Title",
style = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold), // Should use theme
color = Color(0xFF0000FF) // Should use theme color
)
Spacer(modifier = Modifier.height(17.dp)) // Non-standard spacing
```
### Accessibility
```kotlin
// ✅ GOOD - Interactive element with description
Icon(
painter = painterResource(R.drawable.ic_password),
contentDescription = "Password visibility toggle",
modifier = Modifier.clickable { onToggle() }
)
// ✅ GOOD - Decorative icon with explicit null
Icon(
painter = painterResource(R.drawable.ic_check),
contentDescription = null, // Decorative icon next to descriptive text
tint = BitwardenTheme.colorScheme.success
)
// ❌ BAD - Interactive element missing description
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null, // Interactive elements need descriptions
modifier = Modifier.clickable { onDelete() }
)
```
## Prioritizing Findings
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
## Output Format
See `examples/review-outputs.md` for the required output format and inline comment structure.
## Example Review
```markdown
## Summary
Updates login screen layout for improved visual hierarchy and touch targets
## Critical Issues
None
## Suggested Improvements
**app/auth/LoginScreen.kt:67** - Can we use BitwardenTextField?
This custom text field looks very similar to `ui/components/BitwardenTextField.kt:89`.
Would using the existing component maintain consistency?
**app/auth/LoginScreen.kt:123** - Add contentDescription
```kotlin
Icon(
painter = painterResource(R.drawable.ic_visibility),
contentDescription = "Show password", // Add for accessibility
modifier = Modifier.clickable { onToggleVisibility() }
)
```
**app/auth/LoginScreen.kt:145** - Use design system spacing
```kotlin
// Current
Spacer(modifier = Modifier.height(17.dp))
// Design system uses 4.dp increments (4, 8, 12, 16, 24, 32, etc.)
Spacer(modifier = Modifier.height(16.dp))
```
```

View File

@@ -0,0 +1,459 @@
# Review Output Examples
Well-structured code reviews demonstrating appropriate depth, tone, and formatting for different change types.
## Table of Contents
**Format Reference:**
- [Quick Format Reference](#quick-format-reference)
- [Inline Comment Format](#inline-comment-format-required)
- [Summary Comment Format](#summary-comment-format)
**Examples:**
- [Example 1: Clean PR (No Issues)](#example-1-clean-pr-no-issues)
- [Example 2: Dependency Update with Breaking Changes](#example-2-dependency-update-with-breaking-changes)
- [Example 3: Feature Addition with Critical Issues](#example-3-feature-addition-with-critical-issues)
**Anti-Patterns:**
- [❌ Anti-Patterns to Avoid](#-anti-patterns-to-avoid)
- [Problem: Verbose Summary with Multiple Sections](#problem-verbose-summary-with-multiple-sections)
- [Problem: Praise-Only Inline Comments](#problem-praise-only-inline-comments)
- [Problem: Missing `<details>` Tags](#problem-missing-details-tags)
**Summary:**
- [Summary](#summary)
---
## Quick Format Reference
### Inline Comment Format (REQUIRED)
**MUST use `<details>` tags.** Only severity + description visible; all other content collapsed.
```
[emoji] **[SEVERITY]**: [One-line issue description]
<details>
<summary>Details and fix</summary>
[Code example or specific fix]
[Rationale explaining why]
Reference: [docs link if applicable]
</details>
```
**Severity Levels:**
-**CRITICAL** - Blocking, must fix (security, crashes, architecture violations)
- ⚠️ **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)
### Summary Comment Format
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
[1-2 neutral sentences describing what was reviewed]
**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>
```
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.
---
## Example 1: Clean PR (No Issues)
**Context**: Moving shared code to common module, complete migration, all patterns followed
**Review Comment:**
```markdown
**Overall Assessment:** APPROVE
Clean refactoring that moves ExitManager to :ui module, eliminating duplication between apps.
```
**Why this works:**
- Immediate approval visible (2-3 lines)
- One sentence acknowledging the work
- No unnecessary sections or elaborate praise
- Author gets quick feedback and can proceed
---
## Example 2: Dependency Update with Breaking Changes
**Context**: Major version update requiring code migration
**Summary Comment:**
```markdown
**Overall Assessment:** REQUEST CHANGES
**Critical Issues:**
- API migration required for Retrofit 3.0 breaking changes (network/api/BitwardenApiService.kt:34)
See inline comments for migration details.
```
**Inline Comment 1** (on `network/api/BitwardenApiService.kt:34`):
```markdown
**CRITICAL**: API migration required for Retrofit 3.0
<details>
<summary>Details and fix</summary>
Retrofit 3.0 removes the `Call<T>` return type. All 12 API methods in this file need migration:
```kotlin
// Current (deprecated in Retrofit 3.0)
@GET("api/accounts/profile")
fun getProfile(): Call<ProfileResponse>
// Must migrate to
@GET("api/accounts/profile")
suspend fun getProfile(): Response<ProfileResponse>
```
Breaking API change affects:
- 12 methods in BitwardenApiService
- 8 methods in VaultApiService
- All call sites using enqueue/execute
- Test utilities
Consider creating separate PR for this migration given the scope.
Reference: [Retrofit 3.0 migration guide](https://square.github.io/retrofit/changelogs/changelog-3.x/)
</details>
```
**Key Features:**
- Minimal summary (2-3 lines)
- Full details in collapsed inline comment
- Specific file:line references
- Code examples in <details>
- Migration guidance and scope assessment
---
## Example 3: Feature Addition with Critical Issues
**Context**: Implements PIN unlock for vault access
**Summary Comment:**
```markdown
**Overall Assessment:** REQUEST CHANGES
**Critical Issues:**
- Exposes mutable state violating MVVM (UnlockViewModel.kt:78)
- PIN stored without encryption - SECURITY ISSUE (UnlockRepository.kt:145)
See inline comments for all issues and suggestions.
```
**Inline Comment 1** (on `app/vault/unlock/UnlockViewModel.kt:78`):
```markdown
**CRITICAL**: Exposes mutable state
<details>
<summary>Details and fix</summary>
Change `MutableStateFlow<State>` to `StateFlow<State>`:
```kotlin
// Current (problematic)
val unlockState: MutableStateFlow<UnlockState>
// Should be
private val _unlockState = MutableStateFlow<UnlockState>()
val unlockState: StateFlow<UnlockState> = _unlockState.asStateFlow()
```
Exposing MutableStateFlow allows external mutation, violating MVVM unidirectional data flow.
Reference: docs/ARCHITECTURE.md#mvvm-pattern
</details>
```
**Inline Comment 2** (on `data/vault/UnlockRepository.kt:145`):
```markdown
❌ **CRITICAL**: PIN stored without encryption - SECURITY ISSUE
<details>
<summary>Details and fix</summary>
Storing PIN in plaintext SharedPreferences exposes it to backup systems and rooted devices.
```kotlin
// Current (CRITICAL SECURITY ISSUE)
sharedPreferences.edit {
putString(KEY_PIN, pin)
}
// Must use Android Keystore encryption
suspend fun storePin(pin: String): Result<Unit> = runCatching {
val encrypted = keystoreManager.encrypt(pin.toByteArray())
encryptedPrefs.putBytes(KEY_PIN, encrypted)
}
```
Use Android Keystore encryption or EncryptedSharedPreferences per security architecture.
Reference: docs/ARCHITECTURE.md#security
</details>
```
**Inline Comment 3** (on `app/vault/unlock/UnlockViewModel.kt:92`):
```markdown
⚠️ **IMPORTANT**: Missing error handling test
<details>
<summary>Details and fix</summary>
Add test to prevent regression if error handling changes:
```kotlin
@Test
fun `when incorrect PIN entered then returns error state`() = runTest {
val viewModel = UnlockViewModel(mockRepository)
coEvery { mockRepository.validatePin("1234") }
returns Result.failure(InvalidPinException())
viewModel.onPinEntered("1234")
assertEquals(UnlockState.Error("Invalid PIN"), viewModel.state.value)
}
```
Ensures error flow remains robust across refactorings.
</details>
```
**Inline Comment 4** (on `app/vault/unlock/UnlockViewModel.kt:105`):
```markdown
🎨 **SUGGESTED**: Consider rate limiting for PIN attempts
<details>
<summary>Details and fix</summary>
Currently allows unlimited attempts, which could enable brute force attacks.
```kotlin
private var attemptCount = 0
private var lockoutUntil: Instant? = null
fun onPinEntered(pin: String) {
if (isLockedOut()) {
_state.value = UnlockState.LockedOut(lockoutUntil!!)
return
}
// ... validate PIN ...
if (invalid) {
attemptCount++
if (attemptCount >= MAX_ATTEMPTS) {
lockoutUntil = clock.millis() + 15.minutes
}
}
}
```
Would add security layer against brute force. Consider discussing threat model with security team.
</details>
```
**Inline Comment 5** (on `app/vault/unlock/UnlockScreen.kt:134`):
```markdown
❓ **QUESTION**: Can we use BitwardenTextField?
<details>
<summary>Details</summary>
This custom PIN input field looks similar to `ui/components/BitwardenTextField.kt:67`.
Would using the existing component maintain consistency and reduce custom UI code?
</details>
```
**Key Features:**
- Minimal summary (3-4 lines) with critical issues only
- Each issue gets separate inline comment with `<details>` tag
- Multiple severity levels demonstrated (CRITICAL, IMPORTANT, SUGGESTED, QUESTION)
- Mix of prescriptive fixes and collaborative questions
- Code examples collapsed in <details>
- No "Good Practices" or "Action Items" sections
---
## ❌ Anti-Patterns to Avoid
### Problem: Verbose Summary with Multiple Sections
**What NOT to do:**
```markdown
### Review Complete ✅
## Summary
[Lengthy description of what the PR does]
### Strengths 👍
1. **Excellent documentation** - KDoc comments are comprehensive
2. **Proper fail-closed design** - Security defaults to rejection
3. **Defense in depth** - Multiple validation layers
[7 total items with elaboration]
### Critical Issues ⚠️
- Missing test coverage for security-critical code (with full details)
- [More issues with full explanations]
### Recommendations 🎨
- [Multiple recommendations]
### Test Coverage Status 📊
- [Analysis]
### Architecture Compliance ✅
- [Analysis]
## Recommendation
**Conditional approval** with follow-up...
```
**Why this is wrong:**
- 800+ tokens for a summary comment
- Multiple sections (Strengths, Recommendations, Test Coverage, Architecture)
- Elaborates on positive aspects ("Excellent documentation...")
- Duplicates critical issues (summary has details + inline comments have same details)
- Creates visual clutter in PR conversation
**Correct approach:**
```markdown
**Overall Assessment:** REQUEST CHANGES
**Critical Issues:**
- Missing test coverage for security-critical code (PasswordManagerSignatureVerifierImpl.kt:47)
See inline comments for details.
```
**Key differences:**
- 3-5 lines vs 800+ tokens
- Verdict + critical issues only
- All details belong in inline comments
- No positive commentary sections
- Scales with PR complexity, not analysis thoroughness
### Problem: Praise-Only Inline Comments
**What NOT to do:**
Creating inline comment on `AuthenticatorBridgeManagerImpl.kt:73`:
```markdown
👍 **Excellent integration of signature verification**
The signature verification is properly integrated into the connection flow:
- Checked during initialization (line 73)
- Checked before binding (line 134)
- Ensures only validated apps can connect
This is exactly the right approach for fail-safe security.
```
**Why this is wrong:**
- Entire comment is positive feedback with no actionable issue
- Takes up space in PR conversation
- Distracts from actual issues
- Violates "focus on actionable feedback" principle
**Correct approach:**
- Do not create this comment at all
- Reserve inline comments exclusively for issues requiring attention
### Problem: Missing `<details>` Tags
**What NOT to do:**
```markdown
**CRITICAL**: Missing test coverage for security-critical code
The `@OmitFromCoverage` annotation excludes this entire class from test coverage.
**Problems:**
1. No validation that certificate hashes match actual Bitwarden certificates
2. No verification of fail-closed behavior on edge cases
3. No tests for multiple signer rejection logic
4. Certificate hash typos would go undetected until production
**Recommendation:**
Replace `@OmitFromCoverage` with proper unit tests.
Example test structure:
[long code block]
Security-critical code should have the highest test coverage, not be omitted.
```
**Why this is wrong:**
- All content visible immediately (code examples, problems list, rationale)
- Creates visual clutter in PR conversation
- Makes it hard to scan multiple issues quickly
**Correct approach:**
```markdown
**CRITICAL**: Missing test coverage for security-critical code
<details>
<summary>Details and fix</summary>
The `@OmitFromCoverage` annotation excludes this entire class from test coverage.
**Problems:**
1. No validation that certificate hashes match actual Bitwarden certificates
2. No verification of fail-closed behavior on edge cases
3. No tests for multiple signer rejection logic
4. Certificate hash typos would go undetected until production
**Recommendation:**
Replace `@OmitFromCoverage` with proper unit tests.
Example test structure:
[code block]
Security-critical code should have the highest test coverage, not be omitted.
</details>
```
**Key difference:** Only severity + one-line description visible. All details collapsed.
---
## Summary
**Always use:**
- Minimal summary (verdict + critical issues)
- Separate inline comments with `<details>` tags
- Hybrid emoji + text severity prefixes
- Focus exclusively on actionable feedback
**Never use:**
- Multiple summary sections (Strengths, Recommendations, etc.)
- Praise-only inline comments
- Duplication between summary and inline comments
- Verbose analysis in summary (belongs in inline comments)

View File

@@ -0,0 +1,351 @@
# Architectural Patterns Quick Reference
Quick reference for Bitwarden Android architectural patterns during code reviews. For comprehensive details, read `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
## Table of Contents
**Core Patterns:**
- [MVVM + UDF Pattern](#mvvm--udf-pattern)
- [ViewModel Structure](#viewmodel-structure)
- [UI Layer (Compose)](#ui-layer-compose)
- [Hilt Dependency Injection](#hilt-dependency-injection)
- [ViewModels](#viewmodels)
- [Repositories and Managers](#repositories-and-managers)
- [Clock/Time Handling](#clocktime-handling)
- [Module Organization](#module-organization)
- [Error Handling](#error-handling)
- [Use Result Types, Not Exceptions](#use-result-types-not-exceptions)
- [Quick Checklist](#quick-checklist)
---
## MVVM + UDF Pattern
### ViewModel Structure
**✅ GOOD - Proper state encapsulation**:
```kotlin
@HiltViewModel
class FeatureViewModel @Inject constructor(
private val repository: FeatureRepository
) : ViewModel() {
// Private mutable state
private val _state = MutableStateFlow<FeatureState>(FeatureState.Initial)
// Public immutable state
val state: StateFlow<FeatureState> = _state.asStateFlow()
// Actions as functions, state updated via internal action
fun onActionClicked() {
viewModelScope.launch {
val result = repository.performAction()
sendAction(FeatureAction.Internal.ActionComplete(result))
}
}
// The ViewModel has a handler that processes the internal action
private fun handleInternalAction(action: FeatureAction.Internal) {
when (action) {
is FeatureAction.Internal.ActionComplete -> {
// The action handler evaluates the result and updates state
action.result.fold(
onSuccess = { _state.value = State.Success },
onFailure = { _state.value = State.Error(it) }
)
}
}
}
}
```
**❌ BAD - Common violations**:
```kotlin
class FeatureViewModel : ViewModel() {
// ❌ Exposes mutable state
val state: MutableStateFlow<FeatureState>
// ❌ Business logic in ViewModel
fun onSubmit() {
val encrypted = encryptionManager.encrypt(data) // Should be in Repository
_state.value = FeatureState.Success
}
// ❌ Direct Android framework dependency
fun onCreate(context: Context) { // ViewModels shouldn't depend on Context
// ...
}
}
```
**Key Rules**:
- Expose `StateFlow<T>`, never `MutableStateFlow<T>`
- Delegate business logic to Repository/Manager
- No direct Android framework dependencies (except ViewModel, SavedStateHandle)
- Use `viewModelScope` for coroutines
Reference: `docs/ARCHITECTURE.md#mvvm-pattern`
---
### UI Layer (Compose)
**✅ GOOD - Stateless, observes only**:
```kotlin
@Composable
fun FeatureScreen(
state: FeatureState,
onActionClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
when (state) {
is FeatureState.Loading -> LoadingIndicator()
is FeatureState.Success -> SuccessContent(state.data)
is FeatureState.Error -> ErrorMessage(state.error)
}
BitwardenButton(
text = "Action",
onClick = onActionClick // Sends event to ViewModel
)
}
}
```
**❌ BAD - Stateful, modifies state**:
```kotlin
@Composable
fun FeatureScreen(viewModel: FeatureViewModel) {
var localState by remember { mutableStateOf(...) } // ❌ State in UI
Button(onClick = {
viewModel._state.value = FeatureState.Loading // ❌ Directly modifying ViewModel state
})
}
```
**Key Rules**:
- Compose screens observe state, never modify
- User actions passed as events/callbacks to ViewModel
- No business logic in UI layer
- Use existing components from `:ui` module
---
## Hilt Dependency Injection
### ViewModels
**✅ GOOD - Interface injection**:
```kotlin
@HiltViewModel
class FeatureViewModel @Inject constructor(
private val repository: FeatureRepository, // Interface, not implementation
private val authManager: AuthManager,
savedStateHandle: SavedStateHandle
) : ViewModel()
```
**❌ BAD - Common violations**:
```kotlin
// ❌ No @HiltViewModel annotation
class FeatureViewModel @Inject constructor(...)
// ❌ Injecting implementation instead of interface
class FeatureViewModel @Inject constructor(
private val repository: FeatureRepositoryImpl // Should inject interface
)
// ❌ Manual instantiation
class FeatureViewModel : ViewModel() {
private val repository = FeatureRepositoryImpl() // Should use @Inject
}
```
**Key Rules**:
- Annotate with `@HiltViewModel`
- Use `@Inject constructor`
- Inject interfaces, not implementations
- Use `SavedStateHandle` for process death survival
Reference: `docs/ARCHITECTURE.md#dependency-injection`
---
### Repositories and Managers
**✅ GOOD - Implementation with @Inject**:
```kotlin
interface FeatureRepository {
suspend fun fetchData(): Result<Data>
}
class FeatureRepositoryImpl @Inject constructor(
private val apiService: FeatureApiService,
private val database: FeatureDao
) : FeatureRepository {
override suspend fun fetchData(): Result<Data> = runCatching {
apiService.getData()
}
}
```
**Module provides interface**:
```kotlin
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
@Singleton
abstract fun bindFeatureRepository(
impl: FeatureRepositoryImpl
): FeatureRepository
}
```
**Key Rules**:
- Define interface for abstraction
- Implementation uses `@Inject constructor`
- Module binds implementation to interface
- Appropriate scoping (`@Singleton`, `@ViewModelScoped`)
---
### Clock/Time Handling
Time-dependent code must use injected `Clock` rather than direct `Instant.now()` or `DateTime.now()` calls. This follows the same DI principle as other dependencies.
**✅ GOOD - Injected Clock**:
```kotlin
// ViewModel with Clock injection
class MyViewModel @Inject constructor(
private val clock: Clock,
) {
fun save() {
val timestamp = clock.instant()
}
}
// Extension function with Clock parameter
fun State.getTimestamp(clock: Clock): Instant =
existingTime ?: clock.instant()
```
**❌ BAD - Static/direct calls**:
```kotlin
// Hidden dependency, non-testable
val timestamp = Instant.now()
val dateTime = DateTime.now()
```
**Key Rules**:
- Inject `Clock` via Hilt constructor (like other dependencies)
- Pass `Clock` as parameter to extension functions
- `Clock` is provided via `CoreModule` as singleton
- Enables deterministic testing with `Clock.fixed(...)`
Reference: `docs/STYLE_AND_BEST_PRACTICES.md#best-practices--time-and-clock-handling`
---
## Module Organization
```
android/
├── core/ # Shared utilities (cryptography, analytics, logging)
├── data/ # Repositories, database, domain models
├── network/ # API clients, network utilities
├── ui/ # Reusable Compose components, theme
├── app/ # Application, feature screens, ViewModels
└── authenticator/ # Authenticator app (separate from password manager)
```
**Correct Placement**:
- UI screens and ViewModels → `:app`
- Reusable Compose components → `:ui`
- Data models and Repositories → `:data`
- API services → `:network`
- Cryptography, logging → `:core`
**Check for**:
- No circular dependencies
- Correct module placement
- Proper visibility (internal vs public)
Reference: `docs/ARCHITECTURE.md#module-structure`
---
## Error Handling
### Use Result Types, Not Exceptions
**✅ GOOD - Result-based**:
```kotlin
// Repository
suspend fun fetchData(): Result<Data> = runCatching {
apiService.getData()
}
// ViewModel
fun onFetch() {
viewModelScope.launch {
val result = repository.fetchData()
sendAction(FeatureAction.Internal.FetchComplete(result))
}
}
```
**❌ BAD - Exception-based in business logic**:
```kotlin
// ❌ Don't throw in business logic
suspend fun fetchData(): Data {
try {
return apiService.getData()
} catch (e: Exception) {
throw FeatureException(e) // Don't throw in repositories
}
}
// ❌ Try-catch in ViewModel
fun onFetch() {
viewModelScope.launch {
try {
val data = repository.fetchData()
sendAction(FeatureAction.Internal.FetchComplete(data))
} catch (e: Exception) {
sendAction(FeatureAction.Internal.FetchComplete(e))
}
}
}
```
**Key Rules**:
- Use `Result<T>` return types in repositories
- Use `runCatching { }` to wrap API calls
- Handle results with `.fold()` in ViewModels
- Don't throw exceptions in business logic
Reference: `docs/ARCHITECTURE.md#error-handling`
---
## Quick Checklist
### Architecture
- [ ] ViewModels expose StateFlow, not MutableStateFlow?
- [ ] Business logic in Repository, not ViewModel?
- [ ] Using Hilt DI (@HiltViewModel, @Inject constructor)?
- [ ] Injecting interfaces, not implementations?
- [ ] Time-dependent code uses injected `Clock` (not `Instant.now()`)?
- [ ] Correct module placement?
### Error Handling
- [ ] Using Result types, not exceptions in business logic?
- [ ] Errors handled with .fold() in ViewModels?
---
For comprehensive details, always refer to:
- `docs/ARCHITECTURE.md` - Full architecture patterns
- `docs/STYLE_AND_BEST_PRACTICES.md` - Complete style guide

View File

@@ -0,0 +1,430 @@
# Finding Priority Framework
Use this framework to classify findings during code review. Clear prioritization helps authors triage and address issues effectively.
## Table of Contents
**Severity Categories:**
- [❌ CRITICAL (Blocker - Must Fix Before Merge)](#critical-blocker---must-fix-before-merge)
- [⚠️ 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)
- [Optional (Acknowledge But Don't Require)](#optional-acknowledge-but-dont-require)
**Guidelines:**
- [Classification Guidelines](#classification-guidelines)
- [When Something is Between Categories](#when-something-is-between-categories)
- [Context Matters](#context-matters)
- [Examples by Change Type](#examples-by-change-type)
- [Special Cases](#special-cases)
- [Summary](#summary)
---
## ❌ **CRITICAL** (Blocker - Must Fix Before Merge)
These issues **must** be addressed before the PR can be merged. They pose immediate risks to security, stability, or architecture integrity.
### Security
- Data leaks or plaintext sensitive data (passwords, keys, tokens)
- Weak encryption or insecure key storage
- Missing authentication or authorization checks
- Input injection vulnerabilities (SQL, XSS, command injection)
- Sensitive data in logs or error messages
**Example**:
```
**data/vault/VaultRepository.kt:145** - CRITICAL: PIN stored without encryption
PIN must be encrypted using Android Keystore, not stored in plaintext SharedPreferences.
Reference: docs/ARCHITECTURE.md#security
```
### Stability
- Compilation errors or warnings
- Null pointer exceptions in production paths
- Resource leaks (file handles, network connections, memory)
- Crashes or unhandled exceptions in critical paths
- Thread safety violations
**Example**:
```
**app/auth/BiometricRepository.kt:120** - CRITICAL: Missing null safety check
biometricPrompt result can be null. Add explicit null check to prevent crash.
```
### Architecture
- Mutable state exposure in ViewModels (violates MVVM)
- Exception-based error handling in business logic (should use Result)
- Circular dependencies between modules
- Violation of zero-knowledge principles
- Direct dependency instantiation (should use DI)
**Example**:
```
**app/login/LoginViewModel.kt:45** - CRITICAL: Exposes mutable state
Change MutableStateFlow to StateFlow in public API to prevent external state mutation.
This violates MVVM encapsulation pattern.
```
---
## ⚠️ **IMPORTANT** (Should Fix)
These issues should be addressed but don't block merge if there's a compelling reason. They improve code quality, maintainability, or robustness.
### Testing
- Missing tests for critical paths (authentication, encryption, data sync)
- Missing tests for new public APIs
- Tests that don't verify actual behavior (test implementation, not behavior)
- Missing test coverage for error scenarios
**Example**:
```
**data/auth/BiometricRepository.kt** - IMPORTANT: Missing test for cancellation
Add test for user cancellation scenario to prevent regression.
```
### Architecture
- Inconsistent patterns within PR (mixing error handling approaches)
- Poor separation of concerns
- Tight coupling between components
- Not following established project patterns
**Example**:
```
**app/vault/VaultViewModel.kt:89** - IMPORTANT: Business logic in ViewModel
Encryption logic should be in Repository, not ViewModel.
Reference: docs/ARCHITECTURE.md#mvvm-pattern
```
### Documentation
- Undocumented public APIs (missing KDoc)
- Missing documentation for complex algorithms
- Unclear naming or confusing interfaces
**Example**:
```
**core/crypto/EncryptionManager.kt:34** - IMPORTANT: Missing KDoc
Public encryption method should document parameters, return value, and exceptions.
```
### Performance
- Inefficient algorithms in hot paths (with evidence from profiling)
- Blocking main thread with I/O operations
- Memory inefficient data structures (with evidence)
**Example**:
```
**app/vault/VaultListViewModel.kt:78** - IMPORTANT: N+1 query pattern
Fetching items one-by-one in loop. Consider batch fetch to reduce database queries.
```
---
## ♻️ **DEBT** (Technical Debt)
Code that duplicates existing patterns, violates established conventions, or will require rework within 6 months. Introduces technical debt that should be tracked for future cleanup.
### Duplication
- Copy-pasted code blocks across files
- Repeated validation or business logic
- Multiple implementations of same pattern
- Data transformation duplicated in multiple places
**Example**:
```
**app/vault/VaultListViewModel.kt:156** - DEBT: Duplicates encryption logic
Same encryption pattern exists in VaultRepository.kt:234 and SyncManager.kt:89.
Extract to shared EncryptionUtil to reduce maintenance burden.
```
### Convention Violations
- Inconsistent error handling approaches within same module
- Mixing architectural patterns (MVVM + MVC)
- Not following established DI patterns
- Deviating from project code style significantly
**Example**:
```
**data/auth/AuthRepository.kt:78** - DEBT: Exception-based error handling
Project standard is Result<T> for error handling. This uses try-catch with throws.
Creates inconsistency and makes testing harder.
Reference: docs/ARCHITECTURE.md#error-handling
```
### Future Rework Required
- Hardcoded values that should be configurable
- Temporary workarounds without TODO/FIXME
- Code that will need changes when planned features arrive
- Tight coupling that prevents future extensibility
**Example**:
```
**app/settings/SettingsViewModel.kt:45** - DEBT: Hardcoded feature flags
Feature flags should come from remote config for A/B testing.
Will require rework when experimentation framework launches.
```
---
## 🎨 **SUGGESTED** (Nice to Have)
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
- 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**:
```
**app/vault/VaultScreen.kt:145** - SUGGESTED: Consider extracting helper function
This 20-line block appears in 3 places. Consider extracting to reduce duplication.
```
### Testing
- Additional test coverage for edge cases (beyond critical paths)
- More comprehensive integration tests
- Performance tests for non-critical paths
**Example**:
```
**app/login/LoginViewModelTest.kt** - SUGGESTED: Add test for concurrent login attempts
Not critical, but would increase confidence in edge case handling.
```
### Refactoring
- Extracting reusable patterns
- Modernizing old patterns (if touching related code)
- Improving testability
**Example**:
```
**data/vault/VaultRepository.kt:200** - SUGGESTED: Consider extracting validation logic
Could be extracted to separate validator class for reusability and testing.
```
---
## ❓ **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.
### Requirements Clarification
- Ambiguous acceptance criteria
- Multiple valid implementation approaches
- Unclear business rules or edge case handling
- Conflicting requirements between specs and implementation
**Example**:
```
**app/vault/ItemListViewModel.kt:67** - QUESTION: Expected sort behavior for equal timestamps?
When items have identical timestamps, should secondary sort be by:
- Name (alphabetical)
- Creation order
- Item type priority
Spec doesn't specify tie-breaking logic.
```
### Design Decisions
- Architecture choices that could go multiple ways
- Trade-offs between approaches without clear winner
- Feature flag strategy or rollout approach
- API design with multiple valid patterns
**Example**:
```
**data/sync/SyncManager.kt:134** - QUESTION: Should sync failures retry automatically?
Current implementation fails immediately. Options:
- Exponential backoff (3 retries)
- User-triggered retry only
- Background retry on network restore
What's the expected UX?
```
### System Integration
- Unclear contracts with external systems
- Potential conflicts with other features/modules
- Assumptions about third-party API behavior
- Cross-team coordination needs
**Example**:
```
**app/auth/BiometricPrompt.kt:89** - QUESTION: Compatibility with pending device credentials PR?
PR #1234 is refactoring device credentials. Should this:
- Merge first and adapt later
- Wait for #1234 to land
- Coordinate with that author
Timing unclear.
```
### Testing Strategy
- Uncertainty about test scope or approach
- Questions about mocking external dependencies
- Edge cases that need product input
- Performance testing requirements
**Example**:
```
**data/vault/EncryptionTest.kt:45** - QUESTION: Should we test against real Keystore?
Currently using mocked Keystore. Real Keystore testing would:
+ Catch hardware-specific issues
- Slow down CI significantly
- Require API 23+ emulators
What's the priority?
```
---
## Optional (Acknowledge But Don't Require)
Note good practices to reinforce positive patterns. Keep these **brief** - list only, no elaboration.
### Good Practices
**Format**: Simple bullet list, no explanation
```markdown
## Good Practices
- Proper Hilt DI usage throughout
- Comprehensive unit test coverage
- Clear separation of concerns
- Well-documented public APIs
```
**Don't do this** (too verbose):
```markdown
## Good Practices
- Proper Hilt DI usage throughout: Great job using @Inject constructor and injecting interfaces! This follows our established patterns perfectly and makes the code very testable. Really excellent work here! 👍
```
---
## Classification Guidelines
### When Something is Between Categories
**If unsure between Critical and Important**:
- Ask: "Could this cause production incidents, data loss, or security breaches?"
- If yes → Critical
- If no → Important
**If unsure between Important and Debt**:
- Ask: "Is this a bug/defect or just duplication/inconsistency?"
- If bug/defect → Important
- If duplication/inconsistency → Debt
**If unsure between Important and Suggested**:
- Ask: "Would I block merge over this?"
- If yes → Important
- If no → Suggested
**If unsure between Debt and Suggested**:
- Ask: "Will this require rework within 6 months?"
- If yes → Debt
- If no → Suggested
**If unsure between Suggested and Question**:
- Ask: "Am I requesting a change or asking for clarification?"
- If requesting change → Suggested
- If seeking clarification → Question
**If unsure between Suggested and Optional**:
- Ask: "Is this actionable feedback or just acknowledgment?"
- If actionable → Suggested
- If acknowledgment → Optional
### Context Matters
**Same issue, different contexts**:
```
// Critical for production code
Missing null safety check in auth flow → CRITICAL
// Suggested for internal test utility
Missing null safety check in test helper → SUGGESTED
```
**Same pattern, different risk levels**:
```
// Critical for new feature
Missing tests for new auth method → CRITICAL
// Important for bug fix
Missing regression test → IMPORTANT
// Suggested for refactoring
Missing tests for refactored helper → SUGGESTED
```
---
## Examples by Change Type
### Dependency Update
- **Critical**: Known CVEs in old version not addressed
- **Important**: Breaking changes that need migration
- **Suggested**: Beta/alpha version stability concerns
### Bug Fix
- **Critical**: Fix doesn't address root cause
- **Important**: Missing regression test
- **Suggested**: Similar bugs in related code
### Feature Addition
- **Critical**: Security vulnerabilities, architecture violations
- **Important**: Missing tests for critical paths
- **Suggested**: Additional test coverage, minor refactoring
### UI Refinement
- **Critical**: Missing accessibility for key actions
- **Important**: Not using theme (hardcoded colors)
- **Suggested**: Minor spacing/alignment improvements
### Refactoring
- **Critical**: Changes behavior (should be behavior-preserving)
- **Important**: Incomplete migration (mix of old/new patterns)
- **Suggested**: Additional instances that could be refactored
### Infrastructure
- **Critical**: Hardcoded secrets, no rollback plan
- **Important**: Performance regression in build times
- **Suggested**: Further optimization opportunities
---
## Special Cases
### Technical Debt
- Acknowledge existing tech debt but don't require fixing in unrelated PR
- Exception: If change makes tech debt worse, it's Important to address
### Scope Creep
- Don't request changes outside PR scope
- Can note as "Future consideration" but not required for this PR
### Linter-Catchable Issues
- Don't flag issues that automated tools handle
- Exception: If linter is misconfigured and missing real issues
### Personal Preferences
- Don't flag unless grounded in project standards or architectural principles
- Use "I-statements" if suggesting alternative approaches
---
## Summary
**Critical**: Block merge, must fix (security, stability, architecture)
**Important**: Should fix before merge (testing, quality, performance)
**Debt**: Technical debt introduced, track for future cleanup
**Suggested**: Nice to have, consider effort vs benefit
**Question**: Seeking clarification on requirements or design
**Optional**: Acknowledge good practices, keep brief

View File

@@ -0,0 +1,163 @@
# Review Psychology: Constructive Feedback Phrasing
Effective code review feedback is clear, actionable, and constructive. This guide provides phrasing patterns for inline comments.
## Table of Contents
**Guidelines:**
- [Phrasing Templates](#phrasing-templates)
- [Critical Issues (Prescriptive)](#critical-issues-prescriptive)
- [Suggested Improvements (Exploratory)](#suggested-improvements-exploratory)
- [Questions (Collaborative)](#questions-collaborative)
- [Test Suggestions](#test-suggestions)
- [When to Be Prescriptive vs Ask Questions](#when-to-be-prescriptive-vs-ask-questions)
- [Special Cases](#special-cases)
---
## Phrasing Templates
### Critical Issues (Prescriptive)
**Pattern**: State problem + Provide solution + Explain why
```
**[file:line]** - CRITICAL: [Issue description]
[Specific fix with code example if applicable]
[Rationale explaining why this is critical]
Reference: [docs link if applicable]
```
**Example**:
```
**data/vault/VaultRepository.kt:145** - CRITICAL: PIN stored without encryption
PIN must be encrypted using Android Keystore, not stored in plaintext SharedPreferences.
Plaintext storage exposes the PIN to backup systems and rooted devices.
Reference: docs/ARCHITECTURE.md#security
```
---
### Suggested Improvements (Exploratory)
**Pattern**: Observe + Suggest + Explain benefit
```
**[file:line]** - Consider [alternative approach]
[Current observation]
Can we [specific suggestion]?
[Benefit or rationale]
```
**Example**:
```
**app/login/LoginScreen.kt:89** - Consider using existing BitwardenButton
This custom button implementation looks similar to `ui/components/BitwardenButton.kt:45`.
Can we use the existing component to maintain consistency across the app?
```
---
### Questions (Collaborative)
**Pattern**: Ask + Provide context (optional)
```
**[file:line]** - [Question about intent or approach]?
[Optional context or observation]
```
**Example**:
```
**data/sync/SyncManager.kt:234** - How does this handle concurrent sync attempts?
It looks like multiple coroutines could call `startSync()` simultaneously.
Is there a mechanism to prevent race conditions, or is that handled elsewhere?
```
---
### Test Suggestions
**Pattern**: Observe gap + Suggest specific test + Provide skeleton
```
**[file:line]** - Consider adding test for [scenario]
[Rationale]
```kotlin
@Test
fun `test description`() = runTest {
// Test skeleton
}
```
```
**Example**:
```
**data/auth/BiometricRepository.kt** - Consider adding test for cancellation scenario
This would prevent regression of the bug you just fixed:
```kotlin
@Test
fun `when biometric cancelled then returns cancelled state`() = runTest {
coEvery { biometricPrompt.authenticate() } returns null
val result = repository.authenticate()
assertEquals(AuthResult.Cancelled, result)
}
```
```
---
## When to Be Prescriptive vs Ask Questions
**Be Prescriptive** (Tell them what to do):
- Security issues
- Architecture pattern violations
- Null safety problems
- Compilation errors
- Documented project standards
**Ask Questions** (Seek explanation):
- Design decisions with multiple valid approaches
- Performance trade-offs without data
- Unclear intent or reasoning
- Scope decisions (this PR vs future work)
- Patterns not documented in project guidelines
---
## Special Cases
**Nitpicks** - For truly minor suggestions, use "Nit:" prefix:
```
**Nit**: Extra blank line at line 145
```
**Uncertainty** - If unsure, acknowledge it:
```
I'm not certain, but this might be called frequently.
Has this been profiled?
```
**Positive Feedback** - Brief list only, no elaboration:
```
## Good Practices
- Proper Hilt DI usage throughout
- Comprehensive unit test coverage
- Clear separation of concerns
```

View File

@@ -0,0 +1,90 @@
# Security Patterns Quick Reference
Quick reference for Bitwarden Android security patterns during code reviews. For comprehensive details, read `docs/ARCHITECTURE.md#security`.
## Encryption and Key Storage
**✅ GOOD - Android Keystore**:
```kotlin
// Sensitive data encrypted with Keystore
class SecureStorage @Inject constructor(
private val keystoreManager: KeystoreManager
) {
suspend fun storePin(pin: String): Result<Unit> = runCatching {
val encrypted = keystoreManager.encrypt(pin.toByteArray())
securePreferences.putBytes(KEY_PIN, encrypted)
}
}
// Or use EncryptedSharedPreferences
val encryptedPrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
```
**❌ BAD - Plaintext or weak encryption**:
```kotlin
// ❌ CRITICAL - Plaintext storage
sharedPreferences.edit {
putString("pin", userPin) // Never store sensitive data in plaintext
}
// ❌ CRITICAL - Weak encryption
val cipher = Cipher.getInstance("DES") // Use AES-256-GCM
// ❌ CRITICAL - Hardcoded keys
val key = "my_secret_key_123" // Use Android Keystore
```
**Key Rules**:
- Use Android Keystore for encryption keys
- Use EncryptedSharedPreferences for simple key-value storage
- Use AES-256-GCM for encryption
- Never store sensitive data in plaintext
- Never hardcode encryption keys
Reference: `docs/ARCHITECTURE.md#security`
---
## Logging Sensitive Data
**✅ GOOD - No sensitive data**:
```kotlin
Log.d(TAG, "Authentication attempt for user")
Log.d(TAG, "Vault sync completed with ${items.size} items")
```
**❌ BAD - Logs sensitive data**:
```kotlin
// ❌ CRITICAL
Log.d(TAG, "Password: $password")
Log.d(TAG, "Auth token: $token")
Log.d(TAG, "PIN: $pin")
Log.d(TAG, "Encryption key: ${key.encoded}")
```
**Key Rules**:
- Never log passwords, PINs, tokens, keys
- Never log encryption keys or sensitive data
- Be careful with error messages (don't include sensitive context)
---
## Quick Checklist
### Security
- [ ] Sensitive data encrypted with Keystore?
- [ ] No plaintext passwords/keys?
- [ ] No sensitive data in logs?
- [ ] Using AES-256-GCM for encryption?
- [ ] No hardcoded encryption keys?
---
For comprehensive security details, always refer to:
- `docs/ARCHITECTURE.md#security` - Complete security architecture and zero-knowledge principles

View 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).

View File

@@ -0,0 +1,127 @@
# Testing Patterns Quick Reference
Quick reference for Bitwarden Android testing patterns during code reviews. For comprehensive details, read `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
## ViewModel Tests
**✅ GOOD - Tests behavior**:
```kotlin
@Test
fun `when login succeeds then state updates to success`() = runTest {
// Arrange
val viewModel = LoginViewModel(mockRepository)
coEvery { mockRepository.login(any(), any()) } returns Result.success(User())
// Act
viewModel.onLoginClicked("user@example.com", "password")
// Assert
viewModel.state.test {
assertEquals(LoginState.Loading, awaitItem())
assertEquals(LoginState.Success, awaitItem())
}
}
```
**❌ BAD - Tests implementation**:
```kotlin
@Test
fun `repository is called with correct parameters`() {
// ❌ This tests implementation details, not behavior
viewModel.onLoginClicked("user", "pass")
coVerify { mockRepository.login("user", "pass") }
}
```
**Key Rules**:
- Test behavior, not implementation
- Use `runTest` for coroutine tests
- Use Turbine for Flow testing
- Use MockK for mocking
---
## Repository Tests
**✅ GOOD - Tests data transformations**:
```kotlin
@Test
fun `fetchItems maps API response to domain model`() = runTest {
// Arrange
val apiResponse = listOf(ApiItem(id = "1", name = "Test"))
coEvery { apiService.getItems() } returns apiResponse
// Act
val result = repository.fetchItems()
// Assert
assertTrue(result.isSuccess)
assertEquals(
listOf(DomainItem(id = "1", name = "Test")),
result.getOrThrow()
)
}
```
**Key Rules**:
- Test data transformations
- Test error handling (network failures, API errors)
- Test caching behavior if applicable
- Mock API services and databases
Reference: Project uses JUnit 5, MockK, Turbine, kotlinx-coroutines-test
---
## Null Safety
**✅ GOOD - Safe handling**:
```kotlin
// Safe call with elvis operator
val result = apiService.getData() ?: return State.Error("No data")
// Let with safe call
intent?.getStringExtra("key")?.let { value ->
processValue(value)
}
// Require with message
val data = requireNotNull(response.data) { "Response data must not be null" }
```
**❌ BAD - Unsafe assertions**:
```kotlin
// ❌ Unsafe - can crash
val result = apiService.getData()!!
// ❌ Platform type unchecked
val intent: Intent = getIntent() // Could be null from Java
val value = intent.getStringExtra("key") // Potential NPE
```
**Key Rules**:
- Avoid `!!` unless safety is guaranteed (rare)
- Handle platform types with explicit nullability
- Use safe calls (`?.`), elvis operator (`?:`), or explicit checks
- Use `requireNotNull` with descriptive message if crash is acceptable
---
## Quick Checklist
### Testing
- [ ] ViewModels have unit tests?
- [ ] Tests verify behavior, not implementation?
- [ ] Edge cases covered?
- [ ] Error scenarios tested?
### Code Quality
- [ ] Null safety handled properly (no `!!` without guarantee)?
- [ ] Public APIs have KDoc?
- [ ] Following naming conventions?
---
For comprehensive details, always refer to:
- `docs/ARCHITECTURE.md` - Full architecture patterns
- `docs/STYLE_AND_BEST_PRACTICES.md` - Complete style guide

View File

@@ -0,0 +1,85 @@
# Compose UI Patterns Quick Reference
Quick reference for Bitwarden Android Compose UI patterns during code reviews. For comprehensive details, read `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
## Component Reuse
**✅ GOOD - Uses existing components**:
```kotlin
BitwardenButton(
text = "Submit",
onClick = onSubmit
)
BitwardenTextField(
value = text,
onValueChange = onTextChange,
label = "Email"
)
```
**❌ BAD - Duplicates existing components**:
```kotlin
// ❌ Recreating BitwardenButton
Button(
onClick = onSubmit,
colors = ButtonDefaults.buttonColors(
containerColor = BitwardenTheme.colorScheme.primary
)
) {
Text("Submit")
}
```
**Key Rules**:
- Check `:ui` module for existing components before creating custom ones
- Use BitwardenButton, BitwardenTextField, etc. for consistency
- Place new reusable components in `:ui` module
---
## Theme Usage
**✅ GOOD - Uses theme**:
```kotlin
Text(
text = "Title",
style = BitwardenTheme.typography.titleLarge,
color = BitwardenTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp)) // Standard spacing
```
**❌ BAD - Hardcoded values**:
```kotlin
Text(
text = "Title",
style = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold), // Use theme
color = Color(0xFF0066FF) // Use theme color
)
Spacer(modifier = Modifier.height(17.dp)) // Non-standard spacing
```
**Key Rules**:
- Use `BitwardenTheme.colorScheme` for colors
- Use `BitwardenTheme.typography` for text styles
- Use standard spacing (4.dp, 8.dp, 16.dp, 24.dp)
---
## Quick Checklist
### UI Patterns
- [ ] Using existing Bitwarden components from `:ui` module?
- [ ] Using BitwardenTheme for colors and typography?
- [ ] Using standard spacing values (4, 8, 16, 24 dp)?
- [ ] No hardcoded colors or text styles?
- [ ] UI is stateless (observes state, doesn't modify)?
---
For comprehensive details, always refer to:
- `docs/ARCHITECTURE.md` - Full architecture patterns
- `docs/STYLE_AND_BEST_PRACTICES.md` - Complete style guide

View File

@@ -0,0 +1,323 @@
---
name: testing-android-code
description: This skill should be used when writing or reviewing tests for Android code in Bitwarden. Triggered by "BaseViewModelTest", "BitwardenComposeTest", "BaseServiceTest", "stateEventFlow", "bufferedMutableSharedFlow", "FakeDispatcherManager", "expectNoEvents", "assertCoroutineThrows", "createMockCipher", "createMockSend", "asSuccess", "Why is my Bitwarden test failing?", or testing questions about ViewModels, repositories, Compose screens, or data sources in Bitwarden.
version: 1.0.0
---
# Testing Android Code - Bitwarden Testing Patterns
**This skill provides tactical testing guidance for Bitwarden-specific patterns.** For comprehensive architecture and testing philosophy, consult `docs/ARCHITECTURE.md`.
## Test Framework Configuration
**Required Dependencies:**
- **JUnit 5** (jupiter), **MockK**, **Turbine** (app.cash.turbine)
- **kotlinx.coroutines.test**, **Robolectric**, **Compose Test**
**Critical Note:** Tests run with en-US locale for consistency. Don't assume other locales.
---
## A. ViewModel Testing Patterns
### Base Class: BaseViewModelTest
**Always extend `BaseViewModelTest` for ViewModel tests.**
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt`
**Benefits:**
- Automatically registers `MainDispatcherExtension` for `UnconfinedTestDispatcher`
- Provides `stateEventFlow()` helper for simultaneous StateFlow/EventFlow testing
**Pattern:**
```kotlin
class ExampleViewModelTest : BaseViewModelTest() {
private val mockRepository: ExampleRepository = mockk()
private val savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to INITIAL_STATE))
@Test
fun `ButtonClick should fetch data and update state`() = runTest {
coEvery { mockRepository.fetchData(any()) } returns Result.success("data")
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
viewModel.stateFlow.test {
assertEquals(INITIAL_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.ButtonClick)
assertEquals(INITIAL_STATE.copy(data = "data"), awaitItem())
}
coVerify { mockRepository.fetchData(any()) }
}
}
```
**For complete examples:** See `references/test-base-classes.md`
### StateFlow vs EventFlow (Critical Distinction)
| Flow Type | Replay | First Action | Pattern |
|-----------|--------|--------------|---------|
| StateFlow | Yes (1) | `awaitItem()` gets current state | Expect initial → trigger → expect new |
| EventFlow | No | `expectNoEvents()` first | expectNoEvents → trigger → expect event |
**For detailed patterns:** See `references/flow-testing-patterns.md`
---
## B. Compose UI Testing Patterns
### Base Class: BitwardenComposeTest
**Always extend `BitwardenComposeTest` for Compose screen tests.**
**Location:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt`
**Benefits:**
- Pre-configures all Bitwarden managers (FeatureFlags, AuthTab, Biometrics, etc.)
- Wraps content in `BitwardenTheme` and `LocalManagerProvider`
- Provides fixed Clock for deterministic time-based tests
**Pattern:**
```kotlin
class ExampleScreenTest : BitwardenComposeTest() {
private var haveCalledNavigateBack = false
private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setContent {
ExampleScreen(
onNavigateBack = { haveCalledNavigateBack = true },
viewModel = viewModel,
)
}
}
@Test
fun `on back click should send BackClick action`() {
composeTestRule.onNodeWithContentDescription("Back").performClick()
verify { viewModel.trySendAction(ExampleAction.BackClick) }
}
}
```
**Note:** Use `bufferedMutableSharedFlow` for event testing in Compose tests. Default replay is 0; pass `replay = 1` if needed.
**For complete base class details:** See `references/test-base-classes.md`
---
## C. Repository and Service Testing
### Service Testing with MockWebServer
**Base Class:** `BaseServiceTest` (`network/src/testFixtures/`)
```kotlin
class ExampleServiceTest : BaseServiceTest() {
private val api: ExampleApi = retrofit.create()
private val service = ExampleServiceImpl(api)
@Test
fun `getConfig should return success when API succeeds`() = runTest {
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
val result = service.getConfig()
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
}
}
```
### Repository Testing Pattern
```kotlin
class ExampleRepositoryTest {
private val fixedClock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val dispatcherManager = FakeDispatcherManager()
private val mockDiskSource: ExampleDiskSource = mockk()
private val mockService: ExampleService = mockk()
private val repository = ExampleRepositoryImpl(
clock = fixedClock,
exampleDiskSource = mockDiskSource,
exampleService = mockService,
dispatcherManager = dispatcherManager,
)
@Test
fun `fetchData should return success when service succeeds`() = runTest {
coEvery { mockService.getData(any()) } returns expectedData.asSuccess()
val result = repository.fetchData(userId)
assertTrue(result.isSuccess)
}
}
```
**Key patterns:** Use `FakeDispatcherManager`, fixed Clock, and `.asSuccess()` helpers.
---
## D. Test Data Builders
### Builder Pattern with Number Parameter
**Location:** `network/src/testFixtures/kotlin/com/bitwarden/network/model/`
```kotlin
fun createMockCipher(
number: Int,
id: String = "mockId-$number",
name: String? = "mockName-$number",
// ... more parameters with defaults
): SyncResponseJson.Cipher
// Usage:
val cipher1 = createMockCipher(number = 1) // mockId-1, mockName-1
val cipher2 = createMockCipher(number = 2) // mockId-2, mockName-2
val custom = createMockCipher(number = 3, name = "Custom")
```
**Available Builders (35+):**
- **Cipher:** `createMockCipher()`, `createMockLogin()`, `createMockCard()`, `createMockIdentity()`, `createMockSecureNote()`, `createMockSshKey()`, `createMockField()`, `createMockUri()`, `createMockFido2Credential()`, `createMockPasswordHistory()`, `createMockCipherPermissions()`
- **Sync:** `createMockSyncResponse()`, `createMockFolder()`, `createMockCollection()`, `createMockPolicy()`, `createMockDomains()`
- **Send:** `createMockSend()`, `createMockFile()`, `createMockText()`, `createMockSendJsonRequest()`
- **Profile:** `createMockProfile()`, `createMockOrganization()`, `createMockProvider()`, `createMockPermissions()`
- **Attachments:** `createMockAttachment()`, `createMockAttachmentJsonRequest()`, `createMockAttachmentResponse()`
See `network/src/testFixtures/kotlin/com/bitwarden/network/model/` for full list.
---
## E. Result Type Testing
**Locations:**
- `.asSuccess()`, `.asFailure()`: `core/src/main/kotlin/com/bitwarden/core/data/util/ResultExtensions.kt`
- `assertCoroutineThrows`: `core/src/testFixtures/kotlin/com/bitwarden/core/data/util/TestHelpers.kt`
```kotlin
// Create results
"data".asSuccess() // Result.success("data")
throwable.asFailure() // Result.failure<T>(throwable)
// Assertions
assertTrue(result.isSuccess)
assertEquals(expectedValue, result.getOrNull())
```
---
## F. Test Utilities and Helpers
### Fake Implementations
| Fake | Location | Purpose |
|------|----------|---------|
| `FakeDispatcherManager` | `core/src/testFixtures/` | Deterministic coroutine execution |
| `FakeConfigDiskSource` | `data/src/testFixtures/` | In-memory config storage |
| `FakeSharedPreferences` | `data/src/testFixtures/` | Memory-backed SharedPreferences |
### Exception Testing (CRITICAL)
```kotlin
// CORRECT - Call directly, NOT inside runTest
@Test
fun `test exception`() {
assertCoroutineThrows<IllegalStateException> {
repository.throwingFunction()
}
}
```
**Why:** `runTest` catches exceptions and rethrows them, breaking the assertion pattern.
---
## G. Critical Gotchas
Common testing mistakes in Bitwarden. **For complete details and examples:** See `references/critical-gotchas.md`
**Core Patterns:**
- **assertCoroutineThrows + runTest** - Never wrap in `runTest`; call directly
- **Static mock cleanup** - Always `unmockkStatic()` in `@After`
- **StateFlow vs EventFlow** - StateFlow: `awaitItem()` first; EventFlow: `expectNoEvents()` first
- **FakeDispatcherManager** - Always use instead of real `DispatcherManagerImpl`
- **Coroutine test wrapper** - Use `runTest { }` for all Flow/coroutine tests
**Assertion Patterns:**
- **Complete state assertions** - Assert entire state objects, not individual fields
- **JUnit over Kotlin** - Use `assertTrue()`, not Kotlin's `assert()`
- **Use Result extensions** - Use `asSuccess()` and `asFailure()` for Result type assertions
**Test Design:**
- **Fake vs Mock strategy** - Use Fakes for happy paths, Mocks for error paths
- **DI over static mocking** - Extract interfaces (like UuidManager) instead of mockkStatic
- **Null stream testing** - Test null returns from ContentResolver operations
- **bufferedMutableSharedFlow** - Use with `.onSubscription { emit(state) }` in Fakes
- **Test factory methods** - Accept domain state types, not SavedStateHandle
---
## H. Test File Organization
### Directory Structure
```
module/src/test/kotlin/com/bitwarden/.../
├── ui/*ScreenTest.kt, *ViewModelTest.kt
├── data/repository/*RepositoryTest.kt
└── network/service/*ServiceTest.kt
module/src/testFixtures/kotlin/com/bitwarden/.../
├── util/TestHelpers.kt
├── base/Base*Test.kt
└── 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`
- Functions: `` `given state when action should result` ``
---
## Summary
Key Bitwarden-specific testing patterns:
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** - Consistent `number: Int` parameter pattern
6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource
7. **Result Type Testing** - `.asSuccess()`, `.asFailure()`
**Always consult:** `docs/ARCHITECTURE.md` and existing test files for reference implementations.
---
## Reference Documentation
For detailed information, see:
- `references/test-base-classes.md` - Detailed base class documentation and usage patterns
- `references/flow-testing-patterns.md` - Complete Turbine patterns for StateFlow/EventFlow
- `references/critical-gotchas.md` - Full anti-pattern reference and debugging tips
**Complete Examples:**
- `examples/viewmodel-test-example.md` - Full ViewModel test with StateFlow/EventFlow
- `examples/compose-screen-test-example.md` - Full Compose screen test
- `examples/repository-test-example.md` - Full repository test with mocks and fakes

View File

@@ -0,0 +1,337 @@
/**
* Complete Compose Screen Test Example
*
* Key patterns demonstrated:
* - Extending BitwardenComposeTest
* - Mocking ViewModel with flows
* - Testing UI interactions
* - Testing navigation callbacks
* - Using bufferedMutableSharedFlow for events
* - Testing dialogs with isDialog() and hasAnyAncestor()
*/
package com.bitwarden.example.feature
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.util.assertNoDialogExists
import com.bitwarden.ui.util.isProgressBar
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import junit.framework.TestCase.assertTrue
import org.junit.Before
import org.junit.Test
class ExampleScreenTest : BitwardenComposeTest() {
// Track navigation callbacks
private var haveCalledNavigateBack = false
private var haveCalledNavigateToNext = false
// Use bufferedMutableSharedFlow for events (default replay = 0)
private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
// Mock ViewModel with relaxed = true
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
haveCalledNavigateBack = false
haveCalledNavigateToNext = false
setContent {
ExampleScreen(
onNavigateBack = { haveCalledNavigateBack = true },
onNavigateToNext = { haveCalledNavigateToNext = true },
viewModel = viewModel,
)
}
}
/**
* Test: Back button sends action to ViewModel
*/
@Test
fun `on back click should send BackClick action`() {
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify { viewModel.trySendAction(ExampleAction.BackClick) }
}
/**
* Test: Submit button sends action to ViewModel
*/
@Test
fun `on submit click should send SubmitClick action`() {
composeTestRule
.onNodeWithText("Submit")
.performClick()
verify { viewModel.trySendAction(ExampleAction.SubmitClick) }
}
/**
* Test: Loading state shows progress indicator
*/
@Test
fun `loading state should display progress indicator`() {
mutableStateFlow.update { it.copy(isLoading = true) }
composeTestRule
.onNode(isProgressBar)
.assertIsDisplayed()
}
/**
* Test: Data state shows content
*/
@Test
fun `data state should display content`() {
mutableStateFlow.update { it.copy(data = "Test Data") }
composeTestRule
.onNodeWithText("Test Data")
.assertIsDisplayed()
}
/**
* Test: Error state shows error message
*/
@Test
fun `error state should display error message`() {
mutableStateFlow.update { it.copy(errorMessage = "Something went wrong") }
composeTestRule
.onNodeWithText("Something went wrong")
.assertIsDisplayed()
}
/**
* Test: NavigateBack event triggers navigation callback
*/
@Test
fun `NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(ExampleEvent.NavigateBack)
assertTrue(haveCalledNavigateBack)
}
/**
* Test: NavigateToNext event triggers navigation callback
*/
@Test
fun `NavigateToNext event should call onNavigateToNext`() {
mutableEventFlow.tryEmit(ExampleEvent.NavigateToNext)
assertTrue(haveCalledNavigateToNext)
}
/**
* Test: Item in list can be clicked
*/
@Test
fun `on item click should send ItemClick action`() {
val itemId = "item-123"
mutableStateFlow.update {
it.copy(items = listOf(ExampleItem(id = itemId, name = "Test Item")))
}
composeTestRule
.onNodeWithText("Test Item")
.performClick()
verify { viewModel.trySendAction(ExampleAction.ItemClick(itemId)) }
}
// ==================== DIALOG TESTS ====================
/**
* Test: No dialog exists when dialogState is null
*/
@Test
fun `no dialog should exist when dialogState is null`() {
mutableStateFlow.update { it.copy(dialogState = null) }
composeTestRule.assertNoDialogExists()
}
/**
* Test: Loading dialog displays when state updates
* PATTERN: Use isDialog() to check dialog exists
*/
@Test
fun `loading dialog should display when dialogState is Loading`() {
mutableStateFlow.update {
it.copy(dialogState = ExampleState.DialogState.Loading("Please wait..."))
}
composeTestRule
.onNode(isDialog())
.assertIsDisplayed()
// Verify loading text within dialog using hasAnyAncestor(isDialog())
composeTestRule
.onAllNodesWithText("Please wait...")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
/**
* Test: Error dialog displays title and message
* PATTERN: Use filterToOne(hasAnyAncestor(isDialog())) to find text within dialogs
*/
@Test
fun `error dialog should display title and message`() {
mutableStateFlow.update {
it.copy(
dialogState = ExampleState.DialogState.Error(
title = "An error has occurred",
message = "Something went wrong. Please try again.",
),
)
}
// Verify dialog exists
composeTestRule
.onNode(isDialog())
.assertIsDisplayed()
// Verify title within dialog
composeTestRule
.onAllNodesWithText("An error has occurred")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
// Verify message within dialog
composeTestRule
.onAllNodesWithText("Something went wrong. Please try again.")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
/**
* Test: Dialog button click sends action
* PATTERN: Find button with hasAnyAncestor(isDialog()) then performClick()
*/
@Test
fun `error dialog dismiss button should send DismissDialog action`() {
mutableStateFlow.update {
it.copy(
dialogState = ExampleState.DialogState.Error(
title = "Error",
message = "An error occurred",
),
)
}
// Click dismiss button within dialog
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(ExampleAction.DismissDialog) }
}
/**
* Test: Confirmation dialog with multiple buttons
* PATTERN: Test both confirm and cancel actions
*/
@Test
fun `confirmation dialog confirm button should send ConfirmAction`() {
mutableStateFlow.update {
it.copy(
dialogState = ExampleState.DialogState.Confirmation(
title = "Confirm Action",
message = "Are you sure you want to proceed?",
),
)
}
// Click confirm button
composeTestRule
.onAllNodesWithText("Confirm")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(ExampleAction.ConfirmAction) }
}
@Test
fun `confirmation dialog cancel button should send DismissDialog action`() {
mutableStateFlow.update {
it.copy(
dialogState = ExampleState.DialogState.Confirmation(
title = "Confirm Action",
message = "Are you sure?",
),
)
}
// Click cancel button
composeTestRule
.onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(ExampleAction.DismissDialog) }
}
}
private val DEFAULT_STATE = ExampleState(
isLoading = false,
data = null,
errorMessage = null,
items = emptyList(),
dialogState = null,
)
// Example types (normally in separate files)
data class ExampleState(
val isLoading: Boolean = false,
val data: String? = null,
val errorMessage: String? = null,
val items: List<ExampleItem> = emptyList(),
val dialogState: DialogState? = null,
) {
/**
* PATTERN: Nested sealed class for dialog states.
* Common dialog types: Loading, Error, Confirmation
*/
sealed class DialogState {
data class Loading(val message: String) : DialogState()
data class Error(val title: String, val message: String) : DialogState()
data class Confirmation(val title: String, val message: String) : DialogState()
}
}
data class ExampleItem(val id: String, val name: String)
sealed class ExampleAction {
data object BackClick : ExampleAction()
data object SubmitClick : ExampleAction()
data class ItemClick(val itemId: String) : ExampleAction()
data object DismissDialog : ExampleAction()
data object ConfirmAction : ExampleAction()
}
sealed class ExampleEvent {
data object NavigateBack : ExampleEvent()
data object NavigateToNext : ExampleEvent()
}

View File

@@ -0,0 +1,255 @@
/**
* Complete Repository Test Example
*
* Key patterns demonstrated:
* - Fake for disk sources, Mock for network services
* - Using FakeDispatcherManager for deterministic coroutines
* - Using fixed Clock for deterministic time
* - Testing Result types with .asSuccess() / .asFailure()
* - Asserting actual objects (not isSuccess/isFailure) for better diagnostics
* - Testing Flow emissions with Turbine
*/
package com.bitwarden.example.data.repository
import app.cash.turbine.test
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class ExampleRepositoryTest {
// Fixed clock for deterministic time-based tests
private val fixedClock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
// Use FakeDispatcherManager for deterministic coroutine execution
private val dispatcherManager = FakeDispatcherManager()
// Mock service (network layer is always mocked)
private val mockService: ExampleService = mockk()
/**
* PATTERN: Use Fake for disk source in happy path tests.
* This is the Bitwarden convention for repository testing.
*/
private val fakeDiskSource = FakeExampleDiskSource()
private lateinit var repository: ExampleRepositoryImpl
@BeforeEach
fun setup() {
repository = ExampleRepositoryImpl(
clock = fixedClock,
service = mockService,
diskSource = fakeDiskSource,
dispatcherManager = dispatcherManager,
)
}
// ==================== HAPPY PATH TESTS (use Fake) ====================
/**
* Test: Successful fetch returns data and saves to disk
*/
@Test
fun `fetchData should return success and save to disk when service succeeds`() = runTest {
val expectedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant())
coEvery { mockService.getData() } returns expectedData.asSuccess()
val result = repository.fetchData()
assertEquals(expectedData, result.getOrThrow())
// Fake automatically stores the data - verify it's there
assertEquals(expectedData, fakeDiskSource.storedData)
}
/**
* Test: Service failure returns failure without saving
*/
@Test
fun `fetchData should return failure when service fails`() = runTest {
val exception = Exception("Network error")
coEvery { mockService.getData() } returns exception.asFailure()
val result = repository.fetchData()
assertEquals(exception, result.exceptionOrNull())
// Fake was not updated
assertNull(fakeDiskSource.storedData)
}
/**
* Test: Repository flow emits when disk source updates
*/
@Test
fun `dataFlow should emit when disk source updates`() = runTest {
val data1 = ExampleData(id = "1", name = "First", updatedAt = fixedClock.instant())
val data2 = ExampleData(id = "2", name = "Second", updatedAt = fixedClock.instant())
repository.dataFlow.test {
// Initial null value from Fake
assertNull(awaitItem())
// Update via Fake property setter (triggers emission)
fakeDiskSource.storedData = data1
assertEquals(data1, awaitItem())
// Another update
fakeDiskSource.storedData = data2
assertEquals(data2, awaitItem())
}
}
/**
* Test: Refresh fetches and saves new data
*/
@Test
fun `refresh should fetch new data and update disk source`() = runTest {
val newData = ExampleData(id = "new", name = "Fresh", updatedAt = fixedClock.instant())
coEvery { mockService.getData() } returns newData.asSuccess()
val result = repository.refresh()
assertEquals(Unit, result.getOrThrow())
coVerify { mockService.getData() }
assertEquals(newData, fakeDiskSource.storedData)
}
/**
* Test: Delete clears data from disk
*/
@Test
fun `deleteData should clear disk source`() = runTest {
// Pre-populate the fake
fakeDiskSource.storedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant())
repository.deleteData()
assertNull(fakeDiskSource.storedData)
}
/**
* Test: Cached data returns from disk when available
*/
@Test
fun `getCachedData should return disk data without network call`() = runTest {
val cachedData = ExampleData(
id = "cached",
name = "Cached",
updatedAt = fixedClock.instant(),
)
fakeDiskSource.storedData = cachedData
val result = repository.getCachedData()
assertEquals(cachedData, result)
coVerify(exactly = 0) { mockService.getData() }
}
// ==================== ERROR PATH TESTS ====================
/**
* PATTERN: For error paths, reconfigure the class-level mock per-test.
* Use coEvery to change mock behavior for each specific test case.
*/
@Test
fun `fetchData should return failure when service returns error`() = runTest {
val exception = Exception("Server unavailable")
coEvery { mockService.getData() } returns exception.asFailure()
val result = repository.fetchData()
assertEquals(exception, result.exceptionOrNull())
// Fake state unchanged on failure
assertNull(fakeDiskSource.storedData)
}
@Test
fun `refresh should return failure and preserve cached data when service fails`() = runTest {
// Pre-populate cache via Fake
val cachedData = ExampleData(id = "cached", name = "Old", updatedAt = fixedClock.instant())
fakeDiskSource.storedData = cachedData
// Reconfigure mock to return failure
coEvery { mockService.getData() } returns Exception("Network error").asFailure()
val result = repository.refresh()
assertTrue(result.isFailure)
// Cached data preserved on failure
assertEquals(cachedData, fakeDiskSource.storedData)
}
}
// Example types (normally in separate files)
data class ExampleData(
val id: String,
val name: String,
val updatedAt: Instant,
)
interface ExampleService {
suspend fun getData(): Result<ExampleData>
}
interface ExampleDiskSource {
val dataFlow: kotlinx.coroutines.flow.Flow<ExampleData?>
fun getData(): ExampleData?
fun saveData(data: ExampleData)
fun clearData()
}
/**
* PATTERN: Fake implementation for happy path testing.
*
* Key characteristics:
* - Uses bufferedMutableSharedFlow(replay = 1) for proper replay behavior
* - Uses .onSubscription { emit(state) } for immediate state emission
* - Private storage with override property setter that emits to flow
* - Test assertions done via the override property getter
*/
class FakeExampleDiskSource : ExampleDiskSource {
private var storedDataValue: ExampleData? = null
private val mutableDataFlow = bufferedMutableSharedFlow<ExampleData?>(replay = 1)
/**
* Override property with getter/setter. Setter emits to flow automatically.
* Tests can read this property for assertions and write to trigger emissions.
*/
var storedData: ExampleData?
get() = storedDataValue
set(value) {
storedDataValue = value
mutableDataFlow.tryEmit(value)
}
override val dataFlow: Flow<ExampleData?>
get() = mutableDataFlow.onSubscription { emit(storedData) }
override fun getData(): ExampleData? = storedData
override fun saveData(data: ExampleData) {
storedData = data
}
override fun clearData() {
storedData = null
}
}

View File

@@ -0,0 +1,161 @@
/**
* Complete ViewModel Test Example
*
* Key patterns demonstrated:
* - Extending BaseViewModelTest
* - Testing StateFlow with Turbine
* - Testing EventFlow with Turbine
* - Using stateEventFlow() for simultaneous testing
* - MockK mocking patterns
* - Test factory method design (accepts domain state, not SavedStateHandle)
* - Complete state assertions (assert entire state objects)
*/
package com.bitwarden.example.feature
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ExampleViewModelTest : BaseViewModelTest() {
// Mock dependencies
private val mockRepository: ExampleRepository = mockk()
private val mockAuthDiskSource: AuthDiskSource = mockk {
every { userStateFlow } returns MutableStateFlow(null)
}
/**
* StateFlow has replay=1, so first awaitItem() returns current state
*/
@Test
fun `initial state should be default state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
}
}
/**
* Test state transitions: initial -> loading -> success
*/
@Test
fun `LoadData action should update state from idle to loading to success`() = runTest {
val expectedData = "loaded data"
coEvery { mockRepository.fetchData(any()) } returns Result.success(expectedData)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.LoadData)
assertEquals(DEFAULT_STATE.copy(isLoading = true), awaitItem())
assertEquals(DEFAULT_STATE.copy(isLoading = false, data = expectedData), awaitItem())
}
coVerify { mockRepository.fetchData(any()) }
}
/**
* EventFlow has no replay - MUST call expectNoEvents() first
*/
@Test
fun `SubmitClick action should emit NavigateToNext event`() = runTest {
coEvery { mockRepository.submitData(any()) } returns Result.success(Unit)
val viewModel = createViewModel()
viewModel.eventFlow.test {
expectNoEvents() // CRITICAL for EventFlow
viewModel.trySendAction(ExampleAction.SubmitClick)
assertEquals(ExampleEvent.NavigateToNext, awaitItem())
}
}
/**
* Use stateEventFlow() helper for simultaneous testing
*/
@Test
fun `complex action should update state and emit event`() = runTest {
coEvery { mockRepository.complexOperation(any()) } returns Result.success("result")
val viewModel = createViewModel()
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
assertEquals(DEFAULT_STATE, stateFlow.awaitItem())
eventFlow.expectNoEvents()
viewModel.trySendAction(ExampleAction.ComplexAction)
assertEquals(DEFAULT_STATE.copy(isLoading = true), stateFlow.awaitItem())
assertEquals(DEFAULT_STATE.copy(data = "result"), stateFlow.awaitItem())
assertEquals(ExampleEvent.ShowToast("Success!"), eventFlow.awaitItem())
}
}
/**
* Test state restoration from saved state.
* Note: Use initialState parameter, NOT SavedStateHandle directly.
*/
@Test
fun `initial state from saved state should be preserved`() = runTest {
// Build complete expected state - always assert full objects
val savedState = ExampleState(
isLoading = false,
data = "restored data",
errorMessage = null,
)
val viewModel = createViewModel(initialState = savedState)
viewModel.stateFlow.test {
assertEquals(savedState, awaitItem())
}
}
/**
* Factory method accepts domain state, NOT SavedStateHandle.
* This hides Android framework details from test logic.
*/
private fun createViewModel(
initialState: ExampleState? = null,
): ExampleViewModel = ExampleViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
repository = mockRepository,
authDiskSource = mockAuthDiskSource,
)
}
private val DEFAULT_STATE = ExampleState(
isLoading = false,
data = null,
errorMessage = null,
)
// Example types (normally in separate files)
data class ExampleState(
val isLoading: Boolean = false,
val data: String? = null,
val errorMessage: String? = null,
)
sealed class ExampleAction {
data object LoadData : ExampleAction()
data object SubmitClick : ExampleAction()
data object ComplexAction : ExampleAction()
}
sealed class ExampleEvent {
data object NavigateToNext : ExampleEvent()
data class ShowToast(val message: String) : ExampleEvent()
}

View File

@@ -0,0 +1,698 @@
# Critical Gotchas and Anti-Patterns
Common mistakes and pitfalls when writing tests in the Bitwarden Android codebase.
## ❌ NEVER wrap assertCoroutineThrows in runTest
### The Problem
`runTest` catches exceptions and rethrows them, which breaks the `assertCoroutineThrows` assertion pattern.
### Wrong
```kotlin
@Test
fun `test exception`() = runTest {
assertCoroutineThrows<Exception> {
repository.throwingFunction()
} // Won't work - exception is caught by runTest!
}
```
### Correct
```kotlin
@Test
fun `test exception`() {
assertCoroutineThrows<Exception> {
repository.throwingFunction()
} // Works correctly
}
```
### Why This Happens
`runTest` provides a coroutine scope and catches exceptions to provide better error messages. However, `assertCoroutineThrows` needs to catch the exception itself to verify it was thrown. When wrapped in `runTest`, the exception is caught twice, breaking the assertion.
## ❌ ALWAYS unmock static functions
### The Problem
MockK's static mocking persists across tests. Forgetting to clean up causes mysterious failures in subsequent tests.
### Wrong
```kotlin
@Before
fun setup() {
mockkStatic(::isBuildVersionAtLeast)
every { isBuildVersionAtLeast(any()) } returns true
}
// Forgot @After - subsequent tests will fail mysteriously!
```
### Correct
```kotlin
@Before
fun setup() {
mockkStatic(::isBuildVersionAtLeast)
every { isBuildVersionAtLeast(any()) } returns true
}
@After
fun tearDown() {
unmockkStatic(::isBuildVersionAtLeast) // CRITICAL
}
```
### Common Static Functions to Watch
```kotlin
// Platform version checks
mockkStatic(::isBuildVersionAtLeast)
unmockkStatic(::isBuildVersionAtLeast)
// URI parsing
mockkStatic(Uri::class)
unmockkStatic(Uri::class)
// Static utility functions
mockkStatic(MyUtilClass::class)
unmockkStatic(MyUtilClass::class)
```
### Debugging Tip
If tests pass individually but fail when run together, suspect static mocking cleanup issues.
## ❌ Don't confuse StateFlow and EventFlow testing
### StateFlow (replay = 1)
```kotlin
// CORRECT - StateFlow always has current value
viewModel.stateFlow.test {
val initial = awaitItem() // Gets current state immediately
viewModel.trySendAction(action)
val updated = awaitItem() // Gets new state
}
```
### EventFlow (no replay)
```kotlin
// CORRECT - EventFlow has no initial value
viewModel.eventFlow.test {
expectNoEvents() // MUST do this first
viewModel.trySendAction(action)
val event = awaitItem() // Gets emitted event
}
```
### Common Mistake
```kotlin
// WRONG - Forgetting expectNoEvents() on EventFlow
viewModel.eventFlow.test {
viewModel.trySendAction(action) // May cause flaky tests
assertEquals(event, awaitItem())
}
```
## ❌ Don't mix real and test dispatchers
### Wrong
```kotlin
private val repository = ExampleRepositoryImpl(
dispatcherManager = DispatcherManagerImpl(), // Real dispatcher!
)
@Test
fun `test repository`() = runTest {
// Test will have timing issues - real dispatcher != test dispatcher
}
```
### Correct
```kotlin
private val repository = ExampleRepositoryImpl(
dispatcherManager = FakeDispatcherManager(), // Test dispatcher
)
@Test
fun `test repository`() = runTest {
// Test runs deterministically
}
```
### Why This Matters
Real dispatchers use actual thread pools and delays. Test dispatchers (UnconfinedTestDispatcher) execute immediately and deterministically. Mixing them causes:
- Non-deterministic test failures
- Real delays in tests (slow test suite)
- Race conditions
### Always Use
- `FakeDispatcherManager()` for repositories
- `UnconfinedTestDispatcher()` when manually creating dispatchers
- `runTest` for coroutine tests (provides TestDispatcher automatically)
## ❌ Don't forget to use runTest for coroutine tests
### Wrong
```kotlin
@Test
fun `test coroutine`() {
viewModel.stateFlow.test { /* ... */ } // Missing runTest!
}
```
This causes:
- Test completes before coroutines finish
- False positives (test passes but assertions never run)
- Mysterious failures
### Correct
```kotlin
@Test
fun `test coroutine`() = runTest {
viewModel.stateFlow.test { /* ... */ }
}
```
### When runTest is Required
- Testing ViewModels (they use `viewModelScope`)
- Testing Flows with Turbine `.test {}`
- Testing repositories with suspend functions
- Any test calling suspend functions
### Exception: assertCoroutineThrows
As noted above, `assertCoroutineThrows` should NOT be wrapped in `runTest`.
## ❌ Don't forget relaxed = true for complex mocks
### Without relaxed
```kotlin
private val viewModel = mockk<ExampleViewModel>() // Must mock every method!
// Error: "no answer found for: stateFlow"
```
### With relaxed
```kotlin
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
// Only mock what you care about
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
}
```
### When to Use relaxed
- Mocking ViewModels in Compose tests
- Mocking complex objects with many methods
- When you only care about specific method calls
### When NOT to Use relaxed
- Mocking repository interfaces (be explicit about behavior)
- When you want to verify NO unexpected calls
- Testing error paths (want test to fail if unexpected method called)
## ❌ Don't assert individual fields when complete state is available
### The Problem
Asserting individual state fields can miss unintended side effects on other fields.
### Wrong
```kotlin
@Test
fun `action should update state`() = runTest {
viewModel.trySendAction(SomeAction.DoThing)
val state = viewModel.stateFlow.value
assertEquals(null, state.dialog) // Only checks one field!
}
```
### Correct
```kotlin
@Test
fun `action should update state`() = runTest {
viewModel.trySendAction(SomeAction.DoThing)
val expected = SomeState(
isLoading = false,
data = "result",
dialog = null,
)
assertEquals(expected, viewModel.stateFlow.value) // Checks all fields
}
```
### Why This Matters
- Catches unintended mutations to other state fields
- Makes expected state explicit and readable
- Prevents silent regressions when state structure changes
---
## ❌ Don't use Kotlin assert() for boolean checks
### The Problem
Kotlin's `assert()` doesn't follow JUnit conventions and provides poor failure messages.
### Wrong
```kotlin
@Test
fun `event should trigger callback`() {
mutableEventFlow.tryEmit(SomeEvent.Navigate)
assert(onNavigateCalled) // Kotlin assert - bad failure messages
}
```
### Correct
```kotlin
@Test
fun `event should trigger callback`() {
mutableEventFlow.tryEmit(SomeEvent.Navigate)
assertTrue(onNavigateCalled) // JUnit assertTrue - proper assertion
}
```
### Always Use JUnit Assertions
- `assertTrue()` / `assertFalse()` for booleans
- `assertEquals()` for value comparisons
- `assertNotNull()` / `assertNull()` for nullability
- `assertThrows<T>()` for exceptions
---
## ❌ Don't pass SavedStateHandle to test factory methods
### The Problem
Exposing `SavedStateHandle` in test factory methods leaks Android framework details into test logic.
### Wrong
```kotlin
private fun createViewModel(
savedStateHandle: SavedStateHandle = SavedStateHandle(), // Framework type exposed
): MyViewModel = MyViewModel(
savedStateHandle = savedStateHandle,
repository = mockRepository,
)
@Test
fun `initial state from saved state`() = runTest {
val savedState = MyState(isLoading = true)
val savedStateHandle = SavedStateHandle(mapOf("state" to savedState))
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
// ...
}
```
### Correct
```kotlin
private fun createViewModel(
initialState: MyState? = null, // Domain type only
): MyViewModel = MyViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
repository = mockRepository,
)
@Test
fun `initial state from saved state`() = runTest {
val savedState = MyState(isLoading = true)
val viewModel = createViewModel(initialState = savedState)
// ...
}
```
### Why This Matters
- Cleaner, more intuitive test code
- Hides SavedStateHandle implementation details
- Follows Bitwarden conventions
---
## ❌ Don't test SavedStateHandle persistence in unit tests
### The Problem
Testing whether state persists to SavedStateHandle is testing Android framework behavior, not your business logic.
### Wrong
```kotlin
@Test
fun `state should persist to SavedStateHandle`() = runTest {
val savedStateHandle = SavedStateHandle()
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
viewModel.trySendAction(SomeAction)
val savedState = savedStateHandle.get<MyState>("state")
assertEquals(expectedState, savedState) // Testing framework, not logic!
}
```
### Correct
Focus on testing business logic and state transformations:
```kotlin
@Test
fun `action should update state correctly`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(SomeAction)
assertEquals(expectedState, viewModel.stateFlow.value) // Test observable state
}
```
---
## ❌ Don't use static mocking when DI pattern is available
### The Problem
Static mocking (`mockkStatic`) is harder to maintain and less testable than dependency injection.
### Wrong
```kotlin
class ParserTest {
@BeforeEach
fun setup() {
mockkStatic(UUID::class)
every { UUID.randomUUID() } returns mockk {
every { toString() } returns "fixed-uuid"
}
}
@AfterEach
fun tearDown() {
unmockkStatic(UUID::class)
}
}
```
### Correct
Extract an interface and inject it:
```kotlin
// Production code
interface UuidManager {
fun generateUuid(): String
}
class UuidManagerImpl : UuidManager {
override fun generateUuid(): String = UUID.randomUUID().toString()
}
class Parser(private val uuidManager: UuidManager) { ... }
// Test code
class ParserTest {
private val mockUuidManager = mockk<UuidManager>()
@BeforeEach
fun setup() {
every { mockUuidManager.generateUuid() } returns "fixed-uuid"
}
// No tearDown needed - no static mocking!
}
```
### When to Use This Pattern
- UUID generation
- Timestamp/Clock operations
- System property access
- Any static function that needs deterministic testing
---
## ❌ Don't forget to test null stream returns from Android APIs
### The Problem
Android's `ContentResolver.openOutputStream()` and `openInputStream()` can return null, not just throw exceptions.
### Wrong
```kotlin
class FileManagerTest {
@Test
fun `stringToUri with exception should return false`() = runTest {
every { mockContentResolver.openOutputStream(any()) } throws IOException()
val result = fileManager.stringToUri(mockUri, "data")
assertFalse(result)
}
// Missing: test for null return!
}
```
### Correct
```kotlin
class FileManagerTest {
@Test
fun `stringToUri with exception should return false`() = runTest {
every { mockContentResolver.openOutputStream(any()) } throws IOException()
val result = fileManager.stringToUri(mockUri, "data")
assertFalse(result)
}
@Test
fun `stringToUri with null stream should return false`() = runTest {
every { mockContentResolver.openOutputStream(any()) } returns null
val result = fileManager.stringToUri(mockUri, "data")
assertFalse(result) // CRITICAL: must handle null!
}
}
```
### Common Android APIs That Return Null
- `ContentResolver.openOutputStream()` / `openInputStream()`
- `Context.getExternalFilesDir()`
- `PackageManager.getApplicationInfo()` (can throw)
---
## Bitwarden Mocking Guidelines
**Mock at architectural boundaries:**
- Repository → ViewModel (mock repository)
- Service → Repository (mock service)
- API → Service (use MockWebServer, not mocks)
- DiskSource → Repository (mock disk source)
**Fake vs Mock Strategy (IMPORTANT):**
- **Happy paths**: Use Fake implementations (`FakeAuthenticatorDiskSource`, `FakeVaultDiskSource`)
- **Error paths**: Use MockK with isolated repository instances
```kotlin
// Happy path - use Fake
private val fakeDiskSource = FakeAuthenticatorDiskSource()
@Test
fun `createItem should return Success`() = runTest {
val result = repository.createItem(mockItem)
assertEquals(CreateItemResult.Success, result)
}
// Error path - use isolated Mock
@Test
fun `createItem with exception should return Error`() = runTest {
val mockDiskSource = mockk<AuthenticatorDiskSource> {
coEvery { saveItem(any()) } throws RuntimeException()
}
val repository = RepositoryImpl(diskSource = mockDiskSource)
val result = repository.createItem(mockItem)
assertEquals(CreateItemResult.Error, result)
}
```
**Use Fakes for:**
- `FakeDispatcherManager` - deterministic coroutines
- `FakeConfigDiskSource` - in-memory config storage
- `FakeSharedPreferences` - memory-backed preferences
- `FakeAuthenticatorDiskSource` - in-memory authenticator storage
**Create real instances for:**
- Data classes, value objects (User, Config, CipherView)
- Test data builders (`createMockCipher(number = 1)`)
## ❌ Don't forget bufferedMutableSharedFlow with onSubscription for Fakes
### The Problem
Fake data sources using `MutableSharedFlow` won't emit cached state to new subscribers without explicit handling.
### Wrong
```kotlin
class FakeDataSource : DataSource {
private val mutableFlow = MutableSharedFlow<List<Item>>()
private val storedItems = mutableListOf<Item>()
override fun getItems(): Flow<List<Item>> = mutableFlow
override suspend fun saveItem(item: Item) {
storedItems.add(item)
mutableFlow.emit(storedItems)
}
}
// Test: Initial collection gets nothing!
repository.dataFlow.test {
// Hangs or fails - no initial emission
}
```
### Correct
```kotlin
class FakeDataSource : DataSource {
private val mutableFlow = bufferedMutableSharedFlow<List<Item>>()
private val storedItems = mutableListOf<Item>()
override fun getItems(): Flow<List<Item>> = mutableFlow
.onSubscription { emit(storedItems.toList()) }
override suspend fun saveItem(item: Item) {
storedItems.add(item)
mutableFlow.emit(storedItems.toList())
}
}
// Test: Initial collection receives current state
repository.dataFlow.test {
assertEquals(emptyList(), awaitItem()) // Works!
}
```
### Key Points
- Use `bufferedMutableSharedFlow()` from `core/data/repository/util/`
- Add `.onSubscription { emit(currentState) }` for immediate state emission
- This ensures new collectors receive the current cached state
---
## ✅ Use Result extension functions for assertions
### The Pattern
Use `asSuccess()` and `asFailure()` extensions from `com.bitwarden.core.data.util` for cleaner Result assertions.
### Success Path
```kotlin
@Test
fun `getData should return success`() = runTest {
val result = repository.getData()
val expected = expectedData.asSuccess()
assertEquals(expected.getOrNull(), result.getOrNull())
}
```
### Failure Path
```kotlin
@Test
fun `getData with error should return failure`() = runTest {
val exception = IOException("Network error")
coEvery { mockService.getData() } returns exception.asFailure()
val result = repository.getData()
assertTrue(result.isFailure)
assertEquals(exception, result.exceptionOrNull())
}
```
### Avoid Redundant Assertions
```kotlin
// WRONG - redundant success checks
assertTrue(result.isSuccess)
assertTrue(expected.isSuccess)
assertArrayEquals(expected.getOrNull(), result.getOrNull())
// CORRECT - final assertion is sufficient
assertArrayEquals(expected.getOrNull(), result.getOrNull())
```
---
## Summary Checklist
Before submitting tests, verify:
**Core Patterns:**
- [ ] No `assertCoroutineThrows` inside `runTest`
- [ ] All static mocks have `unmockk` in `@After`
- [ ] EventFlow tests start with `expectNoEvents()`
- [ ] Using FakeDispatcherManager, not real dispatchers
- [ ] All coroutine tests use `runTest`
**Assertion Patterns:**
- [ ] Assert complete state objects, not individual fields
- [ ] Use JUnit `assertTrue()`, not Kotlin `assert()`
- [ ] Use `asSuccess()` for Result type assertions
- [ ] Avoid redundant assertion patterns
**Test Design:**
- [ ] Test factory methods accept domain types, not SavedStateHandle
- [ ] Use Fakes for happy paths, Mocks for error paths
- [ ] Prefer DI patterns over static mocking
- [ ] Test null returns from Android APIs (streams, files)
- [ ] Fakes use `bufferedMutableSharedFlow()` with `.onSubscription`
**General:**
- [ ] Tests don't depend on execution order
- [ ] Complex mocks use `relaxed = true`
- [ ] Test data is created fresh for each test
- [ ] Mocking behavior, not value objects
- [ ] Testing observable behavior, not implementation
When tests fail mysteriously, check these gotchas first.

View File

@@ -0,0 +1,274 @@
# Flow Testing with Turbine
Bitwarden Android uses Turbine for testing Kotlin Flows, including the critical distinction between StateFlow and EventFlow patterns.
## StateFlow vs EventFlow
### StateFlow (Replayed)
**Characteristics:**
- `replay = 1` - Always emits current value to new collectors
- First `awaitItem()` returns the current/initial state
- Survives configuration changes
- Used for UI state that needs to be immediately available
**Test Pattern:**
```kotlin
@Test
fun `action should update state`() = runTest {
val viewModel = MyViewModel(savedStateHandle, mockRepository)
viewModel.stateFlow.test {
// First awaitItem() gets CURRENT state
assertEquals(INITIAL_STATE, awaitItem())
// Trigger action
viewModel.trySendAction(MyAction.LoadData)
// Next awaitItem() gets UPDATED state
assertEquals(LOADING_STATE, awaitItem())
assertEquals(SUCCESS_STATE, awaitItem())
}
}
```
### EventFlow (No Replay)
**Characteristics:**
- `replay = 0` - Only emits new events after subscription
- No initial value emission
- One-time events (navigation, toasts, dialogs)
- Does not survive configuration changes
**Test Pattern:**
```kotlin
@Test
fun `action should emit event`() = runTest {
val viewModel = MyViewModel(savedStateHandle, mockRepository)
viewModel.eventFlow.test {
// MUST call expectNoEvents() first - nothing emitted yet
expectNoEvents()
// Trigger action
viewModel.trySendAction(MyAction.Submit)
// Now expect the event
assertEquals(MyEvent.NavigateToNext, awaitItem())
}
}
```
**Critical:** Always call `expectNoEvents()` before triggering actions on EventFlow. Forgetting this causes flaky tests.
## Testing State and Events Simultaneously
Use the `stateEventFlow()` helper from `BaseViewModelTest`:
```kotlin
@Test
fun `complex action should update state and emit event`() = runTest {
val viewModel = MyViewModel(savedStateHandle, mockRepository)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
// Initial state
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
// No events yet
eventFlow.expectNoEvents()
// Trigger action
viewModel.trySendAction(MyAction.ComplexAction)
// Verify state progression
assertEquals(LOADING_STATE, stateFlow.awaitItem())
assertEquals(SUCCESS_STATE, stateFlow.awaitItem())
// Verify event emission
assertEquals(MyEvent.ShowToast, eventFlow.awaitItem())
}
}
```
## Repository Flow Testing
### Testing Database Flows
```kotlin
@Test
fun `dataFlow should emit when database updates`() = runTest {
val dataFlow = MutableStateFlow(initialData)
every { mockDiskSource.dataFlow } returns dataFlow
repository.dataFlow.test {
// Initial value
assertEquals(initialData, awaitItem())
// Update disk source
dataFlow.value = updatedData
// Verify emission
assertEquals(updatedData, awaitItem())
}
}
```
### Testing Transformed Flows
```kotlin
@Test
fun `flow transformation should map correctly`() = runTest {
val sourceFlow = MutableStateFlow(UserEntity(id = "1", name = "John"))
every { mockDao.observeUser() } returns sourceFlow
// Repository transforms entity to domain model
repository.userFlow.test {
val expectedUser = User(id = "1", name = "John")
assertEquals(expectedUser, awaitItem())
}
}
```
## Common Patterns
### Pattern 1: Testing Initial State + Action
```kotlin
@Test
fun `load data should update from idle to loading to success`() = runTest {
coEvery { repository.getData() } returns "data".asSuccess()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.loadData()
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Success), awaitItem())
}
}
```
### Pattern 2: Testing Error States
```kotlin
@Test
fun `load data with error should emit failure state`() = runTest {
val error = Exception("Network error")
coEvery { repository.getData() } returns error.asFailure()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.loadData()
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
assertEquals(
DEFAULT_STATE.copy(loadingState = LoadingState.Error("Network error")),
awaitItem(),
)
}
}
```
### Pattern 3: Testing Event Sequences
```kotlin
@Test
fun `submit should emit validation then navigation events`() = runTest {
viewModel.eventFlow.test {
expectNoEvents()
viewModel.trySendAction(MyAction.Submit)
assertEquals(MyEvent.ShowValidation, awaitItem())
assertEquals(MyEvent.NavigateToNext, awaitItem())
}
}
```
### Pattern 4: Testing Cancellation
```kotlin
@Test
fun `cancelling collection should stop emissions`() = runTest {
val flow = flow {
repeat(100) {
emit(it)
delay(100)
}
}
flow.test {
assertEquals(0, awaitItem())
assertEquals(1, awaitItem())
// Cancel after 2 items
cancel()
// No more items received
}
}
```
## Anti-Patterns
### ❌ Forgetting expectNoEvents() on EventFlow
```kotlin
// WRONG
viewModel.eventFlow.test {
viewModel.trySendAction(action) // May fail - no initial expectNoEvents
assertEquals(event, awaitItem())
}
// CORRECT
viewModel.eventFlow.test {
expectNoEvents() // ALWAYS do this first
viewModel.trySendAction(action)
assertEquals(event, awaitItem())
}
```
### ❌ Not Using runTest
```kotlin
// WRONG - Missing runTest
@Test
fun `test flow`() {
flow.test { /* ... */ }
}
// CORRECT
@Test
fun `test flow`() = runTest {
flow.test { /* ... */ }
}
```
### ❌ Mixing StateFlow and EventFlow Patterns
```kotlin
// WRONG - Treating StateFlow like EventFlow
stateFlow.test {
expectNoEvents() // Unnecessary - StateFlow always has value
/* ... */
}
// WRONG - Treating EventFlow like StateFlow
eventFlow.test {
val item = awaitItem() // Will hang - no initial value!
/* ... */
}
```
## Reference Implementations
**ViewModel with StateFlow and EventFlow:**
`app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
**Repository Flow Testing:**
`data/src/test/kotlin/com/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt`
**Complex Flow Transformations:**
`data/src/test/kotlin/com/bitwarden/data/vault/repository/VaultRepositoryTest.kt`

View File

@@ -0,0 +1,259 @@
# Test Base Classes Reference
Bitwarden Android provides specialized base classes that configure test environments and provide helper utilities.
## BaseViewModelTest
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt`
### Purpose
Provides essential setup for testing ViewModels with proper coroutine dispatcher configuration and Flow testing helpers.
### Automatic Configuration
- Registers `MainDispatcherExtension` for `UnconfinedTestDispatcher`
- Ensures deterministic coroutine execution in tests
- All coroutines complete immediately without real delays
### Key Feature: stateEventFlow() Helper
**Use Case:** When you need to test both StateFlow and EventFlow simultaneously.
```kotlin
@Test
fun `complex action should update state and emit event`() = runTest {
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
// Verify initial state
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
// No events yet
eventFlow.expectNoEvents()
// Trigger action
viewModel.trySendAction(ExampleAction.ComplexAction)
// Verify state updated
assertEquals(LOADING_STATE, stateFlow.awaitItem())
// Verify event emitted
assertEquals(ExampleEvent.ShowToast, eventFlow.awaitItem())
}
}
```
### Usage Pattern
```kotlin
class MyViewModelTest : BaseViewModelTest() {
private val mockRepository: MyRepository = mockk()
private val savedStateHandle = SavedStateHandle(
mapOf(KEY_STATE to INITIAL_STATE)
)
@Test
fun `test action`() = runTest {
val viewModel = MyViewModel(
savedStateHandle = savedStateHandle,
repository = mockRepository
)
// Test with automatic dispatcher setup
viewModel.stateFlow.test {
assertEquals(INITIAL_STATE, awaitItem())
}
}
}
```
## BitwardenComposeTest
**Location:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt`
### Purpose
Pre-configured test class for Compose UI tests with all Bitwarden managers and theme setup.
### Automatic Configuration
- All Bitwarden managers pre-configured (FeatureFlags, AuthTab, Biometrics, etc.)
- Wraps content in `BitwardenTheme` and `LocalManagerProvider`
- Provides fixed `Clock` for deterministic time-based tests
- Extends `BaseComposeTest` for Robolectric and dispatcher setup
### Key Features
**Pre-configured Managers:**
- `FeatureFlagManager` - Controls feature flag behavior
- `AuthTabManager` - Manages auth tab state
- `BiometricsManager` - Handles biometric authentication
- `ClipboardManager` - Clipboard operations
- `NotificationManager` - Notification display
**Fixed Clock:**
All tests use a fixed clock for deterministic time-based testing:
```kotlin
// Tests use consistent time: 2023-10-27T12:00:00Z
val fixedClock: Clock
```
### Usage Pattern
```kotlin
class MyScreenTest : BitwardenComposeTest() {
private var haveCalledNavigateBack = false
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<MyViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setContent {
MyScreen(
onNavigateBack = { haveCalledNavigateBack = true },
viewModel = viewModel
)
}
}
@Test
fun `on back click should send action`() {
composeTestRule.onNodeWithContentDescription("Back").performClick()
verify { viewModel.trySendAction(MyAction.BackClick) }
}
@Test
fun `loading state should show progress`() {
mutableStateFlow.value = DEFAULT_STATE.copy(isLoading = true)
composeTestRule.onNode(isProgressBar).assertIsDisplayed()
}
}
```
### Important: bufferedMutableSharedFlow for Events
In Compose tests, use `bufferedMutableSharedFlow` instead of regular `MutableSharedFlow` (default replay is 0):
```kotlin
// Correct for Compose tests
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
// This allows triggering events and having the UI react
mutableEventFlow.tryEmit(MyEvent.NavigateBack)
```
## BaseServiceTest
**Location:** `network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt`
### Purpose
Provides MockWebServer setup for testing API service implementations.
### Automatic Configuration
- `server: MockWebServer` - Auto-started before each test, stopped after
- `retrofit: Retrofit` - Pre-configured with:
- JSON converter (kotlinx.serialization)
- NetworkResultCallAdapter for Result<T> responses
- Base URL pointing to MockWebServer
- `json: Json` - kotlinx.serialization JSON instance
### Usage Pattern
```kotlin
class MyServiceTest : BaseServiceTest() {
private val api: MyApi = retrofit.create()
private val service = MyServiceImpl(api)
@Test
fun `getConfig should return success when API succeeds`() = runTest {
// Enqueue mock response
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
// Call service
val result = service.getConfig()
// Verify result
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
}
@Test
fun `getConfig should return failure when API fails`() = runTest {
// Enqueue error response
server.enqueue(MockResponse().setResponseCode(500))
// Call service
val result = service.getConfig()
// Verify failure
assertTrue(result.isFailure)
}
}
```
### MockWebServer Patterns
**Enqueue successful response:**
```kotlin
server.enqueue(MockResponse().setBody("""{"key": "value"}"""))
```
**Enqueue error response:**
```kotlin
server.enqueue(MockResponse().setResponseCode(404))
server.enqueue(MockResponse().setResponseCode(500))
```
**Enqueue delayed response:**
```kotlin
server.enqueue(
MockResponse()
.setBody("""{"key": "value"}""")
.setBodyDelay(1000, TimeUnit.MILLISECONDS)
)
```
**Verify request details:**
```kotlin
val request = server.takeRequest()
assertEquals("/api/config", request.path)
assertEquals("GET", request.method)
assertEquals("Bearer token", request.getHeader("Authorization"))
```
## BaseComposeTest
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseComposeTest.kt`
### Purpose
Base class for Compose tests that extends `BaseRobolectricTest` and provides `setTestContent()` helper.
### Features
- Robolectric configuration for Compose
- Proper dispatcher setup
- `composeTestRule` for UI testing
- `setTestContent()` helper wraps content in theme
### Usage
Typically you'll extend `BitwardenComposeTest` which extends this class. Use `BaseComposeTest` directly only for tests that don't need Bitwarden-specific manager configuration.
## When to Use Each Base Class
| Test Type | Base Class | Use When |
|-----------|------------|----------|
| ViewModel tests | `BaseViewModelTest` | Testing ViewModel state and events |
| Compose screen tests | `BitwardenComposeTest` | Testing Compose UI with Bitwarden components |
| API service tests | `BaseServiceTest` | Testing network layer with MockWebServer |
| Repository tests | None (manual setup) | Testing repository logic with mocked dependencies |
| Utility/helper tests | None (manual setup) | Testing pure functions or utilities |
## Complete Examples
**ViewModel Test:**
`../examples/viewmodel-test-example.md`
**Compose Screen Test:**
`../examples/compose-screen-test-example.md`
**Repository Test:**
`../examples/repository-test-example.md`

2
.github/CODEOWNERS vendored
View File

@@ -12,6 +12,8 @@
# Claude related files
.claude/ @bitwarden/team-ai-sme
.github/workflows/respond.yml @bitwarden/team-ai-sme
.github/workflows/review-code.yml @bitwarden/team-ai-sme
# Auth
# app/src/main/java/com/x8bit/bitwarden/data/auth @bitwarden/team-auth-dev

View File

@@ -9,27 +9,3 @@
## 📸 Screenshots
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
## ⏰ Reminders before review
- Contributor guidelines followed
- All formatters and local linters executed and passed
- Written new unit and / or integration tests where applicable
- Protected functional changes with optionality (feature flags)
- Used internationalization (i18n) for all UI strings
- CI builds passed
- Communicated to DevOps any deployment requirements
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
## 🦮 Reviewer guidelines
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
- 👍 (`:+1:`) or similar for great changes
- 📝 (`:memo:`) or (`:information_source:`) for notes or general info
- ❓ (`:question:`) for questions
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
- 🎨 (`:art:`) for suggestions / improvements
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
- ⛏ (`:pick:`) for minor or nitpick changes

View File

@@ -11,10 +11,14 @@ runs:
steps:
- name: Log inputs to job summary
shell: bash
env:
INPUTS: ${{ inputs.inputs }}
run: |
echo "<details><summary>Job Inputs</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
echo '${{ inputs.inputs }}' >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
{
echo "<details><summary>Job Inputs</summary>"
echo ""
echo '```json'
echo "$INPUTS"
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -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@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
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@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
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
@@ -44,6 +25,5 @@ runs:
- name: Install Fastlane
shell: bash
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3

58
.github/label-pr.json vendored Normal file
View File

@@ -0,0 +1,58 @@
{
"title_patterns": {
"t:feature": ["feat", "feature", "tool"],
"t:bug": ["fix", "bug", "bugfix"],
"t:tech-debt": ["refactor", "chore", "cleanup", "revert", "debt", "test", "perf"],
"t:docs": ["docs"],
"t:ci": ["ci", "build", "chore(ci)"],
"t:deps": ["deps"],
"t:breaking-change": ["breaking", "breaking-change"],
"t:misc": ["misc"],
"t:llm": ["llm"]
},
"path_patterns": {
"app:shared": [
"annotation/",
"core/",
"data/",
"network/",
"ui/",
"authenticatorbridge/",
"gradle/"
],
"app:password-manager": [
"app/",
"cxf/",
"testharness/"
],
"app:authenticator": [
"authenticator/"
],
"t:feature": [
"app/src/main/assets/fido2_privileged_community.json",
"app/src/main/assets/fido2_privileged_google.json",
"testharness/"
],
"t:tech-debt": [
"gradle.properties",
"keystore/"
],
"t:ci": [
".checkmarx/",
".github/",
"scripts/",
"fastlane/",
".gradle/",
"detekt-config.yml"
],
"t:docs": [
"docs/"
],
"t:deps": [
"gradle/"
],
"t:llm": [
".claude/"
]
}
}

34
.github/release.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
changelog:
exclude:
labels:
- ignore-for-release
categories:
- title: '✨ Community Highlight'
labels:
- community-pr
- title: ':shipit: Feature Development'
labels:
- t:feature
- t:feature-app
- t:feature-tool
- t:new-feature
- t:enhancement
- title: '❗ Breaking Changes'
labels:
- t:breaking-change
- title: '🐛 Bug fixes'
labels:
- t:bug
- title: '⚙️ Maintenance'
labels:
- t:tech-debt
- t:ci
- t:docs
- t:misc
- title: '📦 Dependency Updates'
labels:
- dependencies
- t:deps
- title: '🎨 Other'
labels:
- '*'

25
.github/renovate.json vendored
View File

@@ -3,6 +3,8 @@
"extends": [
"github>bitwarden/renovate-config"
],
"labels": ["t:deps"],
"ignoreDeps": ["com.bitwarden:sdk-android"],
"enabledManagers": [
"github-actions",
"gradle",
@@ -19,19 +21,6 @@
"patch"
]
},
{
"groupName": "gradle minor",
"matchUpdateTypes": [
"minor",
"patch"
],
"matchManagers": [
"gradle"
],
"excludePackageNames": [
"com.github.bumptech.glide:compose"
]
},
{
"groupName": "kotlin",
"description": "Kotlin and Compose dependencies that must be updated together to maintain compatibility.",
@@ -44,16 +33,6 @@
"/org.jetbrains.kotlin.*/",
"/com.google.devtools.ksp/"
]
},
{
"groupName": "bundler minor",
"matchUpdateTypes": [
"minor",
"patch"
],
"matchManagers": [
"bundler"
]
}
]
}

View 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)

View File

@@ -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
@@ -40,7 +35,7 @@ Single line of release notes text
```json
...
"customfield_10335": {
"customfield_9999": {
"type": "doc",
"version": 1,
"content": [
@@ -62,7 +57,7 @@ Single line of release notes text
```json
...
"customfield_10335": {
"customfield_9999": {
"type": "doc",
"version": 1,
"content": [

View File

@@ -1,9 +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 = Path(__file__).name
def extract_text_from_content(content):
if isinstance(content, list):
@@ -23,47 +30,81 @@ def extract_text_from_content(content):
return ''
def parse_release_notes(response_json):
try:
fields = response_json.get('fields', {})
release_notes_field = fields.get('customfield_10335', {})
def log_customfields_with_content(fields):
"""Log all customfield_* fields that have a 'content' key to help troubleshoot structure changes."""
print(f"[{SCRIPT_NAME}] Available customfield_* fields with 'content':", file=sys.stderr)
found = False
for key, value in fields.items():
if key.startswith('customfield_') and isinstance(value, dict) and 'content' in value:
found = True
print(f"[{SCRIPT_NAME}] {key}: {json.dumps(value, indent=2)}", file=sys.stderr)
if not found:
print(f"[{SCRIPT_NAME}] None found", file=sys.stderr)
if not release_notes_field or not release_notes_field.get('content'):
def parse_release_notes(response_json):
release_notes_field_name = 'customfield_10309'
try:
fields = response_json.get('fields')
if not fields:
print(f"[{SCRIPT_NAME}] 'fields' is empty or missing in response", file=sys.stderr)
return ''
release_notes = extract_text_from_content(release_notes_field.get('content', []))
release_notes_field = fields.get(release_notes_field_name)
if not release_notes_field:
print(f"[{SCRIPT_NAME}] Release notes field is empty or missing. Field name: {release_notes_field_name}", file=sys.stderr)
log_customfields_with_content(fields)
return ''
content = release_notes_field.get('content', [])
if not content:
print(f"[{SCRIPT_NAME}] Release notes field was found but 'content' is empty or missing in {release_notes_field_name}", file=sys.stderr)
log_customfields_with_content(fields)
return ''
release_notes = extract_text_from_content(content)
return release_notes
except Exception as e:
print(f"Error parsing release notes: {str(e)}", file=sys.stderr)
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"Error fetching Jira issue: {response.status_code}", 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__":

View File

@@ -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",
]

View File

@@ -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" },
]

263
.github/scripts/label-pr.py vendored Normal file
View File

@@ -0,0 +1,263 @@
#!/usr/bin/env python3
# Requires Python 3.9+
"""
Label pull requests based on changed file paths and PR title patterns (conventional commit format).
Usage:
python label-pr.py <pr-number> <pr-labels> [-a|--add|-r|--replace] [-d|--dry-run] [-c|--config CONFIG]
Arguments:
pr-number: The pull request number
pr-labels: Current PR labels as JSON array string
-a, --add: Add labels without removing existing ones (default)
-r, --replace: Replace all existing labels
-d, --dry-run: Run without actually applying labels
-c, --config: Path to JSON config file (default: .github/label-pr.json)
Examples:
python label-pr.py 1234 '[]'
python label-pr.py 1234 '[{"name":"label1"}]' -a
python label-pr.py 1234 '[{"name":"label1"}]' --replace
python label-pr.py 1234 '[{"name":"label1"}]' -r -d
python label-pr.py 1234 '[]' --config custom-config.json
"""
import argparse
import json
import os
import subprocess
import sys
DEFAULT_MODE = "add"
DEFAULT_CONFIG_PATH = ".github/label-pr.json"
def load_config_json(config_file: str) -> dict:
"""Load configuration from JSON file."""
if not os.path.exists(config_file):
print(f"❌ Config file not found: {config_file}")
sys.exit(1)
try:
with open(config_file, 'r') as f:
config = json.load(f)
print(f"✅ Loaded config from: {config_file}")
valid_config = True
if not config.get("title_patterns"):
print("❌ Missing 'title_patterns' in config file")
valid_config = False
if not config.get("path_patterns"):
print("❌ Missing 'path_patterns' in config file")
valid_config = False
if not valid_config:
print("::error::Invalid label-pr.json config file, exiting...")
sys.exit(1)
return config
except json.JSONDecodeError as e:
print(f"❌ JSON deserialization error in label-pr.json config: {e}")
sys.exit(1)
except Exception as e:
print(f"❌ Unexpected error loading label-pr.json config: {e}")
sys.exit(1)
def gh_get_changed_files(pr_number: str) -> list[str]:
"""Get list of changed files in a pull request."""
try:
result = subprocess.run(
["gh", "pr", "diff", pr_number, "--name-only"],
capture_output=True,
text=True,
check=True
)
changed_files = result.stdout.strip().split("\n")
return list(filter(None, changed_files))
except subprocess.CalledProcessError as e:
print(f"::error::Error getting changed files: {e}")
return []
def gh_get_pr_title(pr_number: str) -> str:
"""Get the title of a pull request."""
try:
result = subprocess.run(
["gh", "pr", "view", pr_number, "--json", "title", "--jq", ".title"],
capture_output=True,
text=True,
check=True
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"::error::Error getting PR title: {e}")
return ""
def gh_add_labels(pr_number: str, labels: list[str]) -> None:
"""Add labels to a pull request (doesn't remove existing labels)."""
gh_labels = ','.join(labels)
subprocess.run(
["gh", "pr", "edit", pr_number, "--add-label", gh_labels],
check=True
)
def gh_replace_labels(pr_number: str, labels: list[str]) -> None:
"""Replace all labels on a pull request with the specified labels."""
payload = json.dumps({"labels": labels})
subprocess.run(
["gh", "api", "repos/{owner}/{repo}/issues/" + pr_number, "-X", "PATCH", "--silent", "--input", "-"],
input=payload,
text=True,
check=True
)
def label_filepaths(changed_files: list[str], path_patterns: dict) -> list[str]:
"""Check changed files against path patterns and return labels to apply."""
if not changed_files:
return []
labels_to_apply = set() # Use set to avoid duplicates
for label, patterns in path_patterns.items():
for file in changed_files:
if any(file.startswith(pattern) for pattern in patterns):
print(f"👀 File '{file}' matches pattern for label '{label}'")
labels_to_apply.add(label)
break
if "app:shared" in labels_to_apply:
labels_to_apply.add("app:password-manager")
labels_to_apply.add("app:authenticator")
labels_to_apply.remove("app:shared")
if not labels_to_apply:
print("::notice::No matching file paths found.")
return list(labels_to_apply)
def label_title(pr_title: str, title_patterns: dict) -> list[str]:
"""Check PR title against patterns and return labels to apply."""
if not pr_title:
return []
labels_to_apply = set()
title_lower = pr_title.lower()
for label, patterns in title_patterns.items():
for pattern in patterns:
# Check for pattern with : or ( suffix (conventional commits format)
if f"{pattern}:" in title_lower or f"{pattern}(" in title_lower:
print(f"📝 Title matches pattern '{pattern}' for label '{label}'")
labels_to_apply.add(label)
break
if not labels_to_apply:
print("::notice::No matching title patterns found.")
return list(labels_to_apply)
def parse_pr_labels(pr_labels_str: str) -> list[str]:
"""Parse PR labels from JSON array string."""
try:
labels = json.loads(pr_labels_str)
if not isinstance(labels, list):
print("::warning::Failed to parse PR labels: not a list")
return []
return [item.get("name") for item in labels if item.get("name")]
except (json.JSONDecodeError, TypeError) as e:
print(f"::error::Error parsing PR labels: {e}")
return []
def get_preserved_labels(pr_labels_str: str) -> list[str]:
"""Get existing PR labels that should be preserved (exclude app: and t: labels)."""
existing_labels = parse_pr_labels(pr_labels_str)
print(f"🔍 Parsed PR labels: {existing_labels}")
preserved_labels = [label for label in existing_labels if not (label.startswith("app:") or label.startswith("t:"))]
if preserved_labels:
print(f"🔍 Preserving existing labels: {', '.join(preserved_labels)}")
return preserved_labels
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="Label pull requests based on changed file paths and PR title patterns."
)
parser.add_argument(
"pr_number",
help="The pull request number"
)
parser.add_argument(
"pr_labels",
help="Current PR labels (JSON array)"
)
mode_group = parser.add_mutually_exclusive_group()
mode_group.add_argument(
"-a", "--add",
action="store_true",
help="Add labels without removing existing ones (default)"
)
mode_group.add_argument(
"-r", "--replace",
action="store_true",
help="Replace all existing labels"
)
parser.add_argument(
"-d", "--dry-run",
action="store_true",
help="Run without actually applying labels"
)
parser.add_argument(
"-c", "--config",
default=DEFAULT_CONFIG_PATH,
help=f"Path to JSON config file (default: {DEFAULT_CONFIG_PATH})"
)
args, unknown = parser.parse_known_args() # required to handle --dry-run passed as an empty string ("") by the workflow
return args
def main():
args = parse_args()
config = load_config_json(args.config)
LABEL_TITLE_PATTERNS = config["title_patterns"]
LABEL_PATH_PATTERNS = config["path_patterns"]
pr_number = args.pr_number
mode = "replace" if args.replace else "add"
if args.dry_run:
print("🔍 DRY RUN MODE - Labels will not be applied")
print(f"📌 Labeling mode: {mode}")
print(f"🔍 Checking PR #{pr_number}...")
pr_title = gh_get_pr_title(pr_number)
print(f"📋 PR Title: {pr_title}\n")
changed_files = gh_get_changed_files(pr_number)
print("👀 Changed files:\n" + "\n".join(changed_files) + "\n")
filepath_labels = label_filepaths(changed_files, LABEL_PATH_PATTERNS)
title_labels = label_title(pr_title, LABEL_TITLE_PATTERNS)
all_labels = set(filepath_labels + title_labels)
if all_labels:
print("--------------------------------")
labels_str = ', '.join(sorted(all_labels))
if mode == "add":
print(f"::notice::🏷️ Adding labels: {labels_str}")
if not args.dry_run:
gh_add_labels(pr_number, list(all_labels))
else:
preserved_labels = get_preserved_labels(args.pr_labels)
if preserved_labels:
all_labels.update(preserved_labels)
labels_str = ', '.join(sorted(all_labels))
print(f"::notice::🏷️ Replacing labels with: {labels_str}")
if not args.dry_run:
gh_replace_labels(pr_number, list(all_labels))
else:
print("::warning::No matching patterns found, no labels applied.")
print("✅ Done")
if __name__ == "__main__":
main()

23
.github/scripts/set-build-version.sh vendored Executable file
View 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

173
.github/workflows/_version.yml vendored Normal file
View File

@@ -0,0 +1,173 @@
name: Calculate Version Name and Number
on:
workflow_dispatch:
inputs:
app_codename:
description: "App Name - e.g. 'bwpm' or 'bwa'"
base_version_number:
description: "Base Version Number - Will be added to the calculated version number"
type: number
default: 0
version_name:
description: "Version Name Override - e.g. '2024.8.1'"
version_number:
description: "Version Number Override - e.g. '1021'"
patch_version:
description: "Patch Version Override - e.g. '999'"
distinct_id:
description: "Unique ID for this dispatch, used by dispatch-and-download.yml"
skip_checkout:
description: "Skip checking out the repository"
type: boolean
workflow_call:
inputs:
app_codename:
description: "App Name - e.g. 'bwpm' or 'bwa'"
type: string
base_version_number:
description: "Base Version Number - Will be added to the calculated version number"
type: number
default: 0
version_name:
description: "Version Name Override - e.g. '2024.8.1'"
type: string
version_number:
description: "Version Number Override - e.g. '1021'"
type: string
patch_version:
description: "Patch Version Override - e.g. '999'"
type: string
distinct_id:
description: "Unique ID for this dispatch, used by dispatch-and-download.yml"
type: string
skip_checkout:
description: "Skip checking out the repository"
type: boolean
outputs:
version_name:
description: "Version Name"
value: ${{ jobs.calculate-version.outputs.version_name }}
version_number:
description: "Version Number"
value: ${{ jobs.calculate-version.outputs.version_number }}
env:
APP_CODENAME: ${{ inputs.app_codename }}
BASE_VERSION_NUMBER: ${{ inputs.base_version_number || 0 }}
jobs:
calculate-version:
name: Calculate Version Name and Number
runs-on: ubuntu-22.04
permissions:
contents: read
outputs:
version_name: ${{ steps.calc-version-name.outputs.version_name }}
version_number: ${{ steps.calc-version-number.outputs.version_number }}
steps:
- name: Log inputs to job summary
uses: bitwarden/android/.github/actions/log-inputs@main
with:
inputs: "${{ toJson(inputs) }}"
- name: Echo distinct ID ${{ github.event.inputs.distinct_id }}
env:
_DISTINCT_ID: ${{ inputs.distinct_id }}
run: echo "${_DISTINCT_ID}"
- name: Check out repository
if: ${{ !inputs.skip_checkout || false }}
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Calculate version name
id: calc-version-name
env:
_VERSION_NAME: ${{ inputs.version_name }}
_PATCH_VERSION: ${{ inputs.patch_version }}
run: |
output() {
local version_name=$1
echo "version_name=$version_name" >> "$GITHUB_OUTPUT"
}
# override version name if provided
if [[ ! -z "${_VERSION_NAME}" ]]; then
version_name=${_VERSION_NAME}
echo "::warning::Override applied: $version_name"
output "$version_name"
exit 0
fi
current_year=$(date +%Y)
current_month=$(date +%-m)
latest_tag_version=$(git tag -l --sort=-creatordate | grep "$APP_CODENAME" | head -n 1)
if [[ -z "$latest_tag_version" ]]; then
version_name="${current_year}.${current_month}.${_PATCH_VERSION:-0}"
echo "::warning::No tags found, did you checkout? Calculating version from current date: $version_name"
output "$version_name"
exit 0
fi
# Git tag was found, calculate version from latest tag
latest_version=${latest_tag_version:1} # remove 'v' from tag version
latest_major_version=$(echo "$latest_version" | cut -d "." -f 1)
latest_minor_version=$(echo "$latest_version" | cut -d "." -f 2)
patch_version=0
if [[ ! -z "${_PATCH_VERSION}" ]]; then
patch_version=${_PATCH_VERSION}
echo "::warning::Patch Version Override applied: $patch_version"
elif [[ "$current_year" == "$latest_major_version" && "$current_month" == "$latest_minor_version" ]]; then
latest_patch_version=$(echo "$latest_version" | cut -d "." -f 3)
patch_version=$(($latest_patch_version + 1))
fi
version_name="${current_year}.${current_month}.${patch_version}"
output "$version_name"
- name: Calculate version number
id: calc-version-number
env:
_VERSION_NUMBER: ${{ inputs.version_number }}
run: |
# override version number if provided
if [[ ! -z "${_VERSION_NUMBER}" ]]; then
version_number=${_VERSION_NUMBER}
echo "::warning::Override applied: $version_number"
echo "version_number=$version_number" >> "$GITHUB_OUTPUT"
exit 0
fi
version_number=$(($GITHUB_RUN_NUMBER + ${BASE_VERSION_NUMBER}))
echo "version_number=$version_number" >> "$GITHUB_OUTPUT"
- name: Create version info JSON
env:
_VERSION_NUMBER: ${{ steps.calc-version-number.outputs.version_number }}
_VERSION_NAME: ${{ steps.calc-version-name.outputs.version_name }}
run: |
json=$(cat <<EOF
{
"version_number": "${_VERSION_NUMBER}",
"version_name": "${_VERSION_NAME}"
}
EOF
)
echo "$json" > version_info.json
echo "## version-info.json" >> "$GITHUB_STEP_SUMMARY"
echo '```json' >> "$GITHUB_STEP_SUMMARY"
echo "$json" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
- name: Upload version info artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: version-info
path: version_info.json

View File

@@ -15,100 +15,47 @@ on:
description: "Optional. Build number to use. Overrides default of GitHub run number."
required: false
type: number
patch_version:
description: "Order 999 - Overrides Patch version"
type: boolean
distribute-to-firebase:
description: "Optional. Distribute artifacts to Firebase."
required: false
default: false
default: true
type: boolean
publish-to-play-store:
description: "Optional. Deploy bundle artifact to Google Play Store"
required: false
default: false
default: true
type: boolean
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' }}
permissions:
contents: read
packages: read
id-token: write
jobs:
build:
name: Build Authenticator
runs-on: ubuntu-24.04
steps:
- name: Log inputs to job summary
env:
INPUTS: ${{ toJson(inputs) }}
run: |
{
echo "<details><summary>Job Inputs</summary>"
echo ""
echo '```json'
echo "$INPUTS"
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
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@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
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: Check Authenticator
run: bundle exec fastlane check
- name: Build Authenticator
run: bundle exec fastlane buildAuthenticatorDebug
version:
name: Calculate Version Name and Number
uses: bitwarden/android/.github/workflows/_version.yml@main
with:
app_codename: "bwa"
base_version_number: 0
version_name: ${{ inputs.version-name }}
version_number: ${{ inputs.version-code }}
patch_version: ${{ inputs.patch_version && '999' || '' }}
publish_playstore:
name: Publish Authenticator Play Store artifacts
needs:
- build
- version
runs-on: ubuntu-24.04
permissions:
id-token: write
strategy:
fail-fast: false
matrix:
@@ -116,21 +63,10 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- 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: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
@@ -166,7 +102,7 @@ jobs:
--name com.bitwarden.authenticator.dev-google-services.json --file ${{ github.workspace }}/authenticator/src/debug/google-services.json --output none
- name: Download Firebase credentials
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
@@ -177,7 +113,7 @@ jobs:
--name authenticator_play_firebase-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json --output none
- name: Download Play Store credentials
if: ${{ inputs.publish-to-play-store }}
if: ${{ env.PUBLISH_TO_PLAY_STORE }}
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
@@ -190,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: ${{ inputs.publish-to-play-store }}
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@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
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@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
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 \
@@ -235,22 +146,9 @@ jobs:
- name: Increment version
env:
DEFAULT_VERSION_CODE: ${{ github.run_number }}
INPUT_VERSION_CODE: "${{ inputs.version-code }}"
INPUT_VERSION_NAME: ${{ inputs.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' }}
@@ -276,17 +174,17 @@ jobs:
keyAlias:"bitwardenauthenticator" \
keyPassword:"$KEY_PASSWORD"
- name: Upload release Play Store .aab artifact
- name: Upload to GitHub Artifacts - prod.aab
if: ${{ matrix.variant == 'aab' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.bitwarden.authenticator.aab
path: authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab
if-no-files-found: error
- name: Upload release .apk artifact
- name: Upload to GitHub Artifacts - prod.apk
if: ${{ matrix.variant == 'apk' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.bitwarden.authenticator.apk
path: authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk
@@ -304,38 +202,36 @@ jobs:
sha256sum "authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk" \
> ./authenticator-android-apk-sha256.txt
- name: Upload .apk SHA file for release
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
if: ${{ matrix.variant == 'apk' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: authenticator-android-apk-sha256.txt
path: ./authenticator-android-apk-sha256.txt
if-no-files-found: error
- name: Upload .aab SHA file for release
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
if: ${{ matrix.variant == 'aab' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: authenticator-android-aab-sha256.txt
path: ./authenticator-android-aab-sha256.txt
if-no-files-found: error
- name: Install Firebase app distribution plugin
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
if: ${{ matrix.variant == 'aab' && env.DISTRIBUTE_TO_FIREBASE }}
run: bundle exec fastlane add_plugin firebase_app_distribution
- name: Publish release bundle to Firebase
if: ${{ matrix.variant == 'aab' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
- name: Distribute to Firebase - prod.aab
if: ${{ matrix.variant == 'aab' && env.DISTRIBUTE_TO_FIREBASE }}
env:
FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json
run: |
bundle exec fastlane distributeAuthenticatorReleaseBundleToFirebase \
serviceCredentialsFile:"$FIREBASE_CREDS_PATH"
# Only publish bundles to Play Store when `publish-to-play-store` is true while building
# bundles
- name: Publish release bundle to Google Play Store
if: ${{ inputs.publish-to-play-store && matrix.variant == 'aab' }}
- name: Publish to Play Store - prod.aab
if: ${{ matrix.variant == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
env:
PLAY_STORE_CREDS_FILE: ${{ github.workspace }}/secrets/authenticator_play_store-creds.json
run: |

84
.github/workflows/build-testharness.yml vendored Normal file
View File

@@ -0,0 +1,84 @@
name: Build Test Harness
on:
push:
paths:
- testharness/**
workflow_dispatch:
inputs:
version-name:
description: "Optional. Version string to use, in X.Y.Z format. Overrides default in the project."
required: false
type: string
version-code:
description: "Optional. Build number to use. Overrides default of GitHub run number."
required: false
type: number
patch_version:
description: "Order 999 - Overrides Patch version"
type: boolean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
jobs:
version:
name: Calculate Version Name and Number
uses: bitwarden/android/.github/workflows/_version.yml@main
with:
app_codename: "bwpm"
base_version_number: 0
version_name: ${{ inputs.version-name }}
version_number: ${{ inputs.version-code }}
patch_version: ${{ inputs.patch_version && '999' || '' }}
build:
name: Build Test Harness
runs-on: ubuntu-24.04
needs: version
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Increment version
env:
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: com.bitwarden.testharness.dev-debug.apk
path: testharness/build/outputs/apk/debug/com.bitwarden.testharness.dev.apk
if-no-files-found: error
- name: Create checksum for Test Harness APK
run: |
sha256sum "testharness/build/outputs/apk/debug/com.bitwarden.testharness.dev.apk" \
> ./com.bitwarden.testharness.dev.apk-sha256.txt
- name: Upload Test Harness SHA file
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
if-no-files-found: error

View File

@@ -15,6 +15,9 @@ on:
description: "Optional. Build number to use. Overrides default of GitHub run number."
required: false
type: number
patch_version:
description: "Order 999 - Overrides Patch version"
type: boolean
distribute-to-firebase:
description: "Optional. Distribute artifacts to Firebase."
required: false
@@ -28,433 +31,322 @@ 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' }}
permissions:
contents: read
packages: read
id-token: write
jobs:
build:
name: Build
runs-on: ubuntu-24.04
version:
name: Calculate Version Name and Number
uses: bitwarden/android/.github/workflows/_version.yml@main
with:
app_codename: "bwpm"
# Start from 11000 to prevent collisions with mobile build version codes
base_version_number: 11000
version_name: ${{ inputs.version-name }}
version_number: ${{ inputs.version-code }}
patch_version: ${{ inputs.patch_version && '999' || '' }}
steps:
- name: Log inputs to job summary
env:
INPUTS: ${{ toJson(inputs) }}
run: |
{
echo "<details><summary>Job Inputs</summary>"
echo ""
echo '```json'
echo "$INPUTS"
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
# publish_playstore:
# name: Publish Play Store artifacts
# needs:
# - version
# runs-on: ubuntu-24.04
# permissions:
# id-token: write
# strategy:
# fail-fast: false
# matrix:
# variant: ["prod", "dev"]
# artifact: ["apk", "aab"]
# steps:
# - name: Check out repo
# uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
# with:
# persist-credentials: false
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
# - name: Log in to Azure
# uses: bitwarden/gh-actions/azure-login@main
# with:
# subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# tenant_id: ${{ secrets.AZURE_TENANT_ID }}
# client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
# - name: Get Azure Key Vault secrets
# id: get-kv-secrets
# uses: bitwarden/gh-actions/get-keyvault-secrets@main
# with:
# keyvault: gh-android
# secrets: "UPLOAD-KEYSTORE-PASSWORD,UPLOAD-BETA-KEYSTORE-PASSWORD,UPLOAD-BETA-KEY-PASSWORD,PLAY-KEYSTORE-PASSWORD,PLAY-BETA-KEYSTORE-PASSWORD,PLAY-BETA-KEY-PASSWORD"
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
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: Retrieve secrets
# env:
# ACCOUNT_NAME: bitwardenci
# CONTAINER_NAME: mobile
# run: |
# mkdir -p ${{ github.workspace }}/secrets
# mkdir -p ${{ github.workspace }}/app/src/standardBeta
# mkdir -p ${{ github.workspace }}/app/src/standardRelease
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
# az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
# --name app_play-keystore.jks --file ${{ github.workspace }}/keystores/app_play-keystore.jks --output none
# az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
# --name app_upload-keystore.jks --file ${{ github.workspace }}/keystores/app_upload-keystore.jks --output none
# az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
# --name play_creds.json --file ${{ github.workspace }}/secrets/play_creds.json --output none
# az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
# --name app_beta_play-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_play-keystore.jks --output none
# az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
# --name app_beta_upload-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_upload-keystore.jks --output none
# az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
# --name google-services.json --file ${{ github.workspace }}/app/src/standardRelease/google-services.json --output none
# az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
# --name google-services.json --file ${{ github.workspace }}/app/src/standardBeta/google-services.json --output none
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
# - name: Download Firebase credentials
# if: ${{ matrix.variant == 'prod' && env.DISTRIBUTE_TO_FIREBASE }}
# env:
# ACCOUNT_NAME: bitwardenci
# CONTAINER_NAME: mobile
# run: |
# mkdir -p ${{ github.workspace }}/secrets
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
# az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
# --name app_play_prod_firebase-creds.json --file ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json --output none
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
# - name: Log out from Azure
# uses: bitwarden/gh-actions/azure-logout@main
- name: Check
run: bundle exec fastlane check
# - name: Setup Android Build
# uses: ./.github/actions/setup-android-build
- name: Build
run: bundle exec fastlane assembleDebugApks
# - name: Update app CI Build info
# run: |
# ./scripts/update_app_ci_build_info.sh \
# "$GITHUB_REPOSITORY" \
# "$GITHUB_REF_NAME" \
# "$GITHUB_SHA" \
# "$GITHUB_RUN_ID" \
# "$GITHUB_RUN_ATTEMPT"
- name: Upload test reports on failure
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: failure()
with:
name: test-reports
path: app/build/reports/tests/
# - name: Increment version
# env:
# 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"
publish_playstore:
name: Publish Play Store artifacts
needs:
- build
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
variant: ["prod", "dev"]
artifact: ["apk", "aab"]
steps:
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
# - name: Generate release Play Store bundle
# if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' }}
# env:
# UPLOAD_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-KEYSTORE-PASSWORD }}
# run: |
# bundle exec fastlane bundlePlayStoreRelease \
# storeFile:app_upload-keystore.jks \
# storePassword:$UPLOAD_KEYSTORE_PASSWORD \
# keyAlias:upload \
# keyPassword:$UPLOAD_KEYSTORE_PASSWORD
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
# - name: Generate beta Play Store bundle
# if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
# env:
# UPLOAD_BETA_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-BETA-KEYSTORE-PASSWORD }}
# UPLOAD_BETA_KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-BETA-KEY-PASSWORD }}
# run: |
# bundle exec fastlane bundlePlayStoreBeta \
# storeFile:app_beta_upload-keystore.jks \
# storePassword:$UPLOAD_BETA_KEYSTORE_PASSWORD \
# keyAlias:bitwarden-beta-upload \
# keyPassword:$UPLOAD_BETA_KEY_PASSWORD
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
# - name: Generate release Play Store APK
# if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
# env:
# PLAY_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-KEYSTORE-PASSWORD }}
# run: |
# bundle exec fastlane assemblePlayStoreReleaseApk \
# storeFile:app_play-keystore.jks \
# storePassword:$PLAY_KEYSTORE_PASSWORD \
# keyAlias:bitwarden \
# keyPassword:$PLAY_KEYSTORE_PASSWORD
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
# - name: Generate beta Play Store APK
# if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
# env:
# PLAY_BETA_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEYSTORE-PASSWORD }}
# PLAY_BETA_KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEY-PASSWORD }}
# run: |
# bundle exec fastlane assemblePlayStoreBetaApk \
# storeFile:app_beta_play-keystore.jks \
# storePassword:$PLAY_BETA_KEYSTORE_PASSWORD \
# keyAlias:bitwarden-beta \
# keyPassword:$PLAY_BETA_KEY_PASSWORD
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-android
secrets: "UPLOAD-KEYSTORE-PASSWORD,UPLOAD-BETA-KEYSTORE-PASSWORD,UPLOAD-BETA-KEY-PASSWORD,PLAY-KEYSTORE-PASSWORD,PLAY-BETA-KEYSTORE-PASSWORD,PLAY-BETA-KEY-PASSWORD"
# - name: Generate debug Play Store APKs
# if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
# run: |
# bundle exec fastlane assembleDebugApks
- name: Retrieve secrets
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
run: |
mkdir -p ${{ github.workspace }}/secrets
mkdir -p ${{ github.workspace }}/app/src/standardBeta
mkdir -p ${{ github.workspace }}/app/src/standardRelease
# - name: Upload to GitHub Artifacts - prod.aab
# if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
# uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
# with:
# name: com.x8bit.bitwarden.aab
# path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab
# if-no-files-found: error
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name app_play-keystore.jks --file ${{ github.workspace }}/keystores/app_play-keystore.jks --output none
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name app_upload-keystore.jks --file ${{ github.workspace }}/keystores/app_upload-keystore.jks --output none
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name play_creds.json --file ${{ github.workspace }}/secrets/play_creds.json --output none
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name app_beta_play-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_play-keystore.jks --output none
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name app_beta_upload-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_upload-keystore.jks --output none
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name google-services.json --file ${{ github.workspace }}/app/src/standardRelease/google-services.json --output none
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name google-services.json --file ${{ github.workspace }}/app/src/standardBeta/google-services.json --output none
# - name: Upload to GitHub Artifacts - beta.aab
# if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
# 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
# if-no-files-found: error
- name: Download Firebase credentials
if: ${{ matrix.variant == 'prod' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
run: |
mkdir -p ${{ github.workspace }}/secrets
# - name: Upload to GitHub Artifacts - prod.apk
# if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
# 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
# if-no-files-found: error
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name app_play_prod_firebase-creds.json --file ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json --output none
# - name: Upload to GitHub Artifacts - beta.apk
# if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
# 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
# if-no-files-found: error
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
# # When building variants other than 'prod'
# - name: Upload to GitHub Artifacts - dev.apk
# if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
# 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
# if-no-files-found: error
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
# - name: Create checksum for release .apk artifact
# if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
# run: |
# sha256sum "app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk" \
# > ./com.x8bit.bitwarden.apk-sha256.txt
- name: Cache Gradle files
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
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: Create checksum for beta .apk artifact
# if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
# run: |
# sha256sum "app/build/outputs/apk/standard/beta/com.x8bit.bitwarden.beta.apk" \
# > ./com.x8bit.bitwarden.beta.apk-sha256.txt
- name: Cache build output
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
# - name: Create checksum for release .aab artifact
# if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
# run: |
# sha256sum "app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab" \
# > ./com.x8bit.bitwarden.aab-sha256.txt
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
# - name: Create checksum for beta .aab artifact
# if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
# run: |
# sha256sum "app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab" \
# > ./com.x8bit.bitwarden.beta.aab-sha256.txt
- name: Update app CI Build info
run: |
./scripts/update_app_ci_build_info.sh \
"$GITHUB_REPOSITORY" \
"$GITHUB_REF_NAME" \
"$GITHUB_SHA" \
"$GITHUB_RUN_ID" \
"$GITHUB_RUN_ATTEMPT"
# - name: Create checksum for Debug .apk artifact
# if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
# run: |
# sha256sum "app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk" \
# > ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
- name: Increment version
env:
VERSION_CODE: ${{ inputs.version-code }}
VERSION_NAME: ${{ inputs.version-name }}
run: |
VERSION_CODE="${VERSION_CODE:-$((11000 + GITHUB_RUN_NUMBER))}"
bundle exec fastlane setBuildVersionInfo \
versionCode:$VERSION_CODE \
versionName:$VERSION_NAME
# - name: Upload to GitHub Artifacts - prod.apk-sha256.txt
# if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
# uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
# with:
# name: com.x8bit.bitwarden.apk-sha256.txt
# path: ./com.x8bit.bitwarden.apk-sha256.txt
# if-no-files-found: error
- name: Generate release Play Store bundle
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' }}
env:
UPLOAD_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-KEYSTORE-PASSWORD }}
run: |
bundle exec fastlane bundlePlayStoreRelease \
storeFile:app_upload-keystore.jks \
storePassword:$UPLOAD_KEYSTORE_PASSWORD \
keyAlias:upload \
keyPassword:$UPLOAD_KEYSTORE_PASSWORD
# - name: Upload to GitHub Artifacts - beta.apk-sha256.txt
# if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
# 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
# if-no-files-found: error
- name: Generate beta Play Store bundle
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
env:
UPLOAD_BETA_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-BETA-KEYSTORE-PASSWORD }}
UPLOAD_BETA_KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-BETA-KEY-PASSWORD }}
run: |
bundle exec fastlane bundlePlayStoreBeta \
storeFile:app_beta_upload-keystore.jks \
storePassword:$UPLOAD_BETA_KEYSTORE_PASSWORD \
keyAlias:bitwarden-beta-upload \
keyPassword:$UPLOAD_BETA_KEY_PASSWORD
# - name: Upload to GitHub Artifacts - prod.aab-sha256.txt
# if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
# uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
# with:
# name: com.x8bit.bitwarden.aab-sha256.txt
# path: ./com.x8bit.bitwarden.aab-sha256.txt
# if-no-files-found: error
- name: Generate release Play Store APK
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
env:
PLAY_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-KEYSTORE-PASSWORD }}
run: |
bundle exec fastlane assemblePlayStoreReleaseApk \
storeFile:app_play-keystore.jks \
storePassword:$PLAY_KEYSTORE_PASSWORD \
keyAlias:bitwarden \
keyPassword:$PLAY_KEYSTORE_PASSWORD
# - name: Upload to GitHub Artifacts - beta.aab-sha256.txt
# if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
# 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
# if-no-files-found: error
- name: Generate beta Play Store APK
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
env:
PLAY_BETA_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEYSTORE-PASSWORD }}
PLAY_BETA_KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEY-PASSWORD }}
run: |
bundle exec fastlane assemblePlayStoreBetaApk \
storeFile:app_beta_play-keystore.jks \
storePassword:$PLAY_BETA_KEYSTORE_PASSWORD \
keyAlias:bitwarden-beta \
keyPassword:$PLAY_BETA_KEY_PASSWORD
# - name: Upload to GitHub Artifacts - debug.apk-sha256.txt
# if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
# 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
# if-no-files-found: error
- name: Generate debug Play Store APKs
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
run: |
bundle exec fastlane assembleDebugApks
# - name: Install Firebase app distribution plugin
# if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
# run: bundle exec fastlane add_plugin firebase_app_distribution
- name: Upload release Play Store .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: com.x8bit.bitwarden.aab
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab
if-no-files-found: error
# - name: Distribute to Firebase - prod.apk
# if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
# env:
# APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
# run: |
# bundle exec fastlane distributeReleasePlayStoreToFirebase \
# actionUrl:$GITHUB_ACTION_RUN_URL \
# service_credentials_file:$APP_PLAY_FIREBASE_CREDS_PATH
- name: Upload beta Play Store .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: com.x8bit.bitwarden.beta.aab
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab
if-no-files-found: error
# - name: Distribute to Firebase - beta.apk
# if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
# env:
# APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
# run: |
# bundle exec fastlane distributeBetaPlayStoreToFirebase \
# actionUrl:$GITHUB_ACTION_RUN_URL \
# service_credentials_file:$APP_PLAY_FIREBASE_CREDS_PATH
- name: Upload release .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: com.x8bit.bitwarden.apk
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk
if-no-files-found: error
# - name: Verify Play Store credentials
# if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
# run: |
# bundle exec fastlane run validate_play_store_json_key
- name: Upload beta .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: com.x8bit.bitwarden.beta.apk
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden.beta.apk
if-no-files-found: error
# When building variants other than 'prod'
- name: Upload debug .apk artifact
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk
if-no-files-found: error
- name: Create checksum for release .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
run: |
sha256sum "app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk" \
> ./com.x8bit.bitwarden.apk-sha256.txt
- name: Create checksum for beta .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
run: |
sha256sum "app/build/outputs/apk/standard/beta/com.x8bit.bitwarden.beta.apk" \
> ./com.x8bit.bitwarden.beta.apk-sha256.txt
- name: Create checksum for release .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
run: |
sha256sum "app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab" \
> ./com.x8bit.bitwarden.aab-sha256.txt
- name: Create checksum for beta .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
run: |
sha256sum "app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab" \
> ./com.x8bit.bitwarden.beta.aab-sha256.txt
- name: Create checksum for Debug .apk artifact
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
run: |
sha256sum "app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk" \
> ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
- name: Upload .apk SHA file for release
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: com.x8bit.bitwarden.apk-sha256.txt
path: ./com.x8bit.bitwarden.apk-sha256.txt
if-no-files-found: error
- name: Upload .apk SHA file for beta
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: com.x8bit.bitwarden.beta.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
if-no-files-found: error
- name: Upload .aab SHA file for release
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: com.x8bit.bitwarden.aab-sha256.txt
path: ./com.x8bit.bitwarden.aab-sha256.txt
if-no-files-found: error
- name: Upload .aab SHA file for beta
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: com.x8bit.bitwarden.beta.aab-sha256.txt
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
if-no-files-found: error
- name: Upload .apk SHA file for debug
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
path: ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
if-no-files-found: error
- name: Install Firebase app distribution plugin
if: ${{ matrix.variant == 'prod' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
run: bundle exec fastlane add_plugin firebase_app_distribution
- name: Publish release artifacts to Firebase
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
env:
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
run: |
bundle exec fastlane distributeReleasePlayStoreToFirebase \
actionUrl:$GITHUB_ACTION_RUN_URL \
service_credentials_file:$APP_PLAY_FIREBASE_CREDS_PATH
- name: Publish beta artifacts to Firebase
if: ${{ (matrix.variant == 'prod' && matrix.artifact == 'apk') && (inputs.distribute-to-firebase || github.event_name == 'push') }}
env:
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
run: |
bundle exec fastlane distributeBetaPlayStoreToFirebase \
actionUrl:$GITHUB_ACTION_RUN_URL \
service_credentials_file:$APP_PLAY_FIREBASE_CREDS_PATH
- name: Verify Play Store credentials
if: ${{ matrix.variant == 'prod' && inputs.publish-to-play-store }}
run: |
bundle exec fastlane run validate_play_store_json_key
- name: Publish Play Store bundle
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && (inputs.publish-to-play-store || github.event_name == 'push') }}
run: |
bundle exec fastlane publishProdToPlayStore
bundle exec fastlane publishBetaToPlayStore
# - name: Publish to Play Store - prod.aab
# if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
# run: |
# bundle exec fastlane publishProdToPlayStore
# bundle exec fastlane publishBetaToPlayStore
publish_fdroid:
name: Publish F-Droid artifacts
needs:
- build
- version
runs-on: ubuntu-24.04
permissions:
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- 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: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
@@ -474,13 +366,14 @@ jobs:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
run: |
mkdir -p ${{ github.workspace }}/keystores
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name app_fdroid-keystore.jks --file ${{ github.workspace }}/keystores/app_fdroid-keystore.jks --output none
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
--name app_beta_fdroid-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_fdroid-keystore.jks --output none
- name: Download Firebase credentials
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
@@ -493,33 +386,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@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
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@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
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: |
@@ -530,23 +398,11 @@ jobs:
"$GITHUB_RUN_ID" \
"$GITHUB_RUN_ATTEMPT"
# Start from 11000 to prevent collisions with mobile build version codes
- name: Increment version
env:
VERSION_CODE: ${{ inputs.version-code }}
VERSION_NAME: ${{ inputs.version-name }}
run: |
VERSION_CODE="${VERSION_CODE:-$((11000 + 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"
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 F-Droid artifacts
env:
FDROID_STORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-KEYSTORE-PASSWORD }}
@@ -557,6 +413,31 @@ jobs:
keyAlias:bitwarden \
keyPassword:$FDROID_STORE_PASSWORD
- name: Truncate F-Droid changelogs
run: |
# F-Droid has a 500-character limit on changelog/"What's New" text
MAX_LENGTH=500
shopt -s nullglob globstar
for file in fastlane/metadata/android/**/changelogs/*.txt; do
char_count=$(python3 -c "import sys; print(len(open(sys.argv[1], encoding='utf-8').read()))" "$file")
if [ "$char_count" -gt "$MAX_LENGTH" ]; then
echo "Truncating $file ($char_count -> $MAX_LENGTH chars)"
python3 - "$file" "$MAX_LENGTH" <<'EOF'
import sys
path, max_len = sys.argv[1], int(sys.argv[2])
text = open(path, encoding='utf-8').read()
# Truncate at a word boundary if possible, leave room for ellipsis
cutoff = max_len - 1 # 1 char for ellipsis "…"
truncated = text[:cutoff]
last_space = truncated.rfind(' ')
if last_space > cutoff - 50: # only break on word if reasonably close to end
truncated = truncated[:last_space]
truncated = truncated.rstrip() + '…'
open(path, 'w', encoding='utf-8').write(truncated)
EOF
fi
done
- name: Generate F-Droid Beta Artifacts
env:
FDROID_BETA_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-BETA-KEYSTORE-PASSWORD }}
@@ -568,8 +449,8 @@ jobs:
keyAlias:bitwarden-beta \
keyPassword:$FDROID_BETA_KEY_PASSWORD
- name: Upload F-Droid .apk artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- name: Upload to GitHub Artifacts - fdroid.apk
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
@@ -580,15 +461,15 @@ jobs:
sha256sum "app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk" \
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
- name: Upload F-Droid SHA file
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- name: Upload to GitHub Artifacts - fdroid.apk-sha256.txt
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 F-Droid Beta .apk artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- name: Upload to GitHub Artifacts - beta.fdroid.apk
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
@@ -599,19 +480,19 @@ jobs:
sha256sum "app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk" \
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
- name: Upload F-Droid Beta SHA file
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- name: Upload to GitHub Artifacts - beta.fdroid.apk-sha256.txt
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
if-no-files-found: error
- name: Install Firebase app distribution plugin
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
run: bundle exec fastlane add_plugin firebase_app_distribution
- name: Publish release F-Droid artifacts to Firebase
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
- name: Distribute to Firebase - fdroid.apk
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
env:
APP_FDROID_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_fdroid_firebase-creds.json
run: |

View File

@@ -2,8 +2,8 @@ name: Cron / Sync Google Privileged Browsers List
on:
schedule:
# Run weekly on Monday at 00:00 UTC
- cron: "0 0 * * 1"
# Run weekly on Sunday at 00:00 UTC
- cron: '0 0 * * 0'
workflow_dispatch:
env:
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: true
@@ -96,4 +96,4 @@ jobs:
--base main \
--head "$BRANCH_NAME" \
--label "automated-pr" \
--label "t:ci"
--label "t:deps"

View File

@@ -4,19 +4,21 @@ run-name: Crowdin Pull - ${{ github.event_name == 'workflow_dispatch' && 'Manual
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * 5"
# Run weekly on Sunday at 00:00 UTC
- cron: '0 0 * * 0'
permissions: {}
jobs:
crowdin-sync:
name: Crowdin Pull - ${{ github.event_name }}
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
contents: read
id-token: write
steps:
- name: Checkout repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
@@ -38,24 +40,26 @@ 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 }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-contents: write # for creating and pushing a new branch
permission-pull-requests: write # for creating pull request
- name: Download translations
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
@@ -69,5 +73,4 @@ jobs:
create_pull_request: true
pull_request_title: "Crowdin Pull"
pull_request_body: ":inbox_tray: New translations received!"
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
gpg_passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
pull_request_labels: "automated-pr, t:misc"

View File

@@ -16,7 +16,7 @@ jobs:
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
@@ -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

View File

@@ -25,7 +25,7 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
persist-credentials: true
@@ -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,14 +180,19 @@ 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"
product_release_notes=$(python3 .github/scripts/jira-get-release-notes/jira_release_notes.py "$_RELEASE_TICKET_ID" "$_JIRA_API_EMAIL" "$_JIRA_API_TOKEN")
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=$(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 [[ -z "$product_release_notes" || $product_release_notes == "Error checking"* ]]; then
echo "::warning::Failed to fetch release notes from Jira. Output: $product_release_notes"
if [[ $script_exit_code -ne 0 || -z "$product_release_notes" ]]; then
echo "Script Output: $product_release_notes"
echo "::warning::Failed to fetch release notes from Jira. Check script logs for more details."
product_release_notes="<insert product release notes here>"
else
echo "✅ Product release notes:"
@@ -285,5 +290,5 @@ jobs:
echo " * :ocean: Previous tag set in the description \"Full Changelog\" link: \`$_LAST_RELEASE_TAG\`"
echo " * :white_check_mark: Description has automated release notes and they match the commits in the release branch"
echo "> [!NOTE]"
echo "> Commits directly pushed to branches without a Pull Request won't appear in the automated release notes."
echo "> Commits directly pushed to branches without a Pull Request won't appear in the automated release notes."
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -18,6 +18,7 @@ jobs:
workflow_name: "publish-github-release-bwa.yml"
credentials_filename: "authenticator_play_store-creds.json"
project_type: android
make_latest: false
check_release_command: >
bundle exec fastlane getLatestPlayStoreVersion package_name:com.bitwarden.authenticator track:production
secrets: inherit

View File

@@ -19,6 +19,7 @@ jobs:
workflow_name: "publish-github-release-bwpm.yml"
credentials_filename: "play_creds.json"
project_type: android
make_latest: true
check_release_command: >
bundle exec fastlane getLatestPlayStoreVersion package_name:com.x8bit.bitwarden track:production
secrets: inherit

View File

@@ -68,20 +68,12 @@ jobs:
steps:
- name: Log inputs to job summary
env:
INPUTS: ${{ toJson(inputs) }}
run: |
{
echo "<details><summary>Job Inputs</summary>"
echo ""
echo '```json'
echo "$INPUTS"
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
uses: bitwarden/android/.github/actions/log-inputs@main
with:
inputs: "${{ toJson(inputs) }}"
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Configure Ruby
@@ -91,7 +83,6 @@ jobs:
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3

View File

@@ -22,7 +22,7 @@ jobs:
actions: write
steps:
- name: Check out repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
persist-credentials: true

Some files were not shown because too many files have changed in this diff Show More