Compare commits

..

169 Commits

Author SHA1 Message Date
renovate[bot]
36bb6cda0d [deps]: Update googleBilling to v9 2026-06-08 03:45:18 +00:00
David Perez
2022507cc0 PM-38618: Feat: Update Accessibility Service disclosure text (#7026) 2026-06-05 21:15:33 +00:00
David Perez
c0caaf9ce9 PM-38513: Bug: Do not emit policies before we have recieved them (#7023) 2026-06-05 18:06:20 +00:00
David Perez
fdeafd7388 PM-38587: Feat: Add accessibility service disclaimer at startup (#7018) 2026-06-04 21:43:09 +00:00
David Perez
9b85e028f7 PM-38534: bug: Update LoginResult to use friendly error message (#7017) 2026-06-04 16:12:21 +00:00
David Perez
0cd9c99fcf BWA-252: bug: Empty string totp codes should not be counted as a totp code (#7015) 2026-06-04 16:12:04 +00:00
David Perez
86cf21602c PM-38280: Fix: Update collection API to V2 (#7014) 2026-06-04 14:06:02 +00:00
Patrick Honkonen
c45ae58b0e [PM-37571] chore: Remove unused isSdkSupported guard (#7011) 2026-06-03 15:03:50 +00:00
Patrick Honkonen
d44212d9bd [PM-38364] fix: Multiply subscription line-item cost by quantity (#7012) 2026-06-03 14:18:56 +00:00
aikido-autofix[bot]
1c90dc242f [AppSec] AI Fix for Template Injection in GitHub Workflows Action (#6784)
Co-authored-by: aikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
2026-06-02 21:33:45 +00:00
David Perez
22ad8ec78f Chore: Add gradle lockfiles (#7008) 2026-06-02 20:52:53 +00:00
Patrick Honkonen
b5a6ab0ac0 [PM-37571] feat: Map Passport and License to SDK types (#7009) 2026-06-02 20:29:20 +00:00
David Perez
ecbceb8baf PM-37887: Feat: Update the Key Connector vault unlock to use the SDK (#6999) 2026-06-02 20:20:10 +00:00
David Perez
bd45d5f56a PM-38479: Feat: Update the RemovePasswordScreen UI (#7010) 2026-06-02 20:02:04 +00:00
David Perez
051b8b53d1 PM-38358: Chore: Remove user key (#7004) 2026-06-02 19:15:47 +00:00
bw-ghapp[bot]
9c8b4891a1 Crowdin Pull (#7001)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-06-02 18:10:30 +00:00
David Perez
5fd32ab391 Deps: Update the Gradle Wrapper to v9.5.1 (#7006) 2026-06-02 15:46:02 +00:00
bw-ghapp[bot]
57a14f2785 Update SDK to 3.0.0-7198-7bca9fca (#6983)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-06-02 15:01:01 +00:00
Álison Fernandes
c18dd58c41 [PM-38411] tech-debt: Update deprecated argument in actions/create-github-app-token (#7003) 2026-06-01 18:42:45 +00:00
David Perez
227359bc26 PM-26577: Feat: Support multiple schemes for Duo, WebAuthn, and SSO callbacks (#6339) 2026-06-01 16:09:16 +00:00
github-actions[bot]
fa219f6963 Update Google privileged browsers list (#7000)
Co-authored-by: GitHub Actions Bot <actions@github.com>
2026-06-01 13:57:13 +00:00
David Perez
a3bcff9463 PM-37911: Feat: Update Organization model (#6960) 2026-05-29 18:07:10 +00:00
Patrick Honkonen
aca9949874 [PM-37920] fix: Show Storage cost row when additional storage is present (#6997) 2026-05-29 17:26:21 +00:00
David Perez
217bfc1097 Chore: Move dispatcher for sdk functions into SDK sources (#6995) 2026-05-29 16:58:10 +00:00
David Perez
a94978c8e2 Deps: Update Firebase BOM to v34.14.0 (#6996) 2026-05-29 16:34:23 +00:00
David Perez
0a920d5800 Deps: Update the Compose BOM to v2026.05.01 (#6998) 2026-05-29 16:34:10 +00:00
Patrick Honkonen
09f0f5b9bf [PM-38263] fix: Reference invoices in past due subscription description (#6994) 2026-05-29 15:19:28 +00:00
ifernandezdiaz
b57fb9c437 [QA-1826] Adding missing testTags for Authenticator/PM apps (#6993) 2026-05-29 13:37:12 +00:00
aj-rosado
124ce37bc3 [PM-38118] fix: Support Firefox updated toolbar in accessibility autofill (#6986) 2026-05-29 13:35:50 +00:00
Patrick Honkonen
e7e2c26bef [PM-36970] fix: Correct Update Payment status description (#6988) 2026-05-28 21:37:06 +00:00
Patrick Honkonen
c89a52e5d2 [PM-38279] fix: Hide Cancel Premium action for Update payment status (#6989) 2026-05-28 21:14:09 +00:00
David Perez
fb955e903f Deps: Update the protobuf library to v4.35.0 (#6985) 2026-05-28 20:44:14 +00:00
Patrick Honkonen
230c8f769d [PM-37181] feat: Surface Expired subscription substate (#6982) 2026-05-28 20:37:50 +00:00
David Perez
8661dfaf2f PM-38285: Feat: Filter unconfirmed organizations from the app (#6987) 2026-05-28 19:48:07 +00:00
David Perez
a872db128b Chore: Remove the Manager type from OrganizationType (#6984) 2026-05-28 19:47:41 +00:00
Patrick Honkonen
40604d0ec0 [PM-37804] fix: Drop redundant Stripe checkout confirmation on Upgrade Now (#6980) 2026-05-28 19:08:00 +00:00
bw-ghapp[bot]
0f4b3fb9f0 Update SDK to 3.0.0-7126-025e5d85 (#6976)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-05-28 10:38:48 +00:00
David Perez
a0edef99e6 PM-38140 Feat: SDK policy filters (#6979) 2026-05-27 20:59:53 +00:00
Patrick Honkonen
cc6fcecc5b [PM-37232] fix: Hide upgrade CTAs while a Premium upgrade is pending (#6978) 2026-05-27 20:53:06 +00:00
David Perez
58408bcd77 PM-38130: Feat: Parse new organizations and policies properties from sync response (#6977) 2026-05-27 09:52:12 +00:00
David Perez
3732672ab4 PM-37985: Feat: Use PolicyView in the app (#6966) 2026-05-26 16:34:10 +00:00
renovate[bot]
b53d3fbd29 [deps]: Update com.google.devtools.ksp to v2.3.8 (#6971)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 15:08:30 +00:00
bw-ghapp[bot]
f0f1f91c62 Update SDK to 3.0.0-7068-a635e32d (#6967)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-05-26 14:48:57 +00:00
Patrick Honkonen
ecc47005fb [PM-37282] feat: Add Upgrade to Premium CTA to File Send dialog (#6968) 2026-05-26 14:40:15 +00:00
bw-ghapp[bot]
3fc5965a05 Crowdin Pull (#6969)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-05-26 14:25:24 +00:00
Patrick Honkonen
8cd52a716f [PM-37916] feat: Add titleExtraLarge typography and apply to Premium header (#6962) 2026-05-22 20:00:40 +00:00
David Perez
7b94daf3ae Chore: Update Policy Types to conform to known values (#6965) 2026-05-22 19:42:23 +00:00
Patrick Honkonen
a5f7288208 [PM-37916] chore: Align Premium subscription card line items with Web (#6961) 2026-05-22 18:45:57 +00:00
David Perez
c6a439a791 Deps: Update to Junit v6.1.0 (#6964) 2026-05-22 17:51:54 +00:00
bw-ghapp[bot]
34ce0edc03 Update SDK to 3.0.0-7038-1a2acacb (#6956)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-05-22 15:11:49 +00:00
Patrick Honkonen
f4507384e9 llm: Add interface KDoc rule to implementing-android-code skill (#6963) 2026-05-22 13:32:25 +00:00
Mick Letofsky
0005ed7a2f PM-36952 - Improve code review workflow with added triggers (#6933) 2026-05-22 12:56:03 +00:00
renovate[bot]
bf14dfbf40 [deps]: Lock file maintenance (#6904)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Álison Fernandes <vvolkgang@users.noreply.github.com>
2026-05-21 22:35:54 +00:00
renovate[bot]
fc2786d809 [deps]: Update gh minor (#6926)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Álison Fernandes <vvolkgang@users.noreply.github.com>
2026-05-21 22:23:57 +00:00
David Perez
c6bef627a2 Deps: Update Coroutines to v1.11.0 (#6959) 2026-05-21 19:42:39 +00:00
aj-rosado
afe296c3db [PM-37254] feat: Add fill-assist-targeting-rules feature flag (#6952) 2026-05-21 16:28:27 +00:00
David Perez
e4fb873d34 PM-34085: Chore: Remove the Authenticator Sync backwards compatibility (#6928) 2026-05-21 14:45:23 +00:00
Patrick Honkonen
50960456c5 [PM-37691] feat: Surface pending cancellation status on Premium plan view (#6957) 2026-05-21 12:18:54 +00:00
David Perez
ebc3bd8081 PM-37568: Feat: Remove feature flags (#6955) 2026-05-20 22:28:30 +00:00
Patrick Honkonen
8f72c10f8e [PM-37804] feat: Confirm before leaving the app to Stripe checkout (#6958) 2026-05-20 22:28:05 +00:00
Patrick Honkonen
c6746fb369 [PM-37814] feat: Add debug flag to disable self-host premium check (#6954) 2026-05-20 21:44:07 +00:00
Patrick Honkonen
31011b5789 [PM-37289] fix: Refresh archive row after premium upgrade (#6949) 2026-05-20 19:19:58 +00:00
Patrick Honkonen
a9048c6393 [PM-37810] fix: Update cancel premium confirmation dialog (#6953) 2026-05-20 19:03:13 +00:00
Patrick Honkonen
9e27b950e8 [PM-37284] fix: Show Upgraded to Premium card across all Send view states (#6947) 2026-05-20 18:00:32 +00:00
bw-ghapp[bot]
8940a2c490 Update SDK to 3.0.0-6963-1256a563 (#6921)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-05-20 16:55:41 +00:00
Patrick Honkonen
2eab66ecd3 [PM-37076] fix: Manage Plan launches web vault subscription URL (#6944) 2026-05-20 16:39:08 +00:00
Patrick Honkonen
fce814d6bd [PM-36886] fix: Gate premium upgrade flow on self-hosted environments (#6939) 2026-05-20 15:29:31 +00:00
Patrick Honkonen
8002794c59 [PM-37294] fix: Announce external-link affordance on premium upgrade CTAs (#6951) 2026-05-20 15:15:20 +00:00
David Perez
304c32e1a4 PM-37705: Feat: Hide Send navigation when DISABLE_SEND policy is enabled (#6945) 2026-05-20 14:40:47 +00:00
Patrick Honkonen
cc210a5764 [PM-37335] fix: Route attachments upgrade CTA through in-app plan modal (#6946) 2026-05-20 01:17:24 +00:00
Patrick Honkonen
5f3f9d186c [PM-36969] feat: Surface subscription substate to premium gates (#6931)
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-05-19 22:17:12 +00:00
Álison Fernandes
6cf7227973 [PM-35435] ci: Stop applying Change Type labels based on changed files (#6925) 2026-05-19 21:02:39 +00:00
aj-rosado
e949dd710a [PM-33982] feat: Add device management screen (#6754) 2026-05-19 19:40:13 +00:00
David Perez
6ccb9d9f3e Bug: Update BitwardenDatePickerDialog to match current designs (#6943) 2026-05-19 16:41:21 +00:00
David Perez
83a9d35e32 PM-37690: feat: Update copy for adding a new item (#6940) 2026-05-18 21:30:11 +00:00
David Perez
5f415e2deb chore: Remove background event interface from action (#6941) 2026-05-18 21:29:06 +00:00
Patrick Honkonen
1f8280f76d [PM-37465] fix: Gate Plan row and Upgraded card on personal Premium (#6930) 2026-05-18 20:48:48 +00:00
David Perez
bc93d7c311 PM-37573: feat: Add DatePicker functionality to VaultAddEditScreen (#6937) 2026-05-18 17:47:32 +00:00
Patrick Honkonen
fcc1eebab1 [PM-32806] feat: Add Passport vault, listing, and search surfaces (#6929) 2026-05-18 17:27:00 +00:00
Patrick Honkonen
e2ab1f5663 Update MobilePremiumUpgrade feature flag key to lowercase (#6934) 2026-05-18 16:33:28 +00:00
David Perez
11bdb07bde PM-37573: Feat: Create DatePickerDialog (#6924) 2026-05-18 15:00:14 +00:00
bw-ghapp[bot]
4108811349 Crowdin Pull (#6932)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-05-18 13:55:25 +00:00
David Perez
38a5e6fe55 PM-36508: Chore: Local network access permission (#6916) 2026-05-15 19:30:02 +00:00
David Perez
2f6a36ce1a bug: Update Passport and License date formats in VaultItemScreen (#6927) 2026-05-15 19:03:42 +00:00
Patrick Honkonen
484d326e14 [PM-32806] feat: Add Add/Edit support for Passport item type (#6923) 2026-05-15 16:14:13 +00:00
renovate[bot]
cc06636276 [deps]: Update fastlane to v2.233.1 (#6902)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Álison Fernandes <vvolkgang@users.noreply.github.com>
2026-05-14 21:56:06 +00:00
Patrick Honkonen
d390272c0c [PM-32806] feat: View Passport item type (#6853) 2026-05-14 19:28:31 +00:00
David Perez
92845c6a4d Bug: Fix CipherType crash (#6922) 2026-05-14 19:27:06 +00:00
bw-ghapp[bot]
f002c2c070 deps: Update SDK to 3.0.0-6822-fe351b43 (#6912)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-05-14 13:08:25 +00:00
renovate[bot]
4f2a364eec [deps]: Update org.sonarqube to v7.3.0.8198 (#6903)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Álison Fernandes <vvolkgang@users.noreply.github.com>
2026-05-13 20:45:01 +00:00
David Perez
e4f030d0e3 Bug: Support translations for Cookie Acquisition error (#6917) 2026-05-13 20:05:53 +00:00
Patrick Honkonen
8fe23ad275 [PM-32808] feat: Add Driver's License vault, listing, and search surfaces (#6909) 2026-05-13 19:22:02 +00:00
Patrick Honkonen
ecc4fa6dea [PM-32808] feat: Add Add/Edit support for Driver's License item type (#6908) 2026-05-13 14:20:03 +00:00
ifernandezdiaz
f6d56fa234 [QA-1859] Updating testTags in AddEditSendItemView (#6911) 2026-05-12 17:46:52 +00:00
David Perez
07a40c2c04 Chore: Remove Retrofit dependency from app module (#6896) 2026-05-12 16:57:57 +00:00
Patrick Honkonen
68f88b651e [PM-32808] feat: View License item type (#6852) 2026-05-12 15:42:44 +00:00
bw-ghapp[bot]
dbb27c1cd0 Update SDK to 3.0.0-6774-0a0f5faf (#6895)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-05-12 14:27:26 +00:00
github-actions[bot]
73f1e2c645 chore: Update Google privileged browsers list (#6899)
Co-authored-by: GitHub Actions Bot <actions@github.com>
2026-05-11 18:07:45 +00:00
Fabian Freund
4e527f3097 [PM-37042] chore: Add eu.weblibre.gecko.alpha to privileged apps (#6905) 2026-05-11 18:07:10 +00:00
David Perez
7e553867d0 build: Allow logging and access to the debug menu for beta builds (#6874) 2026-05-11 14:59:16 +00:00
Gabriel Brand
33fcbad97f Use string resource for button label (#6897) 2026-05-11 14:09:10 +00:00
bw-ghapp[bot]
24a9aea1c9 Crowdin Pull (#6900)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-05-11 14:00:05 +00: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
674 changed files with 53623 additions and 7742 deletions

View File

@@ -58,22 +58,27 @@ User Request (UI Action)
### Workflow Skills
> **Quick start**: Use the `android-architect` agent (or `/plan-android-work <task>`) to refine requirements and plan,
> then the `android-implementer` agent (or `/work-on-android <task>`) for 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.
Planning: 12 | Implementation: 37 | Review & PR: 810
## Skills & Commands
1. `refining-android-requirements` - Gap analysis and structured spec from any input source
2. `planning-android-implementation` - Architecture design and phased task breakdown
3. `implementing-android-code` - Patterns, gotchas, and templates for writing code
4. `testing-android-code` - Test patterns and templates for verifying code
5. `build-test-verify` - Build, test, lint, and deploy commands
6. `perform-android-preflight-checklist` - Quality gate before committing
7. `committing-android-changes` - Commit message format and pre-commit workflow
8. `reviewing-changes` - Android-specific MVVM/Compose code review checklists (invoked by `/review-android`)
9. `/review-android` - Full review workflow: PR context gathering → Android checklist → output
10. `creating-android-pull-request` - PR creation workflow and templates
| 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" |
| 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 |
---
@@ -94,7 +99,7 @@ Planning: 12 | Implementation: 37 | Review & PR: 810
- **Formatter**: Android Studio with `bitwarden-style.xml` | **Line Limit**: 100 chars | **Detekt**: Enabled
- **Naming**: `camelCase` (vars/fns), `PascalCase` (classes), `SCREAMING_SNAKE_CASE` (constants), `...Impl` (implementations)
- **KDoc**: Required for all public APIs
- **String Resources**: Add new strings to `:ui` module (`ui/src/main/res/values/strings.xml`). Use typographic quotes/apostrophes (`"` `"` `'`) not escaped ASCII (`\"` `\'`)
- **String Resources**: Add new strings to `:ui` module (`ui/src/main/res/values/strings.xml`). Use typographic quotes/apostrophes (`"` `"` `'`) not escaped ASCII (`\"` `\'`). Name each resource from its own text content in `snake_case` — not with generic suffixes (`_message`, `_title`). E.g., `one_or_more_email_addresses_are_incorrect`, not `invalid_email_addresses_message`.
> For complete style rules (imports, formatting, documentation, Compose conventions), see `docs/STYLE_AND_BEST_PRACTICES.md`.
@@ -125,8 +130,8 @@ In addition to the Key Principles above, follow these rules:
- **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 `perform-android-preflight-checklist` skill, then `committing-android-changes` skill for message format
- **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 `creating-android-pull-request` skill for PR workflow and templates
- **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

@@ -1,162 +0,0 @@
---
name: android-architect
description: "Plans, architects, and refines implementation details for Android features in the Bitwarden Android codebase before any code is written. Use at the START of any new feature, significant change, Jira ticket, or when requirements need clarification and gap analysis. Proactively suggest when the user describes a feature, shares a ticket, or asks to plan Android work. Produces a structured, phased implementation plan ready for the android-implementer agent."
model: opus
color: green
tools: Read, Glob, Grep, Write, Edit, Agent, Skill(refining-android-requirements), Skill(planning-android-implementation), Skill(plan-android-work), mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_issue, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_issue_comments, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_issues, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_confluence, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_confluence_page
---
You are the Android Architect — an elite software architect and senior Android engineer with deep mastery of the Bitwarden Android codebase. You operate as a planning and design authority, responsible for transforming vague requirements, tickets, or feature ideas into precise, actionable, phased implementation plans before any code is written.
Your primary workflow is `Skill(plan-android-work)`, which encompasses two sequential phases:
1. **`Skill(refining-android-requirements)`** — Gap analysis, ambiguity resolution, and structured specification
2. **`Skill(planning-android-implementation)`** — Architecture design, pattern selection, and phased task breakdown
---
## Core Responsibilities
### Phase 1: Requirements Refinement (`Skill(refining-android-requirements)`)
Before any planning begins, you must fully understand what is being built. You will:
1. **Parse and Extract Intent**: Identify the core feature request, affected modules (`:app`, `:authenticator`, shared), and user-facing vs. internal scope.
2. **Identify Gaps**: Actively look for missing information:
- Ambiguous acceptance criteria
- Undefined edge cases (empty states, error states, loading states, network failure)
- Missing security or zero-knowledge implications
- Unclear UI/UX behavior
- Unspecified API contracts or SDK interactions
- Missing test coverage expectations
3. **Produce Structured Specification**: Output a refined spec with:
- Feature summary (1-2 sentences)
- Affected modules and components
- Functional requirements (numbered list)
- Non-functional requirements (performance, security, accessibility)
- Open questions that MUST be resolved before implementation (ask the user if needed)
- Assumptions being made (document clearly)
### Phase 2: Implementation Planning (`Skill(planning-android-implementation)`)
With a refined spec, produce a comprehensive implementation plan:
1. **Architecture Design**:
- Identify which ViewModel(s), Repository(ies), and data sources are involved
- Define new interfaces and their `...Impl` counterparts
- Map UDF flow: UI Actions → ViewModel → Repository → SDK/Network/Disk → DataState
- Identify required State, Action, and Event sealed class members
- Note any new Hilt modules or injection changes required
2. **Pattern Selection**:
- Identify existing patterns in the codebase that apply
- Flag any cases where a new pattern might be needed (rare — prefer established patterns)
- Reference relevant existing files as implementation guides
3. **Phased Task Breakdown**: Organize work into logical phases:
- Phase 1: Data layer (repositories, data sources, models)
- Phase 2: Domain/business logic (ViewModel, state management)
- Phase 3: UI layer (Compose screens, previews, navigation)
- Phase 4: Tests (unit tests per component, integration where needed)
- Phase 5: Polish (strings, accessibility, edge cases)
4. **Dependency and Risk Analysis**:
- Identify blocking dependencies between tasks
- Flag high-risk areas (security, crypto, SDK interactions)
- Note areas requiring special care (e.g., DataState streaming, coroutine context)
5. **File Manifest**: List all files to be created or modified with brief descriptions.
---
## Bitwarden Android Expertise
You have deep knowledge of this codebase and must apply it in every plan:
### Architecture Constraints
- **No exceptions from data layer**: All suspending functions must return `Result<T>` or sealed classes
- **State hoisting**: All behavior-affecting state lives in ViewModel's state — never in composables
- **Interface-based DI**: Every implementation has an interface counterpart with Hilt injection
- **UDF strictly enforced**: State flows down, actions flow up — no bidirectional data flow
- **Internal actions for coroutines**: Never update state directly inside `launch` blocks; map results to `Internal` actions first
### Zero-Knowledge Security Rules (NON-NEGOTIABLE)
- Never transmit unencrypted vault data or master passwords to the server
- All encryption via Bitwarden SDK — never implement custom crypto
- Use scoped SDK sources (`ScopedVaultSdkSource`) to prevent cross-user context leakage
- On logout, all sensitive data cleared via `UserLogoutManager.logout()`
- Store sensitive data only via Android Keystore or SDK-encrypted storage
### Code Style Requirements
- 100-character line limit
- `camelCase` for vars/functions, `PascalCase` for classes, `SCREAMING_SNAKE_CASE` for constants
- `...Impl` suffix for all implementations
- KDoc required for all public APIs
- Test constants at bottom of file — NO companion objects in tests
- String resources in `:ui` module (`ui/src/main/res/values/strings.xml`) using typographic quotes
---
## Output Format
Your output must always be a structured planning document with these sections:
```
# Implementation Plan: [Feature Name]
## Refined Requirements
### Summary
### Functional Requirements
### Non-Functional Requirements
### Assumptions
### Open Questions (if any — request answers from user before proceeding)
## Architecture Design
### Affected Components
### New Interfaces & Implementations
### UDF Flow Diagram (text-based)
### State / Action / Event Definitions
## Phased Implementation Plan
### Phase 1: [Name] — [Estimated scope]
- Task 1.1: ...
- Task 1.2: ...
### Phase 2: ...
...
## File Manifest
### New Files
### Modified Files
## Risk & Dependency Notes
## Handoff Notes for Implementer
```
---
## Behavioral Guidelines
### DO
- Explore the codebase (via sub-agents) to understand existing patterns before designing — never assume file locations or implementations
- Ask clarifying questions BEFORE producing a plan if critical information is missing
- Reference specific existing files and patterns as implementation guides in your plan
- Apply security considerations proactively — flag any zero-knowledge implications
- Produce plans detailed enough that an implementer needs no additional context
- Note when existing patterns should be reused vs. when genuinely new patterns are warranted
### DON'T
- Write implementation code — your job ends where the implementer's begins
- Assume requirements are complete — always perform gap analysis
- Invent new architectural patterns when established ones exist
- Ignore security implications of any feature touching vault data, credentials, or keys
- Produce vague tasks — every task must be concrete and actionable
- Skip the requirements refinement phase even for seemingly simple requests
### Codebase Exploration Protocol
Before designing any architecture, deploy exploration sub-agents to:
- Locate relevant existing ViewModels, Repositories, and data sources
- Understand current patterns for similar features
- Identify reusable components and shared infrastructure
- Check for existing test patterns to replicate

View File

@@ -1,58 +0,0 @@
---
name: android-implementer
description: "Autonomously implements features, fixes bugs, and completes development tasks on the Bitwarden Android project. Drives the full /work-on-android lifecycle (implement, test, build, preflight, commit) with self-review at each phase. Use when the user wants end-to-end implementation without manual phase approvals. Proactively suggest after /plan-android-work completes or when planning output is ready for implementation."
model: opus
color: green
tools: Bash, Read, Edit, Write, Glob, Grep, LSP, Agent, Skill(implementing-android-code), Skill(testing-android-code), Skill(build-test-verify), Skill(perform-android-preflight-checklist), Skill(committing-android-changes), Skill(work-on-android)
---
You are an elite Android implementation engineer specialized in the Bitwarden Android codebase. Your role is to autonomously drive implementation from start to finish, acting as both the implementer and the quality reviewer at each phase.
## First Action: Invoke `/work-on-android`
**Immediately invoke the `work-on-android` skill using the Skill tool.** This is your primary workflow — it defines the phases, invokes the correct sub-skills, and structures the entire implementation lifecycle. Do not manually orchestrate individual skills; let `/work-on-android` drive the phase sequence.
Your added value on top of `/work-on-android` is autonomy: where the skill asks for user confirmation between phases, you provide that confirmation yourself by applying the self-review protocol below. Do not wait for human approval between phases — evaluate your own output, refine if necessary, and advance.
## Self-Review Protocol
At each phase transition where `/work-on-android` would normally ask the user to confirm, apply this review instead:
```
--- Phase Review: [Phase Name] ---
Status: APPROVED / NEEDS REFINEMENT
Findings: [brief assessment]
Action: [Proceeding to next phase / Iterating on: X]
---
```
If status is NEEDS REFINEMENT, iterate up to 3 times before proceeding with the best available output and noting remaining concerns.
**Review criteria by phase:**
- **Implementation**: Follows skill guidance and CLAUDE.md anti-patterns list?
- **Testing**: Covers happy path, error cases, and edge cases?
- **Build & Verify**: All tests pass? No compilation errors or warnings?
- **Preflight**: Would this pass code review by a senior engineer?
- **Commit**: Message clear, properly formatted, and accurate?
## Decision-Making Framework
- **When uncertain about a pattern**: Search the codebase for existing examples. Follow what exists rather than inventing.
- **When finding multiple valid approaches**: Choose the one most consistent with nearby code in the same module.
- **When discovering scope creep**: Note it as a follow-up item and stay focused on the original task.
- **When tests fail**: Diagnose the root cause, fix it, and re-run. Don't skip failing tests.
- **When a phase produces subpar output**: Iterate. Don't advance with known deficiencies unless you've exhausted reasonable refinement attempts.
## Communication Style
- Be concise and direct in phase transition summaries
- Provide detailed technical reasoning only when making non-obvious decisions
- Flag any genuine blockers that require human input clearly and specifically
- At completion, provide a summary of what was implemented, what was tested, and any follow-up items
## Critical Rules
1. **Minimize user interruptions**: Only escalate for genuine ambiguities that codebase context cannot resolve.
2. **Never skip testing**: Every implementation phase must have corresponding tests.
3. **Never invent new patterns**: Use established codebase patterns. Search for examples first.
4. **Never leave the codebase in a broken state**: If you can't complete a phase cleanly, revert and explain why.

View File

@@ -35,13 +35,13 @@ Invoke `Skill(build-test-verify)` to run tests, lint, and detekt. Ensure everyth
### Phase 4: Self-Review
Invoke `Skill(perform-android-preflight-checklist)` to perform a quality gate check on all changes. Address any issues found.
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(committing-android-changes)` to stage and commit the changes with a properly formatted commit message.
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.
@@ -56,7 +56,7 @@ Launch a subagent with the `/bitwarden-code-review:code-review-local` command to
### Phase 7: Pull Request
Prompt the user to invoke `Skill(creating-android-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.
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

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,81 +0,0 @@
---
name: committing-android-changes
version: 0.1.0
description: Git commit conventions and workflow for Bitwarden Android. Use when committing code, writing commit messages, or preparing changes for commit. Triggered by "commit", "git commit", "commit message", "prepare commit", "stage changes".
---
# Git Commit Conventions
## Commit Message Format
```
[PM-XXXXX] <type>: <imperative summary>
<optional body explaining why, not what>
```
### Rules
1. **Ticket prefix**: Always include `[PM-XXXXX]` matching the Jira ticket
2. **Type keyword**: Include a conventional commit type after the ticket prefix (see table below)
3. **Imperative mood**: "Add feature" not "Added feature" or "Adds feature"
4. **Short summary**: Under 72 characters for the first line
5. **Body**: Explain the "why" not the "what" — the diff shows the what
### Type Keywords
Invoke the `labeling-android-changes` skill for the full type keyword table and selection guidance.
### Example
```
[PM-12345] feat: Add biometric unlock timeout configuration
Users reported confusion about when biometric prompts appear.
This adds a configurable timeout setting to the security preferences.
```
### Followup Commits
Only the first commit on a branch needs the full format (ticket prefix, type keyword, body). Subsequent commits — whether addressing review feedback, making intermediate changes, or iterating locally — can use a short, descriptive summary with no prefix or body required.
```
Update error handling in login flow
```
---
## Pre-Commit Checklist
Run the `perform-android-preflight-checklist` skill for the full quality gate. At minimum, before staging and committing:
1. **Run affected module tests** (use `build-test-verify` skill for correct commands)
2. **Check lint**: `./gradlew detekt` on changed modules
3. **Review staged changes**: `git diff --staged` — verify no unintended modifications
4. **Verify no secrets**: No API keys, tokens, passwords, or `.env` files staged
5. **Verify no generated files**: No build outputs, `.idea/` changes, or generated code
---
## What NOT to Commit
- `.env` files or `user.properties` with real tokens
- Credential files or signing keystores
- Build outputs (`build/`, `*.apk`, `*.aab`)
- IDE-specific files (`.idea/` changes, `*.iml`)
- Large binary files
---
## Staging Best Practices
- **Stage specific files** by name rather than `git add -A` or `git add .`
- Put each file path on its own line for readability:
```bash
git add \
path/to/first/File.kt \
path/to/second/File.kt \
path/to/third/File.kt
```
- Review each file being staged to avoid accidentally including sensitive data
- Use `git status` (without `-uall` flag) to see the working tree state

View File

@@ -1,79 +0,0 @@
---
name: creating-android-pull-request
version: 0.1.0
description: Pull request creation workflow for Bitwarden Android. Use when creating PRs, writing PR descriptions, or preparing branches for review. Triggered by "create PR", "pull request", "open PR", "gh pr create", "PR description".
---
# Create Pull Request
## PR Title Format
```
[PM-XXXXX] <type>: <short imperative summary>
```
**Examples:**
- `[PM-12345] feat: Add autofill support for passkeys`
- `[PM-12345] fix: Resolve crash during vault sync`
- `[PM-12345] refactor: Simplify authentication flow`
**Rules:**
- Include Jira ticket prefix
- Keep under 70 characters total
- Use imperative mood in the summary
**Type keywords** (triggers automatic `t:` label via CI):
Invoke the `labeling-android-changes` skill for the full type keyword table and selection guidance.
---
## PR Body Template
**IMPORTANT:** Always follow the repo's PR template at `.github/PULL_REQUEST_TEMPLATE.md`. Delete the Screenshots section entirely if there are no UI changes.
---
## Pre-PR Checklist
1. **All tests pass**: Run `./gradlew app:testStandardDebugUnitTest` (and other affected modules)
2. **Lint clean**: Run `./gradlew detekt`
3. **Self-review done**: Use `perform-android-preflight-checklist` skill
4. **No unintended changes**: Check `git diff origin/main...HEAD` for unexpected files
5. **Branch up to date**: Rebase on `main` if needed
---
## Creating the PR
```bash
# Ensure branch is pushed
git push -u origin <branch-name>
# Create PR as draft by default (body follows .github/PULL_REQUEST_TEMPLATE.md)
gh pr create --draft --title "[PM-XXXXX] feat: Short summary" --body "<fill in from PR template>"
```
**Default to draft PRs.** Only create a non-draft (ready for review) PR if the user explicitly requests it.
---
## AI Review Label
Before running `gh pr create`, **always** use the `AskUserQuestion` tool to ask whether to add an AI review label:
- **Question**: "Would you like to add an AI review label to this PR?"
- **Options**: `ai-review-vnext`, `ai-review`, `No label`
If the user selects a label, include it via the `--label` flag:
```bash
gh pr create --draft --label "ai-review-vnext" --title "..." --body "..."
```
---
## Base Branch
- Default target: `main`
- Check with team if targeting a feature branch instead

View File

@@ -1,6 +1,6 @@
---
name: implementing-android-code
version: 0.1.2
version: 0.1.4
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.
---
@@ -236,6 +236,44 @@ object ExampleRepositoryModule {
- ✅ Data sources return `Result<T>`, repositories return domain sealed classes
- ✅ Use `StateFlow` for continuously observed data
**KDoc on Interfaces vs. Implementations**
KDoc on an interface member describes the **contract** — what the caller can rely on. It must not describe **how** the implementation fulfills that contract. Implementation details (which data source is consulted, what is cached, what is logged, which manager is delegated to, ordering of internal calls, etc.) belong on the `...Impl` member — and only when the *why* is non-obvious from the code itself.
This applies to every interface in the codebase: repositories, managers, data sources, validators, and UI-layer interfaces alike. The rule is the same one CLAUDE.md states for comments in general — describe the contract or the non-obvious *why*, never the *what* a well-named identifier already conveys.
**Wrong** — interface KDoc leaks the implementation:
```kotlin
interface ExampleRepository {
/**
* Fetches data by reading from [ExampleDiskSource] first, falling back to
* [ExampleService] on cache miss and writing the network result back to disk.
*/
suspend fun fetchData(id: String): ExampleResult
}
```
**Right** — interface KDoc describes the contract; implementation details (if needed at all) live on the override:
```kotlin
interface ExampleRepository {
/** Returns the [ExampleData] for [id], or an error result on failure. */
suspend fun fetchData(id: String): ExampleResult
}
class ExampleRepositoryImpl(...) : ExampleRepository {
// No KDoc needed — the disk-then-network pattern is visible in the code.
override suspend fun fetchData(id: String): ExampleResult { ... }
}
```
Red flags that an interface KDoc has drifted into implementation territory:
- Names a concrete collaborator (`...DiskSource`, `...Service`, `...Manager`, `...Impl`)
- Describes ordering of internal calls ("first ... then ...", "falls back to ...")
- Mentions caching, retries, logging, or threading behavior that callers don't depend on
- Restates the method signature in prose ("Suspends until X returns Y")
If callers genuinely depend on a behavior (e.g., "this method is safe to call before vault unlock", "result is cached for the session"), that *is* part of the contract and belongs on the interface. The test: would a different valid implementation be free to change this? If yes, it's implementation detail — strip it.
---
### E. UI Components
@@ -437,6 +475,42 @@ val FIXED_CLOCK = Clock.fixed(
---
### I. Kotlin Style Rules
Project-specific style conventions enforced in code review. These supplement (not replace) `docs/STYLE_AND_BEST_PRACTICES.md`.
**`when` branches with wrapped right-hand side require curly braces.**
When a `when` branch's expression is too long to fit on the same line as the arrow and is wrapped to the next line, wrap the body in `{ }`. A bare `->` followed by an indented expression on its own line is rejected in review.
**Wrong** — wrapped body without braces:
```kotlin
when (type) {
VaultItemCipherType.LOGIN -> VaultAddEditState.ViewState.Content.ItemType.Login()
VaultItemCipherType.BANK_ACCOUNT ->
VaultAddEditState.ViewState.Content.ItemType.BankAccount()
VaultItemCipherType.DRIVERS_LICENSE ->
VaultAddEditState.ViewState.Content.ItemType.DriversLicense()
}
```
**Right** — wrapped body with braces:
```kotlin
when (type) {
VaultItemCipherType.LOGIN -> VaultAddEditState.ViewState.Content.ItemType.Login()
VaultItemCipherType.BANK_ACCOUNT -> {
VaultAddEditState.ViewState.Content.ItemType.BankAccount()
}
VaultItemCipherType.DRIVERS_LICENSE -> {
VaultAddEditState.ViewState.Content.ItemType.DriversLicense()
}
}
```
Single-line branches (body fits on the same line as `->`) do **not** need braces.
---
## Bitwarden-Specific Anti-Patterns
**General anti-patterns are documented in CLAUDE.md.** This section covers violations specific to Bitwarden's State-Action-Event, navigation, and data layer patterns:
@@ -468,6 +542,9 @@ val FIXED_CLOCK = Clock.fixed(
**NEVER call `Instant.now()` or `DateTime.now()` directly**
- Inject `Clock` via Hilt, use `clock.instant()` for testability
**NEVER put implementation details in interface KDoc**
- Interface KDoc describes the contract; concrete collaborators, call ordering, caching, and fallback behavior belong on the `...Impl` override (and only when the *why* is non-obvious). See section D for examples.
---
## Quick Reference
@@ -478,4 +555,3 @@ For build, test, and codebase discovery commands, use the **`build-test-verify`*
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,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

@@ -1,40 +0,0 @@
---
name: labeling-android-changes
version: 0.1.0
description: Conventional commit type keywords for PR titles and commit messages. Use when determining the change type for commits or PRs. Triggered by "what type", "label", "change type", "conventional commit", "t: label".
---
# Labeling Changes
PR titles and commit messages must include a conventional commit type keyword. This keyword drives automatic `t:` label assignment via CI (`.github/workflows/sdlc-label-pr.yml`).
## Format
The type keyword appears after the Jira ticket prefix:
```
[PM-XXXXX] <type>: <imperative summary>
```
## Type Keywords
| Type | Label | Use for |
|------|-------|---------|
| `feat` | `t:feature` | New features or functionality |
| `fix` | `t:bug` | Bug fixes |
| `refactor` | `t:tech-debt` | Code restructuring without behavior change |
| `chore` | `t:tech-debt` | Maintenance, cleanup, minor tweaks |
| `test` | `t:tech-debt` | Adding or updating tests |
| `perf` | `t:tech-debt` | Performance improvements |
| `docs` | `t:docs` | Documentation changes |
| `ci` / `build` | `t:ci` | CI/CD and build system changes |
| `deps` | `t:deps` | Dependency updates |
| `llm` | `t:llm` | LLM/Claude configuration changes |
| `breaking` | `t:breaking-change` | Breaking changes requiring migration |
| `misc` | `t:misc` | Changes that do not fit other categories |
## Selecting a Type
Infer the type from the task description and changes made. **If the type cannot be confidently determined, ask the user.**
The CI labeling script matches `<type>:` or `<type>(` in the lowercased PR title, so the keyword must be followed by a colon or parenthesis. CI also accepts additional aliases (e.g., `revert`, `bugfix`, `cleanup`). See `.github/label-pr.json` for the full mapping.

View File

@@ -1,37 +0,0 @@
---
name: perform-android-preflight-checklist
version: 0.1.0
description: Quality gate checklist to run before committing or creating a PR. Use when finishing implementation, checking work quality, or preparing to commit. Triggered by "self review", "check my work", "ready to commit", "done implementing", "review checklist", "quality check".
---
# Self-Review Checklist
Run through this checklist before committing or opening a PR.
## Tests
- [ ] Tests pass with correct flavor: `./gradlew app:testStandardDebugUnitTest`
- [ ] New code has corresponding test coverage
- [ ] Tests for affected modules also pass (`:core:test`, `:data:test`, etc.)
## Code Quality
- [ ] Lint/detekt clean: `./gradlew detekt`
- [ ] No unintended file changes (`git diff` review)
- [ ] KDoc on all new public APIs
- [ ] No TODO comments left behind (or they reference a ticket)
## Security
- [ ] No plaintext keys, tokens, or secrets in code
- [ ] User input validated before processing
- [ ] Sensitive data uses encrypted storage patterns
- [ ] No logging of sensitive data (passwords, keys, tokens)
## Bitwarden Patterns
- [ ] String resources in `:ui` module with typographic quotes
- [ ] Navigation route is `@Serializable` and registered in graph
- [ ] New implementations have Hilt `@Binds` or `@Provides` in a module
- [ ] ViewModel extends `BaseViewModel<S, E, A>` with proper state persistence
- [ ] Async results mapped through internal actions (not direct state updates)
## Files
- [ ] No accidental `.idea/`, build output, or generated files staged
- [ ] No credential files or `.env` files included

View File

@@ -68,7 +68,8 @@ Load reference files only when needed for specific questions:
- **Security questions (comprehensive)** → `docs/ARCHITECTURE.md#security` (full zero-knowledge architecture)
- **Testing questions** → `reference/testing-patterns.md` (unit tests, mocking, null safety)
- **UI questions** → `reference/ui-patterns.md` (Compose patterns, theming)
- **Style questions** → `docs/STYLE_AND_BEST_PRACTICES.md`
- **Style questions (project-specific)** → `reference/style-patterns.md` (Kotlin rules enforced in review)
- **Style questions (general)** → `docs/STYLE_AND_BEST_PRACTICES.md`
## Core Principles

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

@@ -245,8 +245,6 @@ fun `test exception`() {
Common testing mistakes in Bitwarden. **For complete details and examples:** See `references/critical-gotchas.md`
> **⛔ STOP — `@Suppress("MaxLineLength")`**: Do NOT add this annotation unless the `fun` declaration line **actually exceeds 100 characters**. Count the characters first. Do not copy it from nearby tests. Detekt will tell you if it's needed — when in doubt, leave it off.
**Core Patterns:**
- **assertCoroutineThrows + runTest** - Never wrap in `runTest`; call directly
- **Static mock cleanup** - Always `unmockkStatic()` in `@After`

View File

@@ -12,12 +12,12 @@ runs:
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
with:
bundler-cache: true
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: "temurin"
java-version: ${{ inputs.java-version }}

26
.github/label-pr.json vendored
View File

@@ -27,32 +27,6 @@
],
"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/"
]
}
}

11
.github/renovate.json vendored
View File

@@ -3,6 +3,7 @@
"extends": [
"github>bitwarden/renovate-config"
],
"labels": ["t:deps"],
"ignoreDeps": ["com.bitwarden:sdk-android"],
"enabledManagers": [
"github-actions",
@@ -32,16 +33,6 @@
"/org.jetbrains.kotlin.*/",
"/com.google.devtools.ksp/"
]
},
{
"groupName": "bundler minor",
"matchUpdateTypes": [
"minor",
"patch"
],
"matchManagers": [
"bundler"
]
}
]
}

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

View File

@@ -1,11 +1,16 @@
#!/usr/bin/env python3
# Requires Python 3.9+
"""Fetch release notes from a Jira issue."""
import sys
import argparse
import base64
import json
import requests
import sys
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen
SCRIPT_NAME = "jira_release_notes.py"
SCRIPT_NAME = Path(__file__).name
def extract_text_from_content(content):
if isinstance(content, list):
@@ -63,32 +68,43 @@ def parse_release_notes(response_json):
print(f"[{SCRIPT_NAME}] Error parsing release notes: {str(e)}", file=sys.stderr)
return ''
def main():
if len(sys.argv) != 4:
print(f"Usage: {sys.argv[0]} <issue_id> <jira_email> <jira_api_token>")
sys.exit(1)
def parse_args():
parser = argparse.ArgumentParser(
description=__doc__,
)
parser.add_argument("issue_id", help="RELEASE issue ID to fetch release notes from")
parser.add_argument("jira_cloud_id", help="Atlassian Cloud ID - Can be retrieved from the `tenant_info` endpoint, e.g.: `https://<my-site-name>.atlassian.net/_edge/tenant_info`")
parser.add_argument("jira_email", help="Email used to create the API token")
parser.add_argument("jira_api_token", help="Jira API token - Generate one at: https://id.atlassian.com/manage-profile/security/api-tokens")
return parser.parse_args()
jira_issue_id = sys.argv[1]
jira_email = sys.argv[2]
jira_api_token = sys.argv[3]
jira_base_url = "https://bitwarden.atlassian.net"
def main():
args = parse_args()
jira_issue_id = args.issue_id
jira_cloud_id = args.jira_cloud_id
jira_email = args.jira_email
jira_api_token = args.jira_api_token
jira_base_url = "https://api.atlassian.com/ex/jira"
auth = base64.b64encode(f"{jira_email}:{jira_api_token}".encode()).decode()
headers = {
"Authorization": f"Basic {auth}",
"Content-Type": "application/json"
}
response = requests.get(
f"{jira_base_url}/rest/api/3/issue/{jira_issue_id}",
headers=headers
request = Request(
f"{jira_base_url}/{jira_cloud_id}/rest/api/3/issue/{jira_issue_id}",
headers={
"Authorization": f"Basic {auth}",
"Content-Type": "application/json"
}
)
if response.status_code != 200:
print(f"[{SCRIPT_NAME}] Error fetching Jira issue ({jira_issue_id}). Status code: {response.status_code}. Msg: {response.text}", file=sys.stderr)
try:
with urlopen(request) as response:
response_json = json.loads(response.read().decode())
except HTTPError as error:
error_text = error.read().decode().replace(jira_cloud_id, "[REDACTED]")
print(f"[{SCRIPT_NAME}] Error fetching Jira issue ({jira_issue_id}). Status code: {error.code}. Msg: {error_text}", file=sys.stderr)
sys.exit(1)
release_notes = parse_release_notes(response.json())
release_notes = parse_release_notes(response_json)
print(release_notes)
if __name__ == "__main__":

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

View File

@@ -79,7 +79,7 @@ jobs:
- name: Check out repository
if: ${{ !inputs.skip_checkout || false }}
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
@@ -167,7 +167,7 @@ jobs:
echo '```' >> "$GITHUB_STEP_SUMMARY"
- name: Upload version info artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: version-info
path: version_info.json

View File

@@ -63,7 +63,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -176,7 +176,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.aab
if: ${{ matrix.variant == 'aab' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.bitwarden.authenticator.aab
path: authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab
@@ -184,7 +184,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.apk
if: ${{ matrix.variant == 'apk' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.bitwarden.authenticator.apk
path: authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk
@@ -204,7 +204,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
if: ${{ matrix.variant == 'apk' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: authenticator-android-apk-sha256.txt
path: ./authenticator-android-apk-sha256.txt
@@ -212,7 +212,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
if: ${{ matrix.variant == 'aab' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: authenticator-android-aab-sha256.txt
path: ./authenticator-android-aab-sha256.txt

View File

@@ -48,7 +48,7 @@ jobs:
inputs: "${{ toJson(inputs) }}"
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -65,7 +65,7 @@ jobs:
run: ./gradlew :testharness:assembleDebug
- name: Upload Test Harness APK
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.bitwarden.testharness.dev-debug.apk
path: testharness/build/outputs/apk/debug/com.bitwarden.testharness.dev.apk
@@ -77,7 +77,7 @@ jobs:
> ./com.bitwarden.testharness.dev.apk-sha256.txt
- name: Upload Test Harness SHA file
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.bitwarden.testharness.dev.apk-sha256.txt
path: ./com.bitwarden.testharness.dev.apk-sha256.txt

View File

@@ -65,7 +65,7 @@ jobs:
artifact: ["apk", "aab"]
steps:
- name: Check out repo
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -192,7 +192,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.aab
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.x8bit.bitwarden.aab
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab
@@ -200,7 +200,7 @@ jobs:
- name: Upload to GitHub Artifacts - beta.aab
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.x8bit.bitwarden.beta.aab
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab
@@ -208,7 +208,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.apk
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.x8bit.bitwarden.apk
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk
@@ -216,7 +216,7 @@ jobs:
- name: Upload to GitHub Artifacts - beta.apk
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.x8bit.bitwarden.beta.apk
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden.beta.apk
@@ -225,7 +225,7 @@ jobs:
# When building variants other than 'prod'
- name: Upload to GitHub Artifacts - dev.apk
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk
@@ -263,7 +263,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.x8bit.bitwarden.apk-sha256.txt
path: ./com.x8bit.bitwarden.apk-sha256.txt
@@ -271,7 +271,7 @@ jobs:
- name: Upload to GitHub Artifacts - beta.apk-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.x8bit.bitwarden.beta.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
@@ -279,7 +279,7 @@ jobs:
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.x8bit.bitwarden.aab-sha256.txt
path: ./com.x8bit.bitwarden.aab-sha256.txt
@@ -287,7 +287,7 @@ jobs:
- name: Upload to GitHub Artifacts - beta.aab-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.x8bit.bitwarden.beta.aab-sha256.txt
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
@@ -295,7 +295,7 @@ jobs:
- name: Upload to GitHub Artifacts - debug.apk-sha256.txt
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
path: ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
@@ -343,7 +343,7 @@ jobs:
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -424,7 +424,7 @@ jobs:
keyPassword:$FDROID_BETA_KEY_PASSWORD
- name: Upload to GitHub Artifacts - fdroid.apk
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.x8bit.bitwarden-fdroid.apk
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk
@@ -436,14 +436,14 @@ jobs:
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
- name: Upload to GitHub Artifacts - fdroid.apk-sha256.txt
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.x8bit.bitwarden-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
if-no-files-found: error
- name: Upload to GitHub Artifacts - beta.fdroid.apk
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.x8bit.bitwarden.beta-fdroid.apk
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk
@@ -455,7 +455,7 @@ jobs:
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
- name: Upload to GitHub Artifacts - beta.fdroid.apk-sha256.txt
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt

View File

@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: true

View File

@@ -18,7 +18,7 @@ jobs:
id-token: write
steps:
- name: Checkout repo
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -40,26 +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@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
client-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
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.CROWDIN-API-TOKEN }}
_CROWDIN_PROJECT_ID: "269690"
with:
config: crowdin.yml
@@ -74,5 +74,3 @@ jobs:
pull_request_title: "Crowdin Pull"
pull_request_body: ":inbox_tray: New translations received!"
pull_request_labels: "automated-pr, t:misc"
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
gpg_passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}

View File

@@ -16,7 +16,7 @@ jobs:
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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,13 +180,14 @@ jobs:
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
_RELEASE_TICKET_ID: ${{ inputs.release-ticket-id }}
_JIRA_CLOUD_ID: ${{ steps.get-kv-secrets.outputs.JIRA-CLOUD-ID }}
_JIRA_API_EMAIL: ${{ steps.get-kv-secrets.outputs.JIRA-API-EMAIL }}
_JIRA_API_TOKEN: ${{ steps.get-kv-secrets.outputs.JIRA-API-TOKEN }}
run: |
echo "Getting product release notes..."
# capture output and exit code so this step continues even if we can't retrieve release notes.
script_exit_code=0
product_release_notes=$(python .github/scripts/jira-get-release-notes/jira_release_notes.py "$_RELEASE_TICKET_ID" "$_JIRA_API_EMAIL" "$_JIRA_API_TOKEN") || script_exit_code=$?
product_release_notes=$(python3 .github/scripts/jira-get-release-notes/jira_release_notes.py "$_RELEASE_TICKET_ID" "$_JIRA_CLOUD_ID" "$_JIRA_API_EMAIL" "$_JIRA_API_TOKEN") || script_exit_code=$?
echo "--------------------------------"
if [[ $script_exit_code -ne 0 || -z "$product_release_notes" ]]; then

View File

@@ -73,11 +73,11 @@ jobs:
inputs: "${{ toJson(inputs) }}"
- name: Check out repo
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
with:
bundler-cache: true
@@ -169,8 +169,9 @@ jobs:
- name: Enable Publish Github Release Workflow
env:
PRODUCT: ${{ inputs.product }}
DRY_RUN: ${{ inputs.dry-run }}
run: |
if ${{ inputs.dry-run }} ; then
if $DRY_RUN ; then
gh workflow view publish-github-release-bwpm.yml
exit 0
fi

View File

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

View File

@@ -2,7 +2,7 @@ name: Code Review
on:
pull_request:
types: [opened, synchronize, reopened]
types: [labeled, opened, ready_for_review, reopened, synchronize]
permissions: {}

View File

@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

View File

@@ -34,7 +34,7 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

View File

@@ -53,17 +53,17 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
client-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-pull-requests: write
permission-actions: read
permission-contents: write
- name: Check out repo
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0
@@ -204,7 +204,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

View File

@@ -54,7 +54,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -99,7 +99,7 @@ jobs:
disable_search: true
- name: Upload test reports
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: test-reports-${{ matrix.group }}

9
.mcp.json Normal file
View File

@@ -0,0 +1,9 @@
{
"mcpServers": {
"android-device": {
"type": "stdio",
"command": "bash",
"args": ["-c", "cd .claude/mcp/android-device-server && npm install --silent >/dev/null 2>&1 && npm run build >/dev/null 2>&1 && exec node build/index.js"]
}
}
}

18
Gemfile
View File

@@ -1,21 +1,21 @@
source "https://rubygems.org"
source 'https://rubygems.org'
ruby File.read(".ruby-version").strip
gem 'fastlane'
gem 'time'
gem 'fastlane', '2.233.1'
gem 'time', '0.4.2'
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
# Since ruby 3.4.0 these are not included in the standard library
gem 'abbrev'
gem 'logger'
gem 'mutex_m'
gem 'csv'
gem 'abbrev', '0.1.2'
gem 'logger', '1.7.0'
gem 'mutex_m', '0.3.0'
gem 'csv', '3.3.5'
# Since ruby 3.4.1 these are not included in the standard library
gem 'nkf'
gem 'nkf', '0.2.0'
# Starting with Ruby 3.5.0, these are not included in the standard library
gem 'ostruct'
gem 'ostruct', '0.6.3'

View File

@@ -8,8 +8,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1237.0)
aws-sdk-core (3.244.0)
aws-partitions (1.1249.0)
aws-sdk-core (3.247.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -17,18 +17,19 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (1.125.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.219.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-s3 (1.222.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
bigdecimal (4.1.1)
base64 (0.2.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -72,14 +73,16 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.229.0)
fastlane (2.233.1)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
base64 (~> 0.2.0)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
@@ -90,21 +93,24 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-env (>= 1.6.0, <= 2.1.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
@@ -117,48 +123,50 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-plugin-firebase_app_distribution (0.10.1)
google-apis-firebaseappdistribution_v1 (~> 0.3.0)
google-apis-firebaseappdistribution_v1alpha (~> 0.2.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
fastlane-plugin-firebase_app_distribution (1.0.0)
fastlane (>= 2.232.0)
google-apis-firebaseappdistribution_v1 (>= 0.9.0)
google-apis-firebaseappdistribution_v1alpha (>= 0.12.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
google-apis-androidpublisher_v3 (0.100.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-firebaseappdistribution_v1 (0.3.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-firebaseappdistribution_v1alpha (0.2.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-firebaseappdistribution_v1 (0.19.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-firebaseappdistribution_v1alpha (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-iamcredentials_v1 (0.27.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.62.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.47.0)
google-cloud-storage (1.60.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
googleauth (~> 1.9)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
@@ -169,13 +177,13 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.19.3)
json (2.19.5)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.20.1)
multi_json (1.21.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
@@ -186,7 +194,7 @@ GEM
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.5)
rake (13.3.1)
rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
@@ -205,7 +213,6 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@@ -235,15 +242,15 @@ PLATFORMS
ruby
DEPENDENCIES
abbrev
csv
fastlane
abbrev (= 0.1.2)
csv (= 3.3.5)
fastlane (= 2.233.1)
fastlane-plugin-firebase_app_distribution
logger
mutex_m
nkf
ostruct
time
logger (= 1.7.0)
mutex_m (= 0.3.0)
nkf (= 0.2.0)
ostruct (= 0.6.3)
time (= 0.4.2)
RUBY VERSION
ruby 3.4.2p28

View File

@@ -13,7 +13,7 @@ configure<LibraryExtension> {
defaultConfig {
minSdk {
version = release(libs.versions.minSdk.get().toInt())
version = release(libs.versions.minSdkBwa.get().toInt())
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")

View File

@@ -121,7 +121,7 @@ configure<ApplicationExtension> {
"proguard-rules.pro",
)
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false")
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "true")
buildConfigField(type = "boolean", name = "HAS_LOGS_ENABLED", value = "false")
}
release {
@@ -285,8 +285,6 @@ dependencies {
implementation(libs.kotlinx.serialization)
implementation(platform(libs.square.okhttp.bom))
implementation(libs.square.okhttp)
implementation(platform(libs.square.retrofit.bom))
implementation(libs.square.retrofit)
implementation(libs.timber)
// For now we are restricted to running Compose tests for debug builds only
@@ -294,10 +292,11 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling)
// Standard-specific flavor dependencies
standardImplementation(libs.google.firebase.cloud.messaging)
standardImplementation(platform(libs.google.firebase.bom))
standardImplementation(libs.google.firebase.crashlytics)
standardImplementation(libs.google.billing)
standardImplementation(platform(libs.google.firebase.bom))
standardImplementation(libs.google.firebase.cloud.messaging)
standardImplementation(libs.google.firebase.crashlytics)
standardImplementation(libs.google.mlkit.text.recognition)
standardImplementation(libs.google.play.review)
// Pull in test fixtures from other modules

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<!-- Trust pre-installed CAs -->
<certificates src="system" />
<!-- Additionally trust user added CAs -->
<certificates
src="user"
tools:ignore="AcceptsUserCertificates" />
</trust-anchors>
</base-config>
<!-- A lot of TLS certificates point to http:// URLs for CRL and OCSP checking,
so we need to allow cleartext traffic on them -->
<domain-config cleartextTrafficPermitted="true">
<!-- CRL Distribution Servers -->
<domain includeSubdomains="true">c.lencr.org</domain>
<domain includeSubdomains="true">c.pki.goog</domain>
<!-- OCSP Responder Servers -->
<domain includeSubdomains="true">o.pki.goog</domain>
<domain includeSubdomains="true">ocsp.sectigo.com</domain>
</domain-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">bitwarden.com</domain>
<domain includeSubdomains="true">bitwarden.eu</domain>
<domain includeSubdomains="true">bitwarden.pw</domain>
<trust-anchors>
<!-- Only trust pre-installed CAs for Bitwarden domains and all subdomains -->
<certificates src="system" />
<!-- Additionally trust user added CAs -->
<certificates src="user" />
</trust-anchors>
</domain-config>
</network-security-config>

View File

@@ -0,0 +1,27 @@
package com.bitwarden.ui.platform.feature.cardscanner.util
import androidx.camera.core.ImageProxy
import com.bitwarden.annotation.OmitFromCoverage
/**
* No-op [CardTextAnalyzer] for the F-Droid build flavor.
*
* Google ML Kit is not permitted in F-Droid releases, so this stub replaces the
* standard analyzer at build time. The Scan Card UI is hidden via
* `BuildInfoManager.isFdroid`; this implementation exists solely to satisfy the
* flavor-uniform construction path used by `LocalManagerProvider`. The
* `cardDataParser` argument is unused, retained so the constructor signature
* matches the standard flavor and call sites remain identical.
*/
@OmitFromCoverage
@Suppress("UnusedParameter")
class CardTextAnalyzerImpl(
cardDataParser: CardDataParser,
) : CardTextAnalyzer {
override lateinit var onCardScanned: (CardScanData) -> Unit
override fun analyze(image: ImageProxy) {
image.close()
}
}

View File

@@ -9,6 +9,7 @@
android:name="android.hardware.nfc"
android:required="false" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.NFC" />
@@ -169,6 +170,7 @@
<data android:scheme="https" />
<data android:host="bitwarden.com" />
<data android:host="bitwarden.eu" />
<data android:host="bitwarden.pw" />
<data android:pathPattern="/duo-callback" />
<data android:pathPattern="/sso-callback" />
<data android:pathPattern="/webauthn-callback" />

View File

@@ -28,6 +28,22 @@
]
}
},
{
"type": "android",
"info": {
"package_name": "eu.weblibre.gecko.alpha",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "BB:2A:97:F5:61:53:35:C9:E5:7C:86:6F:1C:30:ED:4F:D7:D7:BD:DC:BC:BC:06:68:FE:93:A5:79:17:3D:3D:2D"
},
{
"build": "release",
"cert_fingerprint_sha256": "8F:52:6E:1E:53:D6:BD:4D:FB:F4:F4:B9:3C:2A:91:EC:B5:CB:8D:A5:E1:4A:D9:4C:25:70:E1:E3:C7:13:52:7F"
}
]
}
},
{
"type": "android",
"info": {

View File

@@ -580,6 +580,18 @@
]
}
},
{
"type": "android",
"info": {
"package_name": "com.talonsec.intune.talon",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "09:0C:4C:E6:20:65:9B:04:00:35:8F:C8:3F:DC:9F:02:D2:AB:77:C3:7F:4C:80:14:30:8E:FF:96:BA:54:49:55"
}
]
}
},
{
"type": "android",
"info": {
@@ -633,6 +645,10 @@
"info": {
"package_name": "com.heytap.browser",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "B2:9A:A0:BB:DC:9F:D9:DE:F5:5D:C5:6E:A7:D7:45:76:D5:84:6C:BC:F5:E5:AB:D3:05:E2:D9:31:9E:4F:42:AE"
},
{
"build": "release",
"cert_fingerprint_sha256": "AF:F8:A7:49:CF:0E:7D:75:44:65:D0:FB:FA:7B:8D:0C:64:5E:22:5C:10:C6:E2:32:AD:A0:D9:74:88:36:B8:E5"
@@ -644,6 +660,18 @@
]
}
},
{
"type": "android",
"info": {
"package_name": "com.oplus.credential",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "E4:98:02:40:95:84:CE:53:15:2A:90:00:82:0A:51:E4:FA:8A:72:3B:7B:CC:26:3E:33:52:40:AC:F1:00:BF:9E"
}
]
}
},
{
"type": "android",
"info": {
@@ -828,6 +856,22 @@
]
}
},
{
"type": "android",
"info": {
"package_name": "ai.perplexity.comet",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "89:58:A4:05:40:1F:69:F5:B0:FB:54:44:24:74:6C:40:DE:C3:0C:09:1F:40:1F:95:1F:61:3C:48:35:C3:E5:EC"
},
{
"build": "userdebug",
"cert_fingerprint_sha256": "68:75:3A:54:59:93:C1:34:D3:BD:A3:72:2A:30:53:BF:4D:48:AD:23:63:2C:4E:27:8B:B3:BF:C1:FB:F6:52:8C"
}
]
}
},
{
"type": "android",
"info": {

View File

@@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.core.app.ActivityCompat
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@@ -25,7 +26,10 @@ import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.platform.util.setHorizonOSAppLayout
import com.bitwarden.ui.platform.util.setupEdgeToEdge
import com.bitwarden.ui.platform.util.validate
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManager
@@ -40,6 +44,8 @@ import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.navigateToCooki
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.debugMenuDestination
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.localNetworkAccessDestination
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.navigateToLocalNetworkAccess
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavigationRoute
import com.x8bit.bitwarden.ui.platform.feature.rootnav.rootNavDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
@@ -92,6 +98,22 @@ class MainActivity : AppCompatActivity() {
mainViewModel.trySendAction(MainAction.PremiumCheckoutResult(it))
}
private val stripePortalLauncher = AuthTabIntent.registerActivityResultLauncher(this) {
mainViewModel.trySendAction(MainAction.StripePortalResult(it))
}
private val authTabLaunchers by lazy {
AuthTabLaunchers(
duo = duoLauncher,
sso = ssoLauncher,
webAuthn = webAuthnLauncher,
cookie = cookieLauncher,
premiumCheckout = premiumCheckoutLauncher,
stripePortal = stripePortalLauncher,
)
}
@Suppress("LongMethod")
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
var shouldShowSplashScreen = true
@@ -114,13 +136,7 @@ class MainActivity : AppCompatActivity() {
updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed)
LocalManagerProvider(
featureFlagsState = state.featureFlagsState,
authTabLaunchers = AuthTabLaunchers(
duo = duoLauncher,
sso = ssoLauncher,
webAuthn = webAuthnLauncher,
cookie = cookieLauncher,
premiumCheckout = premiumCheckoutLauncher,
),
authTabLaunchers = authTabLaunchers,
) {
ObserveScreenDataEffect(
onDataUpdate = remember(mainViewModel) {
@@ -131,6 +147,14 @@ class MainActivity : AppCompatActivity() {
theme = state.theme,
dynamicColor = state.isDynamicColorsEnabled,
) {
MainActivityDialogs(
dialogState = state.dialogState,
onAccessibilityDisclaimerDismiss = {
mainViewModel.trySendAction(
MainAction.DismissAccessibilityDisclaimerDialog,
)
},
)
NavHost(
navController = navController,
startDestination = RootNavigationRoute,
@@ -150,6 +174,10 @@ class MainActivity : AppCompatActivity() {
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
localNetworkAccessDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
}
}
}
@@ -212,6 +240,36 @@ class MainActivity : AppCompatActivity() {
.takeIf { it }
?: super.dispatchKeyEvent(event)
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
// resize only one time at the start
if (!mainViewModel.stateFlow.value.hasResizeBeenRequested) {
setHorizonOSAppLayout {
mainViewModel.trySendAction(MainAction.Internal.ResizeHasBeenRequested)
}
}
}
@Composable
private fun MainActivityDialogs(
dialogState: MainState.DialogState?,
onAccessibilityDisclaimerDismiss: () -> Unit,
) {
when (dialogState) {
MainState.DialogState.AccessibilityDisclosure -> {
BitwardenBasicDialog(
title = stringResource(id = BitwardenString.accessibility_service_disclosure),
message = stringResource(
id = BitwardenString.accessibility_disclosure_start_up_text,
),
onDismissRequest = onAccessibilityDisclaimerDismiss,
)
}
null -> Unit
}
}
@Composable
private fun SetupEventsEffect(navController: NavController) {
EventsEffect(viewModel = mainViewModel) { event ->
@@ -224,6 +282,9 @@ class MainActivity : AppCompatActivity() {
MainEvent.Recreate -> handleRecreate()
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
MainEvent.NavigateToCookieAcquisition -> navController.navigateToCookieAcquisition()
MainEvent.NavigateToLocalNetworkAccess -> {
navController.navigateToLocalNetworkAccess()
}
is MainEvent.UpdateAppLocale -> {
AppCompatDelegate.setApplicationLocales(

View File

@@ -27,6 +27,7 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.billing.util.getPremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
@@ -36,11 +37,13 @@ import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManage
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.isAddTotpLoginItemFromAuthenticator
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavViewModel
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
@@ -79,6 +82,7 @@ class MainViewModel @Inject constructor(
accessibilitySelectionManager: AccessibilitySelectionManager,
autofillSelectionManager: AutofillSelectionManager,
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
networkPermissionManager: NetworkPermissionManager,
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
@@ -97,6 +101,8 @@ class MainViewModel @Inject constructor(
theme = settingsRepository.appTheme,
isScreenCaptureAllowed = settingsRepository.isScreenCaptureAllowed,
isDynamicColorsEnabled = settingsRepository.isDynamicColorsEnabled,
hasResizeBeenRequested = false,
dialogState = null,
),
) {
private var specialCircumstance: SpecialCircumstance?
@@ -147,6 +153,12 @@ class MainViewModel @Inject constructor(
.onEach(::trySendAction)
.launchIn(viewModelScope)
settingsRepository
.hasShownAccessibilityDisclaimerFlow
.map { MainAction.Internal.HasShownAccessibilityDisclaimerUpdate(it) }
.onEach(::trySendAction)
.launchIn(viewModelScope)
merge(
authRepository
.userStateFlow
@@ -166,6 +178,13 @@ class MainViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
networkPermissionManager
.isLocalNetworkAccessRequiredStateFlow
.filter { it }
.map { MainAction.Internal.LocalNetworkAccessRequired }
.onEach(::sendAction)
.launchIn(viewModelScope)
cookieAcquisitionRequestManager
.cookieAcquisitionRequestFlow
.filterNotNull()
@@ -198,7 +217,12 @@ class MainViewModel @Inject constructor(
is MainAction.SsoResult -> handleSsoResult(action)
is MainAction.WebAuthnResult -> handleWebAuthnResult(action)
is MainAction.CookieAcquisitionResult -> handleCookieAcquisitionResult(action)
is MainAction.PremiumCheckoutResult -> handlePremiumCheckoutResult()
is MainAction.PremiumCheckoutResult -> handlePremiumCheckoutResult(action)
is MainAction.StripePortalResult -> handleStripePortalResult()
is MainAction.DismissAccessibilityDisclaimerDialog -> {
handleDismissAccessibilityDisclaimerDialog()
}
is MainAction.Internal -> handleInternalAction(action)
}
}
@@ -221,6 +245,22 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
is MainAction.Internal.DynamicColorsUpdate -> handleDynamicColorsUpdate(action)
is MainAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
is MainAction.Internal.LocalNetworkAccessRequired -> handleLocalNetworkAccessRequired()
is MainAction.Internal.ResizeHasBeenRequested -> handleResizeHasBeenRequested()
is MainAction.Internal.HasShownAccessibilityDisclaimerUpdate -> {
handleHasShownAccessibilityDisclaimerUpdate(action)
}
}
}
private fun handleHasShownAccessibilityDisclaimerUpdate(
action: MainAction.Internal.HasShownAccessibilityDisclaimerUpdate,
) {
mutableStateFlow.update {
it.copy(
dialogState = MainState.DialogState.AccessibilityDisclosure
.takeUnless { action.hasBeenShown },
)
}
}
@@ -248,9 +288,18 @@ class MainViewModel @Inject constructor(
)
}
private fun handlePremiumCheckoutResult() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.PremiumCheckoutResult
private fun handlePremiumCheckoutResult(action: MainAction.PremiumCheckoutResult) {
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.PremiumCheckout(
callbackResult = action.authResult.getPremiumCheckoutCallbackResult(),
)
}
private fun handleStripePortalResult() {
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.StripePortal
}
private fun handleDismissAccessibilityDisclaimerDialog() {
settingsRepository.accessibilityDisclaimerHasBeenShown()
}
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
@@ -300,6 +349,14 @@ class MainViewModel @Inject constructor(
sendEvent(MainEvent.NavigateToCookieAcquisition)
}
private fun handleLocalNetworkAccessRequired() {
sendEvent(MainEvent.NavigateToLocalNetworkAccess)
}
private fun handleResizeHasBeenRequested() {
mutableStateFlow.update { it.copy(hasResizeBeenRequested = true) }
}
private fun handleFirstIntentReceived(action: MainAction.ReceiveFirstIntent) {
handleIntent(
intent = action.intent,
@@ -404,7 +461,9 @@ class MainViewModel @Inject constructor(
hasPremiumCheckoutCallback -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.PremiumCheckoutResult
SpecialCircumstance.PremiumCheckout(
callbackResult = intent.data.getPremiumCheckoutCallbackResult(),
)
}
hasGeneratorShortcut -> {
@@ -527,12 +586,26 @@ data class MainState(
val theme: AppTheme,
val isScreenCaptureAllowed: Boolean,
val isDynamicColorsEnabled: Boolean,
val hasResizeBeenRequested: Boolean,
val dialogState: DialogState?,
) : Parcelable {
/**
* Contains all feature flags that are available to the UI.
*/
val featureFlagsState: FeatureFlagsState
get() = FeatureFlagsState
/**
* Representation of all dialogs displayed from the [MainActivity].
*/
@Parcelize
sealed class DialogState : Parcelable {
/**
* Displays an accessibility disclosure to users explaining how we utilize the
* AccessibilityService.
*/
data object AccessibilityDisclosure : DialogState()
}
}
/**
@@ -568,6 +641,14 @@ sealed class MainAction {
val authResult: AuthTabIntent.AuthResult,
) : MainAction()
/**
* Receive the result from the Stripe customer portal flow. The AuthTab does not return a
* payload — closing the tab is the only signal that the user is back in the app.
*/
data class StripePortalResult(
val authResult: AuthTabIntent.AuthResult,
) : MainAction()
/**
* Receive first Intent by the application.
*/
@@ -594,6 +675,11 @@ sealed class MainAction {
*/
data class AppSpecificLanguageUpdate(val appLanguage: AppLanguage) : MainAction()
/**
* Received if the user dismisses the accessibility disclaimer dialog.
*/
data object DismissAccessibilityDisclaimerDialog : MainAction()
/**
* Actions for internal use by the ViewModel.
*/
@@ -644,6 +730,21 @@ sealed class MainAction {
* should proceed.
*/
data object CookieAcquisitionReady : Internal()
/**
* Indicates that the local network access is required.
*/
data object LocalNetworkAccessRequired : Internal()
/**
* Indicates that resize has been requested on the Activity
*/
data object ResizeHasBeenRequested : Internal()
/**
* Indicates that the accessibility disclaimer has been displayed.
*/
data class HasShownAccessibilityDisclaimerUpdate(val hasBeenShown: Boolean) : Internal()
}
}
@@ -678,6 +779,11 @@ sealed class MainEvent {
*/
data object NavigateToCookieAcquisition : MainEvent()
/**
* Navigate to the local network access screen.
*/
data object NavigateToLocalNetworkAccess : MainEvent()
/**
* Indicates that the app language has been updated.
*/

View File

@@ -114,16 +114,6 @@ interface AuthDiskSource : AppIdProvider {
invalidUnlockAttempts: Int?,
)
/**
* Retrieves a user key using a [userId].
*/
fun getUserKey(userId: String): String?
/**
* Stores a user key using a [userId].
*/
fun storeUserKey(userId: String, userKey: String?)
/**
* Retrieves the local user data key for the given [userId].
*/

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.datasource.disk
import android.content.SharedPreferences
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.serializer.SafeMapSerializer
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource
import com.bitwarden.network.model.AccountKeysJson
@@ -14,6 +15,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import java.time.Instant
import java.util.UUID
@@ -107,6 +109,10 @@ class AuthDiskSourceImpl(
// We must migrate the tokens from being stored in the UserState(shared preferences) to
// being stored separately in encrypted shared preferences.
migrateAccountTokens()
// We want to make sure that any left over encrypted user keys are scrubbed from storage
// Since it is no longer supported.
removeLegacyUserKeys()
}
override var authenticatorSyncSymmetricKey: ByteArray?
@@ -144,7 +150,6 @@ class AuthDiskSourceImpl(
override fun clearData(userId: String) {
storeInvalidUnlockAttempts(userId = userId, invalidUnlockAttempts = null)
storeUserKey(userId = userId, userKey = null)
storeLocalUserDataKey(userId = userId, wrappedKey = null)
storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = null)
storePrivateKey(userId = userId, privateKey = null)
@@ -229,16 +234,6 @@ class AuthDiskSourceImpl(
)
}
override fun getUserKey(userId: String): String? =
getString(key = MASTER_KEY_ENCRYPTION_USER_KEY.appendIdentifier(userId))
override fun storeUserKey(userId: String, userKey: String?) {
putString(
key = MASTER_KEY_ENCRYPTION_USER_KEY.appendIdentifier(userId),
value = userKey,
)
}
override fun getLocalUserDataKey(userId: String): String? =
getString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId))
@@ -485,8 +480,13 @@ class AuthDiskSourceImpl(
getString(key = POLICIES_KEY.appendIdentifier(userId))
?.let {
// The policies are stored as a map.
val policiesMap: Map<String, SyncResponseJson.Policy>? =
json.decodeFromStringOrNull(it)
val policiesMap = json.decodeFromStringOrNull(
deserializer = SafeMapSerializer(
keySerializer = String.serializer(),
valueSerializer = SyncResponseJson.Policy.serializer(),
),
string = it,
)
policiesMap?.values?.toList()
}
@@ -655,4 +655,8 @@ class AuthDiskSourceImpl(
.orEmpty(),
)
}
private fun removeLegacyUserKeys() {
removeWithPrefix(prefix = MASTER_KEY_ENCRYPTION_USER_KEY)
}
}

View File

@@ -42,7 +42,11 @@ data class AccountJson(
* @property name The user's name (if applicable).
* @property stamp The account's security stamp (if applicable).
* @property organizationId The ID of the associated organization (if applicable).
* @property hasPremium True if the user has a Premium account.
* @property hasPremiumPersonally True if the user has personal Premium (i.e., a personal
* subscription not derived from any organization membership).
* @property hasPremiumFromOrganization True if any organization the user is a member of grants
* Premium features. `null` when the value has not yet been synced (e.g., immediately after
* token-based login before the first sync completes).
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
* @property forcePasswordResetReason Describes the reason for a forced password reset.
* @property kdfType The KDF type.
@@ -80,7 +84,10 @@ data class AccountJson(
val avatarColorHex: String?,
@SerialName("hasPremiumPersonally")
val hasPremium: Boolean?,
val hasPremiumPersonally: Boolean?,
@SerialName("hasPremiumFromOrganization")
val hasPremiumFromOrganization: Boolean?,
@SerialName("forcePasswordResetReason")
val forcePasswordResetReason: ForcePasswordResetReason?,

View File

@@ -1,5 +1,9 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.auth.JitMasterPasswordRegistrationResponse
import com.bitwarden.auth.KeyConnectorRegistrationResult
import com.bitwarden.auth.TdeRegistrationResponse
import com.bitwarden.auth.UserMasterPasswordRegistrationResponse
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.core.MasterPasswordPolicyOptions
@@ -7,12 +11,63 @@ import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.policies.OrganizationUserPolicyContext
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
/**
* Source of authentication information and functionality from the Bitwarden SDK.
*/
@Suppress("TooManyFunctions")
interface AuthSdkSource {
/**
* Enrolls the user to master password unlock.
*/
@Suppress("LongParameterList")
suspend fun postKeysForJitPasswordRegistration(
userId: String,
organizationId: String,
organizationPublicKey: String,
organizationSsoIdentifier: String,
salt: String,
masterPassword: String,
masterPasswordHint: String?,
shouldResetPasswordEnroll: Boolean,
): Result<JitMasterPasswordRegistrationResponse>
/**
* Enrolls the user to key connector unlock.
*/
suspend fun postKeysForKeyConnectorRegistration(
userId: String,
accessToken: String,
keyConnectorUrl: String,
ssoOrganizationIdentifier: String,
): Result<KeyConnectorRegistrationResult>
/**
* Enrolls the user to TDE unlock.
*/
suspend fun postKeysForTdeRegistration(
userId: String,
organizationId: String,
organizationPublicKey: String,
deviceIdentifier: String,
shouldTrustDevice: Boolean,
): Result<TdeRegistrationResponse>
/**
* Enrolls the user for password unlock.
*/
suspend fun postKeysForUserPasswordRegistration(
email: String,
salt: String,
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String,
): Result<UserMasterPasswordRegistrationResponse>
/**
* Gets the data needed to create a new auth request.
*/
@@ -82,4 +137,13 @@ interface AuthSdkSource {
passwordStrength: PasswordStrength,
policy: MasterPasswordPolicyOptions,
): Result<Boolean>
/**
* Applies the appropriate filters for determining what policies apply to the user.
*/
fun filterPolicies(
policies: List<PolicyView>,
organizations: List<OrganizationUserPolicyContext>,
policyType: PolicyType,
): Result<List<PolicyView>>
}

View File

@@ -1,29 +1,134 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.auth.JitMasterPasswordRegistrationRequest
import com.bitwarden.auth.JitMasterPasswordRegistrationResponse
import com.bitwarden.auth.KeyConnectorRegistrationResult
import com.bitwarden.auth.TdeRegistrationRequest
import com.bitwarden.auth.TdeRegistrationResponse
import com.bitwarden.auth.UserMasterPasswordRegistrationRequest
import com.bitwarden.auth.UserMasterPasswordRegistrationResponse
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.FingerprintRequest
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.core.MasterPasswordPolicyOptions
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.policies.OrganizationUserPolicyContext
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.sdk.AuthClient
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toPasswordStrengthOrNull
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toUByte
import com.x8bit.bitwarden.data.platform.datasource.sdk.BaseSdkSource
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import kotlinx.coroutines.withContext
/**
* Primary implementation of [AuthSdkSource] that serves as a convenience wrapper around a
* [AuthClient].
*/
@Suppress("TooManyFunctions")
class AuthSdkSourceImpl(
private val dispatcherManager: DispatcherManager,
sdkClientManager: SdkClientManager,
) : BaseSdkSource(sdkClientManager = sdkClientManager),
AuthSdkSource {
override suspend fun postKeysForJitPasswordRegistration(
userId: String,
organizationId: String,
organizationPublicKey: String,
organizationSsoIdentifier: String,
salt: String,
masterPassword: String,
masterPasswordHint: String?,
shouldResetPasswordEnroll: Boolean,
): Result<JitMasterPasswordRegistrationResponse> = runCatchingWithLogs {
withContext(context = dispatcherManager.io) {
getClient(userId = userId).auth().registration().postKeysForJitPasswordRegistration(
request = JitMasterPasswordRegistrationRequest(
orgId = organizationId,
orgPublicKey = organizationPublicKey,
userId = userId,
organizationSsoIdentifier = organizationSsoIdentifier,
salt = salt,
masterPassword = masterPassword,
masterPasswordHint = masterPasswordHint,
resetPasswordEnroll = shouldResetPasswordEnroll,
),
)
}
}
override suspend fun postKeysForKeyConnectorRegistration(
userId: String,
accessToken: String,
keyConnectorUrl: String,
ssoOrganizationIdentifier: String,
): Result<KeyConnectorRegistrationResult> = runCatchingWithLogs {
withContext(context = dispatcherManager.io) {
useClient(userId = userId, accessToken = accessToken) {
auth().registration().postKeysForKeyConnectorRegistration(
keyConnectorUrl = keyConnectorUrl,
ssoOrgIdentifier = ssoOrganizationIdentifier,
)
}
}
}
override suspend fun postKeysForTdeRegistration(
userId: String,
organizationId: String,
organizationPublicKey: String,
deviceIdentifier: String,
shouldTrustDevice: Boolean,
): Result<TdeRegistrationResponse> = runCatchingWithLogs {
withContext(context = dispatcherManager.io) {
getClient(userId = userId).auth().registration().postKeysForTdeRegistration(
request = TdeRegistrationRequest(
orgId = organizationId,
orgPublicKey = organizationPublicKey,
userId = userId,
deviceIdentifier = deviceIdentifier,
trustDevice = shouldTrustDevice,
),
)
}
}
override suspend fun postKeysForUserPasswordRegistration(
email: String,
salt: String,
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String,
): Result<UserMasterPasswordRegistrationResponse> = runCatchingWithLogs {
withContext(context = dispatcherManager.io) {
useClient {
auth().registration().postKeysForUserPasswordRegistration(
request = UserMasterPasswordRegistrationRequest(
email = email,
salt = salt,
masterPassword = masterPassword,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
organizationUserId = null,
orgInviteToken = null,
orgSponsoredFreeFamilyPlanToken = null,
acceptEmergencyAccessInviteToken = null,
acceptEmergencyAccessId = null,
providerInviteToken = null,
providerUserId = null,
),
)
}
}
}
override suspend fun getNewAuthRequest(
email: String,
): Result<AuthRequestResponse> = runCatchingWithLogs {
@@ -124,4 +229,16 @@ class AuthSdkSourceImpl(
)
}
}
override fun filterPolicies(
policies: List<PolicyView>,
organizations: List<OrganizationUserPolicyContext>,
policyType: PolicyType,
): Result<List<PolicyView>> = runCatchingWithLogs {
globalClient.policies().filterByType(
policies = policies,
organizationUserPolicyContexts = organizations,
policyType = policyType,
)
}
}

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk.di
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSourceImpl
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
@@ -19,8 +20,10 @@ object AuthSdkModule {
@Provides
@Singleton
fun provideAuthSdkSource(
dispatcherManager: DispatcherManager,
sdkClientManager: SdkClientManager,
): AuthSdkSource = AuthSdkSourceImpl(
dispatcherManager = dispatcherManager,
sdkClientManager = sdkClientManager,
)
}

View File

@@ -1,23 +1,15 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.crypto.Kdf
import com.bitwarden.network.model.AccountKeysJson
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
import com.x8bit.bitwarden.data.auth.manager.model.MigrateNewUserToKeyConnectorResult
/**
* Manager used to interface with a key connector.
*/
interface KeyConnectorManager {
/**
* Retrieves the master key from the key connector.
*/
suspend fun getMasterKeyFromKeyConnector(
url: String,
accessToken: String,
): Result<KeyConnectorMasterKeyResponseJson>
/**
* Migrates an existing user to use the key connector.
*/
@@ -36,6 +28,8 @@ interface KeyConnectorManager {
*/
@Suppress("LongParameterList")
suspend fun migrateNewUserToKeyConnector(
userId: String,
accountKeys: AccountKeysJson?,
url: String,
accessToken: String,
kdfType: KdfTypeJson,
@@ -43,5 +37,5 @@ interface KeyConnectorManager {
kdfMemory: Int?,
kdfParallelism: Int?,
organizationIdentifier: String,
): Result<KeyConnectorResponse>
): Result<MigrateNewUserToKeyConnectorResult>
}

View File

@@ -1,14 +1,18 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.crypto.Kdf
import com.bitwarden.network.model.AccountKeysJson
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.KeyConnectorKeyRequestJson
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
import com.bitwarden.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
import com.x8bit.bitwarden.data.auth.manager.model.MigrateNewUserToKeyConnectorResult
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.DeriveKeyConnectorResult
@@ -19,16 +23,8 @@ class KeyConnectorManagerImpl(
private val accountsService: AccountsService,
private val authSdkSource: AuthSdkSource,
private val vaultSdkSource: VaultSdkSource,
private val featureFlagManager: FeatureFlagManager,
) : KeyConnectorManager {
override suspend fun getMasterKeyFromKeyConnector(
url: String,
accessToken: String,
): Result<KeyConnectorMasterKeyResponseJson> =
accountsService.getMasterKeyFromKeyConnector(
url = url,
accessToken = accessToken,
)
override suspend fun migrateExistingUserToKeyConnector(
userId: String,
url: String,
@@ -77,6 +73,8 @@ class KeyConnectorManagerImpl(
}
override suspend fun migrateNewUserToKeyConnector(
userId: String,
accountKeys: AccountKeysJson?,
url: String,
accessToken: String,
kdfType: KdfTypeJson,
@@ -84,7 +82,50 @@ class KeyConnectorManagerImpl(
kdfMemory: Int?,
kdfParallelism: Int?,
organizationIdentifier: String,
): Result<KeyConnectorResponse> =
): Result<MigrateNewUserToKeyConnectorResult> =
if (featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionKeyConnector)) {
authSdkSource
.postKeysForKeyConnectorRegistration(
userId = userId,
accessToken = accessToken,
keyConnectorUrl = url,
ssoOrganizationIdentifier = organizationIdentifier,
)
.map {
MigrateNewUserToKeyConnectorResult(
masterKey = it.keyConnectorKey,
encryptedUserKey = it.keyConnectorKeyWrappedUserKey,
privateKey = when (val state = it.accountCryptographicState) {
is WrappedAccountCryptographicState.V1 -> state.privateKey
is WrappedAccountCryptographicState.V2 -> state.privateKey
},
accountCryptographicState = it.accountCryptographicState,
)
}
} else {
legacyMigrateNewUserToKeyConnector(
accountKeys = accountKeys,
url = url,
accessToken = accessToken,
kdfType = kdfType,
kdfIterations = kdfIterations,
kdfMemory = kdfMemory,
kdfParallelism = kdfParallelism,
organizationIdentifier = organizationIdentifier,
)
}
@Suppress("LongParameterList")
private suspend fun legacyMigrateNewUserToKeyConnector(
accountKeys: AccountKeysJson?,
url: String,
accessToken: String,
kdfType: KdfTypeJson,
kdfIterations: Int?,
kdfMemory: Int?,
kdfParallelism: Int?,
organizationIdentifier: String,
): Result<MigrateNewUserToKeyConnectorResult> =
authSdkSource
.makeKeyConnectorKeys()
.flatMap { keyConnectorResponse ->
@@ -111,6 +152,15 @@ class KeyConnectorManagerImpl(
),
)
}
.map { keyConnectorResponse }
.map {
MigrateNewUserToKeyConnectorResult(
masterKey = keyConnectorResponse.masterKey,
encryptedUserKey = keyConnectorResponse.encryptedUserKey,
privateKey = keyConnectorResponse.keys.private,
accountCryptographicState = accountKeys.toAccountCryptographicState(
privateKey = keyConnectorResponse.keys.private,
),
)
}
}
}

View File

@@ -57,7 +57,6 @@ class UserLogoutManagerImpl(
val ableToSwitchToNewAccount = switchUserIfAvailable(
currentUserId = userId,
isSecurityStamp = isSecurityStamp,
removeCurrentUserFromAccounts = true,
)
if (!ableToSwitchToNewAccount) {
@@ -87,12 +86,6 @@ class UserLogoutManagerImpl(
userId = userId,
)
switchUserIfAvailable(
currentUserId = userId,
removeCurrentUserFromAccounts = false,
isSecurityStamp = isSecurityStamp,
)
clearData(userId = userId)
mutableLogoutEventFlow.tryEmit(LogoutEvent(loggedOutUserId = userId))
@@ -135,7 +128,6 @@ class UserLogoutManagerImpl(
private fun switchUserIfAvailable(
currentUserId: String,
removeCurrentUserFromAccounts: Boolean,
isSecurityStamp: Boolean,
): Boolean {
val currentUserState = authDiskSource.userState ?: return false
@@ -143,8 +135,7 @@ class UserLogoutManagerImpl(
val currentAccountsMap = currentUserState.accounts
// Remove the active user from the accounts map
val updatedAccounts = currentAccountsMap
.filterKeys { it != currentUserId }
val updatedAccounts = currentAccountsMap.filterKeys { it != currentUserId }
// Check if there is a new active user
return if (updatedAccounts.isNotEmpty()) {
@@ -163,11 +154,7 @@ class UserLogoutManagerImpl(
// Update the user information and emit an updated token
authDiskSource.userState = currentUserState.copy(
activeUserId = updatedActiveUserId,
accounts = if (removeCurrentUserFromAccounts) {
updatedAccounts
} else {
currentAccountsMap
},
accounts = updatedAccounts,
)
true
} else {

View File

@@ -1,8 +1,8 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
@@ -168,8 +168,8 @@ class UserStateManagerImpl(
private fun existingPolicies(
userId: String,
policyType: PolicyTypeJson,
): List<SyncResponseJson.Policy> = policyManager.getUserPolicies(
policyType: PolicyType,
): List<PolicyView> = policyManager.getUserPolicies(
userId = userId,
type = policyType,
)

View File

@@ -89,11 +89,13 @@ object AuthManagerModule {
accountsService: AccountsService,
authSdkSource: AuthSdkSource,
vaultSdkSource: VaultSdkSource,
featureFlagManager: FeatureFlagManager,
): KeyConnectorManager =
KeyConnectorManagerImpl(
accountsService = accountsService,
authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
featureFlagManager = featureFlagManager,
)
@Provides

View File

@@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.auth.manager.model
import com.bitwarden.core.WrappedAccountCryptographicState
/**
* Models result of migrating a new user to key connector.
* */
data class MigrateNewUserToKeyConnectorResult(
val masterKey: String,
val encryptedUserKey: String,
val privateKey: String,
val accountCryptographicState: WrappedAccountCryptographicState,
)

View File

@@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
import com.x8bit.bitwarden.data.auth.repository.model.GetDevicesResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
@@ -230,7 +231,10 @@ interface AuthRepository :
/**
* Continue the previously halted login attempt.
*/
suspend fun continueKeyConnectorLogin(): LoginResult
suspend fun continueKeyConnectorLogin(
orgIdentifier: String,
email: String,
): LoginResult
/**
* Cancel the previously halted login attempt.
@@ -277,7 +281,7 @@ interface AuthRepository :
email: String,
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String? = null,
emailVerificationToken: String,
shouldCheckDataBreaches: Boolean,
isMasterPasswordStrong: Boolean,
): RegisterResult
@@ -354,6 +358,11 @@ interface AuthRepository :
*/
fun setCookieCallbackResult(result: CookieCallbackResult)
/**
* Retrieves all devices registered to the current user.
*/
suspend fun getDevices(): GetDevicesResult
/**
* Get a [Boolean] indicating whether this is a known device.
*/

View File

@@ -5,6 +5,7 @@ import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.core.data.repository.error.MissingPropertyException
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
@@ -22,13 +23,14 @@ import com.bitwarden.network.model.CreateAccountKeysResponseJson
import com.bitwarden.network.model.DeleteAccountResponseJson
import com.bitwarden.network.model.GetTokenResponseJson
import com.bitwarden.network.model.IdentityTokenAuthModel
import com.bitwarden.network.model.OrganizationAutoEnrollStatusResponseJson
import com.bitwarden.network.model.OrganizationKeysResponseJson
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PasswordHintResponseJson
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.PrevalidateSsoResponseJson
import com.bitwarden.network.model.RefreshTokenResponseJson
import com.bitwarden.network.model.RegisterFinishRequestJson
import com.bitwarden.network.model.RegisterRequestJson
import com.bitwarden.network.model.RegisterResponseJson
import com.bitwarden.network.model.ResendEmailRequestJson
import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson
@@ -36,7 +38,6 @@ import com.bitwarden.network.model.ResetPasswordRequestJson
import com.bitwarden.network.model.SendVerificationEmailRequestJson
import com.bitwarden.network.model.SendVerificationEmailResponseJson
import com.bitwarden.network.model.SetPasswordRequestJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.bitwarden.network.model.TwoFactorAuthMethod
import com.bitwarden.network.model.TwoFactorDataModel
@@ -50,6 +51,8 @@ import com.bitwarden.network.service.HaveIBeenPwnedService
import com.bitwarden.network.service.IdentityService
import com.bitwarden.network.service.OrganizationService
import com.bitwarden.network.util.isSslHandShakeError
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
@@ -71,6 +74,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
import com.x8bit.bitwarden.data.auth.repository.model.GetDevicesResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
@@ -100,9 +104,12 @@ import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.accountKeysJson
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.privateKey
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toDeviceInfo
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
@@ -115,6 +122,7 @@ import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
@@ -178,6 +186,7 @@ class AuthRepositoryImpl(
private val userStateManager: UserStateManager,
private val kdfManager: KdfManager,
private val toastManager: ToastManager,
private val featureFlagManager: FeatureFlagManager,
logsManager: LogsManager,
pushManager: PushManager,
dispatcherManager: DispatcherManager,
@@ -303,6 +312,7 @@ class AuthRepositoryImpl(
override val organizations: List<Organization>
get() = activeUserId
?.let { authDiskSource.getOrganizations(it) }
?.filter { it.status == OrganizationStatusType.CONFIRMED }
.orEmpty()
.toOrganizations()
@@ -357,7 +367,7 @@ class AuthRepositoryImpl(
// When the policies for the user have been set, complete the login process.
policyManager
.getActivePoliciesFlow(type = PolicyTypeJson.MASTER_PASSWORD)
.getActivePoliciesFlow(type = PolicyType.MASTER_PASSWORD)
.onEach { policies ->
val userId = activeUserId ?: return@onEach
@@ -462,84 +472,139 @@ class AuthRepositoryImpl(
?: return NewSsoUserResult.Failure(error = NoActiveUserException())
val orgIdentifier = rememberedOrgIdentifier
?: return NewSsoUserResult.Failure(error = MissingPropertyException("OrgIdentifier"))
val userId = account.profile.userId
return organizationService
.getOrganizationAutoEnrollStatus(orgIdentifier)
.flatMap { orgAutoEnrollStatus ->
organizationService
.getOrganizationKeys(orgAutoEnrollStatus.organizationId)
.flatMap { organizationKeys ->
authSdkSource.makeRegisterTdeKeysAndUnlockVault(
userId = userId,
email = account.profile.email,
orgPublicKey = organizationKeys.publicKey,
rememberDevice = authDiskSource
.getShouldTrustDevice(userId = userId) == true,
)
}
.flatMap { registerTdeKeyResponse ->
accountsService
.createAccountKeys(
publicKey = registerTdeKeyResponse.publicKey,
encryptedPrivateKey = registerTdeKeyResponse.privateKey,
)
.map { createAccountKeysResponse ->
registerTdeKeyResponse to createAccountKeysResponse
return userStateManager.userStateTransaction {
organizationService
.getOrganizationAutoEnrollStatus(organizationIdentifier = orgIdentifier)
.flatMap { orgAutoEnrollStatus ->
organizationService
.getOrganizationKeys(organizationId = orgAutoEnrollStatus.organizationId)
.flatMap { organizationKeys ->
if (featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionTde)) {
registerUserForTdeV2(
profile = account.profile,
orgAutoEnrollStatus = orgAutoEnrollStatus,
orgKeys = organizationKeys,
)
} else {
registerUserForTdeV1(
profile = account.profile,
orgAutoEnrollStatus = orgAutoEnrollStatus,
orgKeys = organizationKeys,
)
}
}
.flatMap { (registerTdeKeyResponse, createAccountKeysResponse) ->
organizationService
.organizationResetPasswordEnroll(
organizationId = orgAutoEnrollStatus.organizationId,
userId = userId,
passwordHash = null,
resetPasswordKey = registerTdeKeyResponse.adminReset,
)
.map { registerTdeKeyResponse to createAccountKeysResponse }
}
.onSuccess { (registerTdeKeyResponse, createAccountKeysResponse) ->
createNewSsoUserSuccess(
userId = userId,
createAccountKeysResponse = createAccountKeysResponse,
registerTdeKeyResponse = registerTdeKeyResponse,
)
}
}
.fold(
onSuccess = { NewSsoUserResult.Success },
onFailure = { NewSsoUserResult.Failure(error = it) },
)
}
}
.fold(
onSuccess = { NewSsoUserResult.Success },
onFailure = { NewSsoUserResult.Failure(error = it) },
)
}
}
/**
* Stores all the relevant data from a successful creation of an SSO user. The data is stored
* while in an [UserStateManager.userStateTransaction] to ensure the `UserState` is only
* updated once after data stored.
*/
private suspend fun createNewSsoUserSuccess(
userId: String,
createAccountKeysResponse: CreateAccountKeysResponseJson,
registerTdeKeyResponse: RegisterTdeKeyResponse,
): Unit = userStateManager.userStateTransaction {
authDiskSource.storeAccountKeys(
userId = userId,
accountKeys = createAccountKeysResponse.accountKeys,
)
// TDE and SSO user creation still uses crypto-v1. These users are not
// expected to have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = registerTdeKeyResponse.privateKey,
)
vaultRepository.syncVaultState(userId = userId)
registerTdeKeyResponse.deviceKey?.let { trustDeviceResponse ->
trustedDeviceManager.trustThisDevice(
private suspend fun registerUserForTdeV1(
profile: AccountJson.Profile,
orgAutoEnrollStatus: OrganizationAutoEnrollStatusResponseJson,
orgKeys: OrganizationKeysResponseJson,
): Result<Pair<RegisterTdeKeyResponse, CreateAccountKeysResponseJson>> {
val userId = profile.userId
return authSdkSource
.makeRegisterTdeKeysAndUnlockVault(
userId = userId,
trustDeviceResponse = trustDeviceResponse,
email = profile.email,
orgPublicKey = orgKeys.publicKey,
rememberDevice = authDiskSource.getShouldTrustDevice(userId = userId) == true,
)
}
.flatMap { registerTdeKeyResponse ->
accountsService
.createAccountKeys(
publicKey = registerTdeKeyResponse.publicKey,
encryptedPrivateKey = registerTdeKeyResponse.privateKey,
)
.map { createAccountKeysResponse ->
registerTdeKeyResponse to createAccountKeysResponse
}
}
.flatMap { (registerTdeKeyResponse, createAccountKeysResponse) ->
organizationService
.organizationResetPasswordEnroll(
organizationId = orgAutoEnrollStatus.organizationId,
userId = userId,
passwordHash = null,
resetPasswordKey = registerTdeKeyResponse.adminReset,
)
.map { registerTdeKeyResponse to createAccountKeysResponse }
}
.onSuccess { (registerTdeKeyResponse, createAccountKeysResponse) ->
authDiskSource.storeAccountKeys(
userId = userId,
accountKeys = createAccountKeysResponse.accountKeys,
)
// TDE and SSO user creation still uses crypto-v1. These users are not expected to
// have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = registerTdeKeyResponse.privateKey,
)
vaultRepository.syncVaultState(userId = userId)
registerTdeKeyResponse.deviceKey?.let { response ->
trustedDeviceManager.trustThisDevice(
userId = userId,
trustDeviceResponse = response,
)
}
}
}
private suspend fun registerUserForTdeV2(
profile: AccountJson.Profile,
orgAutoEnrollStatus: OrganizationAutoEnrollStatusResponseJson,
orgKeys: OrganizationKeysResponseJson,
): Result<VaultUnlockResult> {
val userId = profile.userId
val shouldTrustDevice = authDiskSource.getShouldTrustDevice(userId = userId) == true
return authSdkSource
.postKeysForTdeRegistration(
userId = userId,
organizationId = orgAutoEnrollStatus.organizationId,
organizationPublicKey = orgKeys.publicKey,
deviceIdentifier = authDiskSource.uniqueAppId,
shouldTrustDevice = shouldTrustDevice,
)
.map { response ->
// Clear the 'should trust device' flag, since the SDK trusted the device above.
authDiskSource.storeShouldTrustDevice(userId = userId, shouldTrustDevice = null)
this
.unlockVault(
accountCryptographicState = response.accountCryptographicState,
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = response.userKey,
),
)
.also { result ->
if (result is VaultUnlockResult.Success) {
authDiskSource.storeAccountKeys(
userId = userId,
accountKeys = response.accountCryptographicState.accountKeysJson,
)
// Storing the private key here for legacy purposes, the
// `accountKeysJson` stored above will be used for most purposes.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = response.accountCryptographicState.privateKey,
)
if (shouldTrustDevice) {
authDiskSource.storeDeviceKey(
userId = userId,
deviceKey = response.deviceKey,
)
}
}
}
}
}
override suspend fun completeTdeLogin(
@@ -547,15 +612,12 @@ class AuthRepositoryImpl(
asymmetricalKey: String,
): LoginResult {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return LoginResult.Error(errorMessage = null, error = NoActiveUserException())
?: return LoginResult.Error(error = NoActiveUserException())
val userId = profile.userId
val accountKeys = authDiskSource.getAccountKeys(userId = userId)
val privateKey = accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
?: authDiskSource.getPrivateKey(userId = userId)
?: return LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Private Key"),
)
?: return LoginResult.Error(error = MissingPropertyException("Private Key"))
checkForVaultUnlockError(
onVaultUnlockError = { error ->
@@ -605,7 +667,7 @@ class AuthRepositoryImpl(
onFailure = { throwable ->
when {
throwable.isSslHandShakeError() -> LoginResult.CertificateError
else -> LoginResult.Error(errorMessage = null, error = throwable)
else -> LoginResult.Error(error = throwable)
}
},
onSuccess = { it },
@@ -650,10 +712,7 @@ class AuthRepositoryImpl(
orgIdentifier = orgIdentifier,
)
}
?: LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Identity Token Auth Model"),
)
?: LoginResult.Error(error = MissingPropertyException("Identity Token Auth Model"))
override suspend fun login(
email: String,
@@ -671,20 +730,19 @@ class AuthRepositoryImpl(
orgIdentifier = orgIdentifier,
)
}
?: LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Identity Token Auth Model"),
)
?: LoginResult.Error(error = MissingPropertyException("Identity Token Auth Model"))
override suspend fun continueKeyConnectorLogin(): LoginResult {
override suspend fun continueKeyConnectorLogin(
orgIdentifier: String,
email: String,
): LoginResult {
val response = keyConnectorResponse ?: return LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Key Connector Response"),
)
return handleLoginCommonSuccess(
loginResponse = response,
email = rememberedEmailAddress.orEmpty(),
orgIdentifier = rememberedOrgIdentifier,
email = email,
orgIdentifier = orgIdentifier,
password = null,
deviceData = null,
userConfirmedKeyConnector = true,
@@ -885,7 +943,7 @@ class AuthRepositoryImpl(
email: String,
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String?,
emailVerificationToken: String,
shouldCheckDataBreaches: Boolean,
isMasterPasswordStrong: Boolean,
): RegisterResult {
@@ -905,6 +963,21 @@ class AuthRepositoryImpl(
if (!isMasterPasswordStrong) {
return RegisterResult.WeakPassword
}
if (featureFlagManager.getFeatureFlag(key = FlagKey.V2EncryptionPassword)) {
return authSdkSource
.postKeysForUserPasswordRegistration(
email = email,
salt = email,
masterPassword = masterPassword,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
)
.fold(
onSuccess = { RegisterResult.Success },
onFailure = { RegisterResult.Error(errorMessage = null, error = it) },
)
}
val kdf = Kdf.Pbkdf2(iterations = DEFAULT_PBKDF2_ITERATIONS.toUInt())
return authSdkSource
.makeRegisterKeys(
@@ -913,39 +986,21 @@ class AuthRepositoryImpl(
kdf = kdf,
)
.flatMap { registerKeyResponse ->
if (emailVerificationToken == null) {
// TODO PM-6675: Remove register call and service implementation
identityService.register(
body = RegisterRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
key = registerKeyResponse.encryptedUserKey,
keys = RegisterRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
identityService.registerFinish(
body = RegisterFinishRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
userSymmetricKey = registerKeyResponse.encryptedUserKey,
userAsymmetricKeys = RegisterFinishRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
),
)
} else {
identityService.registerFinish(
body = RegisterFinishRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
userSymmetricKey = registerKeyResponse.encryptedUserKey,
userAsymmetricKeys = RegisterFinishRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
)
}
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
)
}
.fold(
onSuccess = {
@@ -981,18 +1036,20 @@ class AuthRepositoryImpl(
}
override suspend fun removePassword(masterPassword: String): RemovePasswordResult {
val activeAccount = authDiskSource
val profile = authDiskSource
.userState
?.activeAccount
?.profile
?: return RemovePasswordResult.Error(error = NoActiveUserException())
val profile = activeAccount.profile
val userId = profile.userId
val userKey = authDiskSource
.getUserKey(userId = userId)
val userKey = profile
.userDecryptionOptions
?.masterPasswordUnlock
?.masterKeyWrappedUserKey
?: return RemovePasswordResult.Error(error = MissingPropertyException("User Key"))
val keyConnectorUrl = organizations
.find {
it.shouldUseKeyConnector &&
it.isKeyConnectorEnabled &&
it.role != OrganizationType.OWNER &&
it.role != OrganizationType.ADMIN
}
@@ -1102,85 +1159,70 @@ class AuthRepositoryImpl(
)
}
@Suppress("LongMethod")
override suspend fun setPassword(
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult {
val activeAccount = authDiskSource
.userState
?.activeAccount
val profile = authDiskSource.userState?.activeAccount?.profile
?: return SetPasswordResult.Error(error = NoActiveUserException())
val userId = activeAccount.profile.userId
// Update the saved master password hash.
val passwordHash = authSdkSource
.hashPassword(
email = activeAccount.profile.email,
password = password,
kdf = activeAccount.profile.toSdkParams(),
purpose = HashPurpose.SERVER_AUTHORIZATION,
)
.getOrElse { return@setPassword SetPasswordResult.Error(error = it) }
return when (activeAccount.profile.forcePasswordResetReason) {
return when (profile.forcePasswordResetReason) {
ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION -> {
vaultSdkSource
.updatePassword(userId = userId, newPassword = password)
.map { it.newKey to null }
setUpdatedPassword(
profile = profile,
organizationIdentifier = organizationIdentifier,
password = password,
passwordHint = passwordHint,
)
}
ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET,
ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
null,
-> {
authSdkSource
.makeRegisterKeys(
email = activeAccount.profile.email,
password = password,
kdf = activeAccount.profile.toSdkParams(),
)
.map { it.encryptedUserKey to it.keys }
setPasswordForJit(
profile = profile,
organizationIdentifier = organizationIdentifier,
password = password,
passwordHint = passwordHint,
)
}
}
.flatMap { (encryptedUserKey, rsaKeys) ->
}
private suspend fun setUpdatedPassword(
profile: AccountJson.Profile,
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult {
val userId = profile.userId
return vaultSdkSource
.updatePassword(userId = userId, newPassword = password)
.flatMap { response ->
accountsService
.setPassword(
body = SetPasswordRequestJson(
passwordHash = passwordHash,
passwordHash = response.passwordHash,
passwordHint = passwordHint,
organizationIdentifier = organizationIdentifier,
kdfIterations = activeAccount.profile.kdfIterations,
kdfMemory = activeAccount.profile.kdfMemory,
kdfParallelism = activeAccount.profile.kdfParallelism,
kdfType = activeAccount.profile.kdfType,
key = encryptedUserKey,
keys = rsaKeys?.let {
RegisterRequestJson.Keys(
publicKey = it.public,
encryptedPrivateKey = it.private,
)
},
kdfIterations = profile.kdfIterations,
kdfMemory = profile.kdfMemory,
kdfParallelism = profile.kdfParallelism,
kdfType = profile.kdfType,
key = response.newKey,
keys = null,
),
)
.onSuccess {
rsaKeys?.private?.let {
// This process is used by TDE and Enterprise accounts during initial
// login. We continue to store the locally generated keys
// until TDE and Enterprise accounts support AEAD keys.
authDiskSource.storePrivateKey(userId = userId, privateKey = it)
}
authDiskSource.storeUserKey(userId = userId, userKey = encryptedUserKey)
}
.map { response.passwordHash }
}
.flatMap {
.flatMap { masterPasswordHash ->
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
is VaultUnlockResult.Success -> {
enrollUserInPasswordReset(
userId = userId,
organizationIdentifier = organizationIdentifier,
passwordHash = passwordHash,
passwordHash = masterPasswordHash,
)
}
@@ -1191,8 +1233,149 @@ class AuthRepositoryImpl(
}
}
.onSuccess {
authDiskSource.storeMasterPasswordHash(userId = userId, passwordHash = passwordHash)
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword()
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = null,
)
this.organizationIdentifier = null
}
.fold(
onFailure = { SetPasswordResult.Error(error = it) },
onSuccess = { SetPasswordResult.Success },
)
}
private suspend fun setPasswordForJit(
profile: AccountJson.Profile,
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult {
if (!featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionJitPassword)) {
return setPasswordForJitV1(
profile = profile,
organizationIdentifier = organizationIdentifier,
password = password,
passwordHint = passwordHint,
)
}
val userId = profile.userId
return organizationService
.getOrganizationAutoEnrollStatus(organizationIdentifier = organizationIdentifier)
.flatMap { enrollStatus ->
organizationService
.getOrganizationKeys(organizationId = enrollStatus.organizationId)
.map { orgKeys -> enrollStatus to orgKeys }
}
.flatMap { (enrollStatus, orgKeys) ->
authSdkSource.postKeysForJitPasswordRegistration(
userId = userId,
organizationId = enrollStatus.organizationId,
organizationPublicKey = orgKeys.publicKey,
organizationSsoIdentifier = organizationIdentifier,
salt = profile.email,
masterPassword = password,
masterPasswordHint = passwordHint,
shouldResetPasswordEnroll = enrollStatus.isResetPasswordEnabled,
)
}
.onSuccess { response ->
authDiskSource.storeAccountKeys(
userId = userId,
accountKeys = response.accountCryptographicState.accountKeysJson,
)
// TDE and SSO user creation still uses crypto-v1. These users are not
// expected to have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = response.accountCryptographicState.privateKey,
)
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = response.masterPasswordUnlock,
)
this.organizationIdentifier = null
}
.flatMap { response ->
// Logging in with the password instead of the decrypted userKey will store
// the master password hash automatically.
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
VaultUnlockResult.Success -> response.asSuccess()
is VaultUnlockError -> {
(result.error ?: IllegalStateException("Failed to unlock vault"))
.asFailure()
}
}
}
.fold(
onFailure = { SetPasswordResult.Error(error = it) },
onSuccess = { SetPasswordResult.Success },
)
}
@Suppress("LongMethod")
private suspend fun setPasswordForJitV1(
profile: AccountJson.Profile,
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult {
val userId = profile.userId
return authSdkSource
.makeRegisterKeys(
email = profile.email,
password = password,
kdf = profile.toSdkParams(),
)
.flatMap { response ->
accountsService
.setPassword(
body = SetPasswordRequestJson(
passwordHash = response.masterPasswordHash,
passwordHint = passwordHint,
organizationIdentifier = organizationIdentifier,
kdfIterations = profile.kdfIterations,
kdfMemory = profile.kdfMemory,
kdfParallelism = profile.kdfParallelism,
kdfType = profile.kdfType,
key = response.encryptedUserKey,
keys = SetPasswordRequestJson.Keys(
publicKey = response.keys.public,
encryptedPrivateKey = response.keys.private,
),
),
)
.onSuccess {
// This process is used by TDE and Enterprise accounts during initial
// login. We continue to store the locally generated keys
// until TDE and Enterprise accounts support AEAD keys.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = response.keys.private,
)
}
.map { response.masterPasswordHash }
}
.flatMap { masterPasswordHash ->
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
is VaultUnlockResult.Success -> {
enrollUserInPasswordReset(
userId = userId,
organizationIdentifier = organizationIdentifier,
passwordHash = masterPasswordHash,
)
}
is VaultUnlockError -> {
(result.error ?: IllegalStateException("Failed to unlock vault"))
.asFailure()
}
}
}
.onSuccess {
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = null,
)
this.organizationIdentifier = null
}
.fold(
@@ -1262,6 +1445,20 @@ class AuthRepositoryImpl(
mutableCookieCallbackResultFlow.tryEmit(result)
}
override suspend fun getDevices(): GetDevicesResult =
devicesService
.getDevices()
.fold(
onFailure = { GetDevicesResult.Error },
onSuccess = { response ->
GetDevicesResult.Success(
devices = response.devices.map { json ->
json.toDeviceInfo(currentDeviceIdentifier = authDiskSource.uniqueAppId)
},
)
},
)
override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult =
devicesService
.getIsKnownDevice(
@@ -1301,7 +1498,12 @@ class AuthRepositoryImpl(
)
override suspend fun validatePassword(password: String): ValidatePasswordResult {
val userId = activeUserId ?: return ValidatePasswordResult.Error(NoActiveUserException())
val profile = authDiskSource
.userState
?.activeAccount
?.profile
?: return ValidatePasswordResult.Error(error = NoActiveUserException())
val userId = profile.userId
return authDiskSource
.getMasterPasswordHash(userId = userId)
?.let { masterPasswordHash ->
@@ -1317,8 +1519,10 @@ class AuthRepositoryImpl(
)
}
?: run {
val encryptedKey = authDiskSource
.getUserKey(userId)
val encryptedKey = profile
.userDecryptionOptions
?.masterPasswordUnlock
?.masterKeyWrappedUserKey
?: return ValidatePasswordResult.Error(MissingPropertyException("UserKey"))
vaultSdkSource
.validatePasswordUserKey(
@@ -1491,7 +1695,7 @@ class AuthRepositoryImpl(
*/
private suspend fun passwordPassesPolicies(
password: String,
policies: List<SyncResponseJson.Policy>,
policies: List<PolicyView>,
): Boolean {
// If there are no master password policies that are enabled and should be
// enforced on login, the check should complete.
@@ -1601,10 +1805,7 @@ class AuthRepositoryImpl(
LoginResult.UnofficialServerError
}
else -> LoginResult.Error(
errorMessage = null,
error = throwable,
)
else -> LoginResult.Error(error = throwable)
}
},
onSuccess = { loginResponse ->
@@ -1668,9 +1869,18 @@ class AuthRepositoryImpl(
)
val profile = userStateJson.activeAccount.profile
val userId = profile.userId
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
accessToken = loginResponse.accessToken,
refreshToken = loginResponse.refreshToken,
expiresAtSec = clock.instant().epochSecond + loginResponse.expiresInSeconds,
),
)
checkForVaultUnlockError(
onVaultUnlockError = { vaultUnlockError ->
authDiskSource.storeAccountTokens(userId = profile.userId, accountTokens = null)
return@userStateTransaction vaultUnlockError.toLoginErrorResult()
},
) {
@@ -1699,6 +1909,7 @@ class AuthRepositoryImpl(
// If a new KeyConnector user is logging in for the first time,
// we should ask him to confirm the domain
if (isNewKeyConnectorUser && isNotConfirmed) {
authDiskSource.storeAccountTokens(userId = profile.userId, accountTokens = null)
keyConnectorResponse = loginResponse
return@userStateTransaction LoginResult.ConfirmKeyConnectorDomain(
domain = keyConnectorUrl,
@@ -1740,16 +1951,7 @@ class AuthRepositoryImpl(
passwordsToCheckMap.put(userId, it)
}
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
accessToken = loginResponse.accessToken,
refreshToken = loginResponse.refreshToken,
expiresAtSec = clock.instant().epochSecond + loginResponse.expiresInSeconds,
),
)
settingsRepository.hasUserLoggedInOrCreatedAccount = true
authDiskSource.userState = userStateJson
password?.let {
// Automatically update kdf to minimums after password unlock and userState update
@@ -1761,11 +1963,6 @@ class AuthRepositoryImpl(
}
}
}
loginResponse.key?.let {
// Only set the value if it's present, since we may have set it already
// when we completed the pending admin auth request.
authDiskSource.storeUserKey(userId = userId, userKey = it)
}
// We continue to store the private key for backwards compatibility. Key connector
// conversion still relies on the private key.
loginResponse.privateKeyOrNull()?.let {
@@ -1865,32 +2062,23 @@ class AuthRepositoryImpl(
null
} else if (key != null && privateKey != null) {
// This is a returning user who should already have the key connector setup
keyConnectorManager
.getMasterKeyFromKeyConnector(
unlockVault(
accountCryptographicState = loginResponse
.accountKeys
.toAccountCryptographicState(privateKey = privateKey),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnectorUrl(
url = keyConnectorUrl,
accessToken = loginResponse.accessToken,
)
.map {
unlockVault(
accountCryptographicState = loginResponse
.accountKeys
.toAccountCryptographicState(privateKey = privateKey),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = it.masterKey,
userKey = key,
),
)
}
.fold(
// If the request failed, we want to abort the login process
onFailure = { VaultUnlockResult.GenericError(error = it) },
onSuccess = { it },
)
keyConnectorKeyWrappedUserKey = key,
),
)
} else {
// This is a new user who needs to setup the key connector
// This is a new user who needs to set up the key connector
val userId = profile.userId
keyConnectorManager
.migrateNewUserToKeyConnector(
userId = userId,
accountKeys = loginResponse.accountKeys,
url = keyConnectorUrl,
accessToken = loginResponse.accessToken,
kdfType = loginResponse.kdfType,
@@ -1899,37 +2087,30 @@ class AuthRepositoryImpl(
kdfParallelism = loginResponse.kdfParallelism,
organizationIdentifier = orgIdentifier,
)
.map { keyConnectorResponse ->
val accountKeys = loginResponse.accountKeys
val result = unlockVault(
accountCryptographicState = accountKeys.toAccountCryptographicState(
privateKey = keyConnectorResponse.keys.private,
),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = keyConnectorResponse.masterKey,
userKey = keyConnectorResponse.encryptedUserKey,
),
)
if (result is VaultUnlockResult.Success) {
// We now know that login/unlock was successful, so we store the userKey
// and privateKey we now have since it didn't exist on the loginResponse
authDiskSource.storeUserKey(
userId = profile.userId,
userKey = keyConnectorResponse.encryptedUserKey,
.map { keyConnector ->
this
.unlockVault(
accountCryptographicState = keyConnector.accountCryptographicState,
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = keyConnector.masterKey,
userKey = keyConnector.encryptedUserKey,
),
)
// We continue to store the private key for backwards compatibility since
// key connector conversion still relies on the private key.
authDiskSource.storePrivateKey(
userId = profile.userId,
privateKey = keyConnectorResponse.keys.private,
)
authDiskSource.storeAccountKeys(
userId = profile.userId,
accountKeys = loginResponse.accountKeys,
)
}
result
.also { result ->
if (result is VaultUnlockResult.Success) {
// We continue to store the private key for backwards compatibility
// since key connector conversion still relies on the private key.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = keyConnector.privateKey,
)
authDiskSource.storeAccountKeys(
userId = userId,
accountKeys = loginResponse.accountKeys,
)
}
}
}
.fold(
// If the request failed, we want to abort the login process
@@ -2058,7 +2239,6 @@ class AuthRepositoryImpl(
method = AuthRequestMethod.UserKey(protectedUserKey = userKey),
),
)
authDiskSource.storeUserKey(userId = userId, userKey = userKey)
}
authDiskSource.storePendingAuthRequest(
userId = userId,
@@ -2088,10 +2268,6 @@ class AuthRepositoryImpl(
deviceProtectedUserKey = encryptedUserKey,
),
)
if (vaultUnlockResult is VaultUnlockResult.Success) {
authDiskSource.storeUserKey(userId = userId, userKey = encryptedUserKey)
}
return vaultUnlockResult
}

View File

@@ -21,6 +21,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
@@ -73,6 +74,7 @@ object AuthRepositoryModule {
userStateManager: UserStateManager,
kdfManager: KdfManager,
toastManager: ToastManager,
featureFlagManager: FeatureFlagManager,
): AuthRepository = AuthRepositoryImpl(
clock = clock,
accountsService = accountsService,
@@ -100,6 +102,7 @@ object AuthRepositoryModule {
userStateManager = userStateManager,
kdfManager = kdfManager,
toastManager = toastManager,
featureFlagManager = featureFlagManager,
)
@Provides

View File

@@ -0,0 +1,32 @@
package com.x8bit.bitwarden.data.auth.repository.model
import android.os.Parcelable
import com.bitwarden.network.model.DeviceType
import kotlinx.parcelize.Parcelize
import java.time.Instant
/**
* Domain model for a device registered to the current user.
*
* @property id The unique identifier of the device.
* @property name The name of the device.
* @property identifier The unique device install identifier of the device.
* @property type The type of the device.
* @property isTrusted Whether this device is trusted.
* @property creationDate The date and time on which this device was created.
* @property lastActivityDate The date and time of the device's last activity, if available.
* @property pendingAuthRequest The pending auth request for this device, if any.
* @property isCurrentDevice If this is the current device being used.
*/
@Parcelize
data class DeviceInfo(
val id: String,
val name: String,
val identifier: String,
val type: DeviceType,
val isTrusted: Boolean,
val creationDate: Instant,
val lastActivityDate: Instant?,
val pendingAuthRequest: DevicePendingAuthRequest?,
val isCurrentDevice: Boolean,
) : Parcelable

View File

@@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.auth.repository.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.time.Instant
/**
* Domain model for a pending auth request associated with a device.
*
* @property id The unique identifier of the pending auth request.
* @property creationDate The date and time on which this auth request was created.
*/
@Parcelize
data class DevicePendingAuthRequest(
val id: String,
val creationDate: Instant,
) : Parcelable

View File

@@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of retrieving all devices registered to the current user.
*/
sealed class GetDevicesResult {
/**
* Contains the list of [DeviceInfo] for the current user's registered devices.
*/
data class Success(val devices: List<DeviceInfo>) : GetDevicesResult()
/**
* There was an error retrieving the devices.
*/
data object Error : GetDevicesResult()
}

View File

@@ -1,5 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of logging in.
*/
@@ -30,8 +32,8 @@ sealed class LoginResult {
* There was an error logging in.
*/
data class Error(
val errorMessage: String?,
val error: Throwable?,
val errorMessage: String? = error?.userFriendlyMessage,
) : LoginResult()
/**

View File

@@ -15,5 +15,5 @@ fun VaultUnlockError.toLoginErrorResult(): LoginResult.Error = when (this) {
is VaultUnlockResult.BiometricDecodingError,
is VaultUnlockResult.GenericError,
is VaultUnlockResult.InvalidStateError,
-> LoginResult.Error(errorMessage = null, error = this.error)
-> LoginResult.Error(error = this.error)
}

View File

@@ -9,7 +9,7 @@ import com.bitwarden.network.model.OrganizationType
* @property name The name of the organization (if applicable).
* @property shouldManageResetPassword Indicates that this user has the permission to manage their
* own password.
* @property shouldUseKeyConnector Indicates that the organization uses a key connector.
* @property isKeyConnectorEnabled Indicates that the organization uses a key connector.
* @property role The user's role in the organization.
* @property keyConnectorUrl The key connector domain (if applicable).
* @property userIsClaimedByOrganization Indicates that the user is claimed by the organization.
@@ -20,7 +20,7 @@ data class Organization(
val id: String,
val name: String,
val shouldManageResetPassword: Boolean,
val shouldUseKeyConnector: Boolean,
val isKeyConnectorEnabled: Boolean,
val role: OrganizationType,
val keyConnectorUrl: String?,
val userIsClaimedByOrganization: Boolean,

View File

@@ -42,7 +42,12 @@ data class UserState(
* @property name The user's name (if applicable).
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
* @property environment The [Environment] associated with the user's account.
* @property isPremium `true` if the account has a Premium membership.
* @property isPremium `true` if the account has a Premium membership from any source (personal
* subscription or organization-granted).
* @property isPremiumFromSelf `true` if the account has a personal Premium subscription. This
* is `false` for users whose only Premium access is granted by an organization they are a
* member of. Use this when behavior should be gated on the user's own subscription, not on
* organization-granted Premium.
* @property isLoggedIn `true` if the account is logged in, or `false` if it requires additional
* authentication to view their vault.
* @property isVaultUnlocked Whether the user's vault is currently unlocked.
@@ -66,6 +71,7 @@ data class UserState(
val avatarColorHex: String,
val environment: Environment,
val isPremium: Boolean,
val isPremiumFromSelf: Boolean,
val isLoggedIn: Boolean,
val isVaultUnlocked: Boolean,
val needsPasswordReset: Boolean,
@@ -113,6 +119,7 @@ data class UserState(
avatarColorHex = "".toHexColorRepresentation(),
environment = Environment.Us,
isPremium = false,
isPremiumFromSelf = false,
isLoggedIn = false,
isVaultUnlocked = false,
needsPasswordReset = false,

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.network.model.OrganizationStatusType
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
@@ -27,6 +28,7 @@ val AuthDiskSource.userOrganizationsList: List<UserOrganizations>
userId = userId,
organizations = this
.getOrganizations(userId = userId)
?.filter { it.status == OrganizationStatusType.CONFIRMED }
.orEmpty()
.toOrganizations(),
)
@@ -48,10 +50,15 @@ val AuthDiskSource.userOrganizationsListFlow: Flow<List<UserOrganizations>>
.map { (userId, _) ->
this
.getOrganizationsFlow(userId = userId)
.map {
.map { organizations ->
UserOrganizations(
userId = userId,
organizations = it.orEmpty().toOrganizations(),
organizations = organizations
?.filter {
it.status == OrganizationStatusType.CONFIRMED
}
.orEmpty()
.toOrganizations(),
)
}
},

View File

@@ -0,0 +1,26 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.network.model.DeviceResponseJson
import com.x8bit.bitwarden.data.auth.repository.model.DeviceInfo
import com.x8bit.bitwarden.data.auth.repository.model.DevicePendingAuthRequest
/**
* Maps the given [DeviceResponseJson] to a [DeviceInfo].
*/
fun DeviceResponseJson.toDeviceInfo(currentDeviceIdentifier: String): DeviceInfo =
DeviceInfo(
id = id,
name = name,
identifier = identifier,
type = type,
isTrusted = isTrusted,
creationDate = creationDate,
lastActivityDate = lastActivityDate,
pendingAuthRequest = devicePendingAuthRequest?.let {
DevicePendingAuthRequest(
id = it.id,
creationDate = it.creationDate,
)
},
isCurrentDevice = identifier == currentDeviceIdentifier,
)

View File

@@ -31,7 +31,8 @@ fun GetTokenResponseJson.Success.toUserState(
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremium = jwtTokenData.hasPremium,
hasPremiumPersonally = jwtTokenData.hasPremium,
hasPremiumFromOrganization = null,
forcePasswordResetReason = this.toForcePasswordResetReason(),
kdfType = this.kdfType,
kdfIterations = this.kdfIterations,

View File

@@ -1,11 +1,24 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.MemberDecryptionType
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.ProductTierType
import com.bitwarden.network.model.ProviderType
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.organizations.OrganizationUserStatusType
import com.bitwarden.organizations.OrganizationUserType
import com.bitwarden.organizations.Permissions
import com.bitwarden.organizations.ProfileOrganization
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import kotlinx.serialization.json.Json
import com.bitwarden.organizations.MemberDecryptionType as SdkMemberDecryptionType
import com.bitwarden.organizations.ProductTierType as SdkProductTierType
import com.bitwarden.organizations.ProviderType as SdkProviderType
private val JSON = Json {
ignoreUnknownKeys = true
@@ -21,7 +34,7 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization? =
Organization(
id = this.id,
name = it,
shouldUseKeyConnector = this.shouldUseKeyConnector,
isKeyConnectorEnabled = this.isKeyConnectorEnabled,
role = this.type,
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
keyConnectorUrl = this.keyConnectorUrl,
@@ -39,28 +52,164 @@ fun List<SyncResponseJson.Profile.Organization>.toOrganizations(): List<Organiza
this.mapNotNull { it.toOrganization() }
/**
* Convert the JSON data of the [SyncResponseJson.Policy] object into [PolicyInformation] data.
* Maps the given list of [SyncResponseJson.Profile.Organization] to a list of
* [ProfileOrganization]s.
*/
val SyncResponseJson.Policy.policyInformation: PolicyInformation?
get() = data?.toString()?.let {
@Suppress("MaxLineLength")
fun List<SyncResponseJson.Profile.Organization>.toSdkProfileOrganizations(): List<ProfileOrganization> =
this.mapNotNull { it.toSdkProfileOrganization() }
/**
* Maps the given [SyncResponseJson.Profile.Organization] to a [ProfileOrganization] or `null` if
* the [SyncResponseJson.Profile.Organization.name] is not present.
*/
@Suppress("LongMethod")
private fun SyncResponseJson.Profile.Organization.toSdkProfileOrganization(): ProfileOrganization? =
this.name?.let {
ProfileOrganization(
id = this.id,
name = it,
status = this.status.toSdkOrganizationUserStatusType(),
type = this.type.toSdkOrganizationUserType(),
enabled = this.isEnabled,
usePolicies = this.shouldUsePolicies,
useGroups = this.shouldUseGroups,
useDirectory = this.shouldUseDirectory,
useEvents = this.shouldUseEvents,
useTotp = this.shouldUseTotp,
use2fa = this.use2fa,
useApi = this.shouldUseApi,
useSso = this.useSso,
useOrganizationDomains = this.useOrganizationDomains,
useKeyConnector = this.shouldUseKeyConnector,
useScim = this.useScim,
useCustomPermissions = this.useCustomPermissions,
useResetPassword = this.useResetPassword,
useSecretsManager = this.useSecretsManager,
usePasswordManager = this.usePasswordManager,
useActivateAutofillPolicy = this.useActivateAutofillPolicy,
useAutomaticUserConfirmation = this.useAutomaticUserConfirmation,
selfHost = this.isSelfHost,
usersGetPremium = this.shouldUsersGetPremium,
seats = this.seats,
maxCollections = this.maxCollections,
maxStorageGb = this.maxStorageGb,
ssoBound = this.ssoBound,
identifier = this.identifier,
permissions = this.permissions.toSdkPermissions(),
resetPasswordEnrolled = this.resetPasswordEnrolled,
userId = this.userId,
organizationUserId = this.organizationUserId,
hasPublicAndPrivateKeys = this.hasPublicAndPrivateKeys,
providerId = this.providerId,
providerName = this.providerName,
providerType = this.providerType?.toSdkProviderType(),
isProviderUser = this.isProviderUser,
isMember = this.isMember,
familySponsorshipFriendlyName = this.familySponsorshipFriendlyName,
familySponsorshipAvailable = this.familySponsorshipAvailable,
productTierType = this.productTierType.toSdkProductTierType(),
keyConnectorEnabled = this.isKeyConnectorEnabled,
keyConnectorUrl = this.keyConnectorUrl,
familySponsorshipLastSyncDate = this.familySponsorshipLastSyncDate,
familySponsorshipValidUntil = this.familySponsorshipValidUntil,
familySponsorshipToDelete = this.familySponsorshipToDelete,
accessSecretsManager = this.accessSecretsManager,
limitCollectionCreation = this.limitCollectionCreation,
limitCollectionDeletion = this.limitCollectionDeletion,
limitItemDeletion = this.limitItemDeletion,
allowAdminAccessToAllCollectionItems = this.allowAdminAccessToAllCollectionItems,
userIsManagedByOrganization = this.userIsClaimedByOrganization,
useAccessIntelligence = this.useAccessIntelligence,
useAdminSponsoredFamilies = this.useAdminSponsoredFamilies,
useDisableSmAdsForUsers = this.useDisableSmAdsForUsers,
isAdminInitiated = this.isAdminInitiated,
ssoEnabled = this.ssoEnabled,
ssoMemberDecryptionType = this.ssoMemberDecryptionType?.toSdkMemberDecryptionType(),
usePhishingBlocker = this.usePhishingBlocker,
useMyItems = this.useMyItems,
)
}
/**
* Convert the JSON data of the [PolicyView] object into [PolicyInformation] data.
*/
val PolicyView.policyInformation: PolicyInformation?
get() = data?.let {
when (type) {
PolicyTypeJson.MASTER_PASSWORD -> {
PolicyType.MASTER_PASSWORD -> {
JSON.decodeFromStringOrNull<PolicyInformation.MasterPassword>(it)
}
PolicyTypeJson.PASSWORD_GENERATOR -> {
PolicyType.PASSWORD_GENERATOR -> {
JSON.decodeFromStringOrNull<PolicyInformation.PasswordGenerator>(it)
}
PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT -> {
PolicyType.MAXIMUM_VAULT_TIMEOUT -> {
JSON.decodeFromStringOrNull<PolicyInformation.VaultTimeout>(it)
}
PolicyTypeJson.SEND_OPTIONS -> {
PolicyType.SEND_OPTIONS -> {
JSON.decodeFromStringOrNull<PolicyInformation.SendOptions>(it)
}
else -> null
}
}
private fun SyncResponseJson.Profile.Permissions.toSdkPermissions(): Permissions =
Permissions(
accessEventLogs = this.accessEventLogs,
accessImportExport = this.accessImportExport,
accessReports = this.accessReports,
createNewCollections = this.createNewCollections,
editAnyCollection = this.editAnyCollection,
deleteAnyCollection = this.deleteAnyCollection,
manageGroups = this.manageGroups,
manageSso = this.manageSso,
managePolicies = this.shouldManagePolicies,
manageUsers = this.manageUsers,
manageResetPassword = this.shouldManageResetPassword,
manageScim = this.manageScim,
)
private fun OrganizationStatusType.toSdkOrganizationUserStatusType(): OrganizationUserStatusType =
when (this) {
OrganizationStatusType.REVOKED -> OrganizationUserStatusType.REVOKED
OrganizationStatusType.INVITED -> OrganizationUserStatusType.INVITED
OrganizationStatusType.ACCEPTED -> OrganizationUserStatusType.ACCEPTED
OrganizationStatusType.CONFIRMED -> OrganizationUserStatusType.CONFIRMED
}
private fun OrganizationType.toSdkOrganizationUserType(): OrganizationUserType =
when (this) {
OrganizationType.OWNER -> OrganizationUserType.OWNER
OrganizationType.ADMIN -> OrganizationUserType.ADMIN
OrganizationType.USER -> OrganizationUserType.USER
OrganizationType.CUSTOM -> OrganizationUserType.CUSTOM
}
private fun ProviderType.toSdkProviderType(): SdkProviderType =
when (this) {
ProviderType.MSP -> SdkProviderType.MSP
ProviderType.RESELLER -> SdkProviderType.RESELLER
ProviderType.BUSINESS_UNIT -> SdkProviderType.BUSINESS_UNIT
}
private fun ProductTierType.toSdkProductTierType(): SdkProductTierType =
when (this) {
ProductTierType.FREE -> SdkProductTierType.FREE
ProductTierType.FAMILIES -> SdkProductTierType.FAMILIES
ProductTierType.TEAMS -> SdkProductTierType.TEAMS
ProductTierType.ENTERPRISE -> SdkProductTierType.ENTERPRISE
ProductTierType.TEAMS_STARTER -> SdkProductTierType.TEAMS_STARTER
}
private fun MemberDecryptionType.toSdkMemberDecryptionType(): SdkMemberDecryptionType =
when (this) {
MemberDecryptionType.MASTER_PASSWORD -> SdkMemberDecryptionType.MASTER_PASSWORD
MemberDecryptionType.KEY_CONNECTOR -> SdkMemberDecryptionType.KEY_CONNECTOR
MemberDecryptionType.TRUSTED_DEVICE_ENCRYPTION -> {
SdkMemberDecryptionType.TRUSTED_DEVICE_ENCRYPTION
}
}

View File

@@ -1,14 +1,18 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.core.MasterPasswordUnlockData
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.ui.platform.base.util.toHexColorRepresentation
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfRequestModel
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
@@ -30,7 +34,10 @@ fun UserStateJson.toRemovedPasswordUserStateJson(
val profile = account.profile
val updatedUserDecryptionOptions = profile
.userDecryptionOptions
?.copy(hasMasterPassword = false)
?.copy(
hasMasterPassword = false,
masterPasswordUnlock = null,
)
?: UserDecryptionOptionsJson(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
@@ -64,7 +71,10 @@ fun UserStateJson.toUpdatedUserStateJson(
?.let { syncUserDecryption ->
profile
.userDecryptionOptions
?.copy(masterPasswordUnlock = syncUserDecryption.masterPasswordUnlock)
?.copy(
hasMasterPassword = syncUserDecryption.masterPasswordUnlock != null,
masterPasswordUnlock = syncUserDecryption.masterPasswordUnlock,
)
?: UserDecryptionOptionsJson(
hasMasterPassword = syncUserDecryption.masterPasswordUnlock != null,
trustedDeviceUserDecryptionOptions = null,
@@ -80,7 +90,8 @@ fun UserStateJson.toUpdatedUserStateJson(
.copy(
avatarColorHex = syncProfile.avatarColor,
stamp = syncProfile.securityStamp,
hasPremium = syncProfile.isPremium || syncProfile.isPremiumFromOrganization,
hasPremiumPersonally = syncProfile.isPremium,
hasPremiumFromOrganization = syncProfile.isPremiumFromOrganization,
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
creationDate = syncProfile.creationDate,
userDecryptionOptions = userDecryptionOptions,
@@ -108,20 +119,34 @@ fun UserStateJson.toUpdatedUserStateJson(
* Updates the [UserStateJson] to set the `hasMasterPassword` value to `true` after a user sets
* their password.
*/
fun UserStateJson.toUserStateJsonWithPassword(): UserStateJson {
fun UserStateJson.toUserStateJsonWithPassword(
masterPasswordUnlock: MasterPasswordUnlockData?,
): UserStateJson {
val account = this.activeAccount
val profile = account.profile
val userDecryptionOptions = profile.userDecryptionOptions
val masterPasswordUnlockJson = masterPasswordUnlock
?.let {
MasterPasswordUnlockDataJson(
salt = it.salt,
kdf = it.kdf.toKdfRequestModel(),
masterKeyWrappedUserKey = it.masterKeyWrappedUserKey,
)
}
?: userDecryptionOptions?.masterPasswordUnlock
val updatedProfile = profile
.copy(
forcePasswordResetReason = null,
userDecryptionOptions = profile
.userDecryptionOptions
?.copy(hasMasterPassword = true)
userDecryptionOptions = userDecryptionOptions
?.copy(
hasMasterPassword = true,
masterPasswordUnlock = masterPasswordUnlockJson,
)
?: UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
masterPasswordUnlock = null,
masterPasswordUnlock = masterPasswordUnlockJson,
),
)
val updatedAccount = account.copy(profile = updatedProfile)
@@ -174,7 +199,7 @@ fun UserStateJson.toUserState(
isBiometricsEnabledProvider: (userId: String) -> Boolean,
vaultUnlockTypeProvider: (userId: String) -> VaultUnlockType,
isDeviceTrustedProvider: (userId: String) -> Boolean,
getUserPolicies: (userId: String, policy: PolicyTypeJson) -> List<SyncResponseJson.Policy>,
getUserPolicies: (userId: String, policy: PolicyType) -> List<PolicyView>,
): UserState =
UserState(
activeUserId = this.activeUserId,
@@ -217,15 +242,15 @@ fun UserStateJson.toUserState(
val hasPersonalOwnershipRestrictedOrg = getUserPolicies(
userId,
PolicyTypeJson.PERSONAL_OWNERSHIP,
PolicyType.ORGANIZATION_DATA_OWNERSHIP,
)
.any { it.isEnabled }
.any { it.enabled }
val hasPersonalVaultExportRestrictedOrg = getUserPolicies(
userId,
PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT,
PolicyType.DISABLE_PERSONAL_VAULT_EXPORT,
)
.any { it.isEnabled }
.any { it.enabled }
UserState.Account(
userId = userId,
@@ -236,7 +261,9 @@ fun UserStateJson.toUserState(
.settings
.environmentUrlData
.toEnvironmentUrlsOrDefault(),
isPremium = profile.hasPremium == true,
isPremium = profile.hasPremiumPersonally == true ||
profile.hasPremiumFromOrganization == true,
isPremiumFromSelf = profile.hasPremiumPersonally == true,
isLoggedIn = userAccountTokens
.find { it.userId == userId }
?.isLoggedIn == true,

View File

@@ -0,0 +1,41 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.network.model.AccountKeysJson
import com.bitwarden.network.model.AccountKeysJson.PublicKeyEncryptionKeyPair
import com.bitwarden.network.model.AccountKeysJson.SecurityState
import com.bitwarden.network.model.AccountKeysJson.SignatureKeyPair
/**
* The user's encryption private key, wrapped by the user key.
*/
val WrappedAccountCryptographicState.privateKey: String
get() = when (this) {
is WrappedAccountCryptographicState.V1 -> this.privateKey
is WrappedAccountCryptographicState.V2 -> this.privateKey
}
/**
* Converts the [WrappedAccountCryptographicState] into a [AccountKeysJson].
*
* @receiver `WrappedAccountCryptographicState` to convert to `AccountEncryptionKeysJson`.
*/
val WrappedAccountCryptographicState.accountKeysJson: AccountKeysJson?
get() = when (this) {
is WrappedAccountCryptographicState.V1 -> null
is WrappedAccountCryptographicState.V2 -> AccountKeysJson(
publicKeyEncryptionKeyPair = PublicKeyEncryptionKeyPair(
publicKey = "",
signedPublicKey = this.signedPublicKey,
wrappedPrivateKey = this.privateKey,
),
signatureKeyPair = SignatureKeyPair(
wrappedSigningKey = this.signingKey,
verifyingKey = "",
),
securityState = SecurityState(
securityState = this.securityState,
securityVersion = 2,
),
)
}

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