From 90f8f64937d568d98c9f67eb07ca21af34b1c811 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 19 Mar 2026 18:11:27 -0700 Subject: [PATCH] ci: update CI/CD configuration --- .../WINDSURF/.last-refresh.log | 14 +- .../implement-shared-packages.md | 298 ++++++++++ .../executionHistory/executionHistory.bin | Bin 546093 -> 546093 bytes .../executionHistory/executionHistory.lock | Bin 17 -> 17 bytes .../buildOutputCleanup.lock | Bin 17 -> 17 bytes .../buildOutputCleanup/cache.properties | 2 +- .../buildOutputCleanup/outputFiles.bin | Bin 21053 -> 20081 bytes .../.gradle/file-system.probe | Bin 8 -> 8 bytes .../compile-file-map.properties | 2 +- .../cacheable/last-build.bin | Bin 18 -> 18 bytes .../local-state/build-history.bin | Bin 31 -> 31 bytes .../bytelyst/platform/BLBroadcastClient.kt | 208 +++++++ .../com/bytelyst/platform/BLFeedbackClient.kt | 267 +++++++++ .../com/bytelyst/platform/BLPasskeyManager.kt | 132 +++++ .../com/bytelyst/platform/BLSurveyClient.kt | 366 ++++++++++++ .../com/bytelyst/platform/DeepLinkRouter.kt | 172 ++++++ .../platform/diagnostics/BreadcrumbTrail.kt | 74 +++ .../diagnostics/DeviceStateCollector.kt | 114 ++++ .../platform/diagnostics/DiagnosticsClient.kt | 534 ++++++++++++++++++ .../platform/diagnostics/DiagnosticsTypes.kt | 152 +++++ .../diagnostics/NetworkInterceptor.kt | 120 ++++ 21 files changed, 2446 insertions(+), 9 deletions(-) create mode 100644 __LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/repo_workflows/learning_ai_common_plat/implement-shared-packages.md create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPasskeyManager.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/DeepLinkRouter.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt diff --git a/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/.last-refresh.log b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/.last-refresh.log index e58decd6..2949b5dd 100644 --- a/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/.last-refresh.log +++ b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/.last-refresh.log @@ -1,9 +1,9 @@ -Last refresh: 2026-03-19T06:00:07Z (2026-03-18 23:00:07 PDT) -Cascade conversations: 50 (397M) -Memories: 83 +Last refresh: 2026-03-20T00:28:47Z (2026-03-19 17:28:47 PDT) +Cascade conversations: 50 (376M) +Memories: 87 Implicit context: 20 -Code tracker dirs: 101 -File edit history: 3041 entries -Workspace storage: 33 workspaces +Code tracker dirs: 95 +File edit history: 3152 entries +Workspace storage: 34 workspaces Repo docs: 7 files across 2 repos -Repo workflows: 42 files across 10 repos +Repo workflows: 43 files across 10 repos diff --git a/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/repo_workflows/learning_ai_common_plat/implement-shared-packages.md b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/repo_workflows/learning_ai_common_plat/implement-shared-packages.md new file mode 100644 index 00000000..84b7f430 --- /dev/null +++ b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/repo_workflows/learning_ai_common_plat/implement-shared-packages.md @@ -0,0 +1,298 @@ +--- +description: Implement all 9 shared @bytelyst/* client packages from the SHARED_CLIENT_PACKAGES_ROADMAP +--- + +# Implement Shared @bytelyst/\* Client Packages + +## Pre-requisites + +// turbo + +1. Read the full roadmap doc: + +```bash +cat docs/roadmaps/SHARED_CLIENT_PACKAGES_ROADMAP.md +``` + +// turbo 2. Study the canonical reference package structure: + +```bash +cat packages/feature-flag-client/package.json packages/feature-flag-client/tsconfig.json packages/feature-flag-client/src/index.ts packages/feature-flag-client/src/types.ts packages/feature-flag-client/src/client.ts packages/feature-flag-client/src/client.test.ts +``` + +## Critical Rules + +- **Package manager is pnpm** — NEVER use npm +- **ESM everywhere** — `"type": "module"`, `.js` extensions in all imports +- **No Node.js deps** — use `globalThis.fetch`, not node-fetch +- **No React/RN deps** — pure TypeScript only +- **Factory pattern** for API clients: `createXxxClient(config)` returning an interface +- **No `console.log`**, no `any` type +- **Every request to platform-service MUST include headers:** + - `x-product-id` (from config.productId) + - `Authorization: Bearer ` (from config.getAccessToken()) +- **Tests in `src/client.test.ts`** (co-located, same as `@bytelyst/feature-flag-client`) +- **tsconfig.json must include `"lib": ["ES2022", "DOM"]`** and `"exclude": ["src/**/*.test.ts"]` +- **All API interfaces, backend types, and ⚠️ warnings are in the roadmap doc** — follow them exactly + +## Implementation (commit after each package) + +### Package 1: `packages/referral-client/` + +Create `packages/referral-client/` with: + +- `package.json` — `@bytelyst/referral-client`, version 0.1.0 +- `tsconfig.json` — extends ../../tsconfig.base.json, lib: ["ES2022", "DOM"] +- `src/types.ts` — ReferralDoc, ReferralClientConfig (from roadmap doc) +- `src/client.ts` — `createReferralClient(config)` factory +- `src/index.ts` — re-exports +- `src/client.test.ts` — 8+ Vitest tests + +⚠️ There is NO `/referrals/apply` endpoint. Use `PUT /referrals/:id` for status updates. + +// turbo 3. Verify package 1: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && pnpm --filter @bytelyst/referral-client build && pnpm --filter @bytelyst/referral-client test +``` + +4. Commit package 1: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && git add packages/referral-client/ && git commit -m "feat(referral-client): add @bytelyst/referral-client shared package" +``` + +### Package 2: `packages/subscription-client/` + +Create `packages/subscription-client/` with: + +- `package.json` — `@bytelyst/subscription-client`, version 0.1.0 +- `tsconfig.json` — extends ../../tsconfig.base.json, lib: ["ES2022", "DOM"] +- `src/types.ts` — SubscriptionDoc, PlanConfig, SubscriptionClientConfig (from roadmap doc) +- `src/client.ts` — `createSubscriptionClient(config)` factory with caching +- `src/index.ts` — re-exports +- `src/client.test.ts` — 10+ Vitest tests + +⚠️ `GET /plans` returns `{ plans: [...] }` — unwrap `.plans` in client. +⚠️ Routes use `:userId` not `:id` — use `config.userId`. +⚠️ `PlanConfig` fields `words`, `dictations`, `tokens` are LysnrAI-specific legacy. Use `features: string[]` for entitlements. + +// turbo 5. Verify package 2: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && pnpm --filter @bytelyst/subscription-client build && pnpm --filter @bytelyst/subscription-client test +``` + +6. Commit package 2: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && git add packages/subscription-client/ && git commit -m "feat(subscription-client): add @bytelyst/subscription-client shared package" +``` + +### Package 3: `packages/celebrations/` + +Create `packages/celebrations/` with: + +- `package.json` — `@bytelyst/celebrations`, version 0.1.0 +- `tsconfig.json` — extends ../../tsconfig.base.json, lib: ["ES2022", "DOM"] +- `src/types.ts` — CelebrationTrigger, Celebration, CelebrationConfig +- `src/client.ts` — `createCelebrationEngine(config?)` factory +- `src/index.ts` — re-exports +- `src/client.test.ts` — 8+ Vitest tests + +Pure TS, no backend. Products register custom triggers via `customTriggers` config. Messages ALWAYS positive. + +// turbo 7. Verify package 3: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && pnpm --filter @bytelyst/celebrations build && pnpm --filter @bytelyst/celebrations test +``` + +8. Commit package 3: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && git add packages/celebrations/ && git commit -m "feat(celebrations): add @bytelyst/celebrations shared package" +``` + +### Package 4: `packages/gentle-notifications/` + +Create `packages/gentle-notifications/` with: + +- `package.json` — `@bytelyst/gentle-notifications`, version 0.1.0 +- `tsconfig.json` — extends ../../tsconfig.base.json, lib: ["ES2022", "DOM"] +- `src/types.ts` — GentleNotificationConfig, GentleMessage +- `src/client.ts` — `createGentleNotificationEngine(config?)` factory + `FORBIDDEN_PHRASES` export +- `src/index.ts` — re-exports +- `src/client.test.ts` — 8+ Vitest tests + +Pure TS, no backend. Export `FORBIDDEN_PHRASES` constant. Support `registerMessages()` for product-specific pools. + +// turbo 9. Verify package 4: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && pnpm --filter @bytelyst/gentle-notifications build && pnpm --filter @bytelyst/gentle-notifications test +``` + +10. Commit package 4: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && git add packages/gentle-notifications/ && git commit -m "feat(gentle-notifications): add @bytelyst/gentle-notifications shared package" +``` + +### Package 5: `packages/accessibility/` + +Create `packages/accessibility/` with: + +- `package.json` — `@bytelyst/accessibility`, version 0.1.0 +- `tsconfig.json` — extends ../../tsconfig.base.json, lib: ["ES2022", "DOM"] +- `src/types.ts` — A11yProps interface +- `src/client.ts` — label generator functions (buttonLabel, timerLabel, progressLabel, etc.) +- `src/index.ts` — re-exports +- `src/client.test.ts` — 10+ Vitest tests + +Pure TS, no backend. Return A11yProps objects compatible with React Native accessibilityLabel/Role. + +// turbo 11. Verify package 5: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && pnpm --filter @bytelyst/accessibility build && pnpm --filter @bytelyst/accessibility test +``` + +12. Commit package 5: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && git add packages/accessibility/ && git commit -m "feat(accessibility): add @bytelyst/accessibility shared package" +``` + +### Package 6: `packages/quick-actions/` + +Create `packages/quick-actions/` with: + +- `package.json` — `@bytelyst/quick-actions`, version 0.1.0 +- `tsconfig.json` — extends ../../tsconfig.base.json, lib: ["ES2022", "DOM"] +- `src/types.ts` — QuickAction, ProgressiveSection, SmartDefault +- `src/client.ts` — getVisibleSections, getAvailableActions, pickSmartDefault +- `src/index.ts` — re-exports +- `src/client.test.ts` — 6+ Vitest tests + +Pure TS, no backend. + +// turbo 13. Verify package 6: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && pnpm --filter @bytelyst/quick-actions build && pnpm --filter @bytelyst/quick-actions test +``` + +14. Commit package 6: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && git add packages/quick-actions/ && git commit -m "feat(quick-actions): add @bytelyst/quick-actions shared package" +``` + +### Package 7: `packages/time-references/` + +Create `packages/time-references/` with: + +- `package.json` — `@bytelyst/time-references`, version 0.1.0 +- `tsconfig.json` — extends ../../tsconfig.base.json, lib: ["ES2022", "DOM"] +- `src/types.ts` — TimeReference interface +- `src/client.ts` — getTimeReference, getEpisodeComparison, getEncouragingMessage, registerReferences +- `src/index.ts` — re-exports +- `src/client.test.ts` — 6+ Vitest tests + +Pure TS, no backend. Support `registerReferences()` for custom reference databases. + +// turbo 15. Verify package 7: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && pnpm --filter @bytelyst/time-references build && pnpm --filter @bytelyst/time-references test +``` + +16. Commit package 7: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && git add packages/time-references/ && git commit -m "feat(time-references): add @bytelyst/time-references shared package" +``` + +### Package 8: `packages/org-client/` + +Create `packages/org-client/` with: + +- `package.json` — `@bytelyst/org-client`, version 0.1.0 +- `tsconfig.json` — extends ../../tsconfig.base.json, lib: ["ES2022", "DOM"] +- `src/types.ts` — OrganizationDoc, WorkspaceDoc, MembershipDoc, LicenseDoc, OrgClientConfig +- `src/client.ts` — `createOrgClient(config)` factory +- `src/index.ts` — re-exports +- `src/client.test.ts` — 10+ Vitest tests + +⚠️ All org routes require admin JWT role (`super_admin` or `admin`). Regular user tokens get 403. +Covers orgs + workspaces + memberships + licenses (4 entity types). + +// turbo 17. Verify package 8: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && pnpm --filter @bytelyst/org-client build && pnpm --filter @bytelyst/org-client test +``` + +18. Commit package 8: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && git add packages/org-client/ && git commit -m "feat(org-client): add @bytelyst/org-client shared package" +``` + +### Package 9: `packages/marketplace-client/` + +Create `packages/marketplace-client/` with: + +- `package.json` — `@bytelyst/marketplace-client`, version 0.1.0 +- `tsconfig.json` — extends ../../tsconfig.base.json, lib: ["ES2022", "DOM"] +- `src/types.ts` — MarketplaceListingDoc, MarketplaceReviewDoc, MarketplaceInstallDoc, MarketplaceClientConfig, CreateListingInput +- `src/client.ts` — `createMarketplaceClient(config)` factory +- `src/index.ts` — re-exports +- `src/client.test.ts` — 10+ Vitest tests + +⚠️ NomGap's influencer.ts is product-specific. This is the GENERIC marketplace client. +Covers listings + reviews + installs + reports. + +// turbo 19. Verify package 9: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && pnpm --filter @bytelyst/marketplace-client build && pnpm --filter @bytelyst/marketplace-client test +``` + +20. Commit package 9: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && git add packages/marketplace-client/ && git commit -m "feat(marketplace-client): add @bytelyst/marketplace-client shared package" +``` + +## Final Verification + +// turbo 21. Run full workspace verification: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && pnpm build && pnpm test && pnpm typecheck +``` + +22. Update roadmap status — in `docs/roadmaps/SHARED_CLIENT_PACKAGES_ROADMAP.md`, change `> **Status:** Not Started` to `> **Status:** ✅ Complete — 9 packages, ~76 tests` + +23. Final commit: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && git add docs/roadmaps/SHARED_CLIENT_PACKAGES_ROADMAP.md && git commit -m "docs: mark shared client packages roadmap as complete" +``` + +24. Push all commits: + +```bash +cd /Users/sd9235/code/mygh/learning_ai_common_plat && git push origin main +``` + +## DO NOT + +- Do NOT modify any files outside `packages/` and the roadmap doc +- Do NOT create packages in any other directory +- Do NOT install external dependencies — these packages have zero deps +- Do NOT skip tests — every package must have passing Vitest tests +- Do NOT modify platform-service or any product repo +- Do NOT use npm — this is a pnpm workspace diff --git a/packages/kotlin-platform-sdk/.gradle/8.11.1/executionHistory/executionHistory.bin b/packages/kotlin-platform-sdk/.gradle/8.11.1/executionHistory/executionHistory.bin index e8d731f6760f991c2c3063b358a13933282e06ad..0493d27ec9fc94d1acc7efcb95f98ac5945cd3be 100644 GIT binary patch delta 4113 zcmaJ^dsvRy7H_?6z4;PFnwoOROoMdw65^!L6e@DgT#(^&$#u;1oVl5q$Eg{2$GDC) zNFzhyxRh^bNH^rJ@BNA}p^KC*hg`;);lWvJzvleeANzTJ@9(#6d+oLNSNr$+?BDB? zJvPQaG%0lYjOqP8Oq!N7ZVm4zi1bknowv zVMl5+70deA9Q)`zN~UX_1lZZxKi2H*OrOx2&ueQNmDg3w2}`;bVo5u-H97C=bL9b+ zmE+8gZtKsD?mcSv7&x=@^!;&1%zrGdpq;#YKSHcvFk(V)m&G_RA|(CI2QTY~*&Z%cI1HO7!BZyy<~*)1kC!htaf#(~4{>ntstgM7h3Y;p@Mxj#{v&&+o~W zfT=U>#bYjI({z9Fl)>rgRpJqYJu~iz-x#c)xoh-80=o9NJ!f=?A4@5fuW3=Ql~3AA zIc?ZOcc17_1}^`yDefO?*5ZQ>mXxGX%8*o!;76+`ueKt5_vr|`?=4Ht%!*hS{7qfj z!vU71x*u!^ntLMpge*$onv@{!!d6-vc(oPK`mF!N#){D3)+Z%>7uZc)>}44=FH?w; zO4@4eNA=<~gNq-Z6DJ8+HYfM12;RA}t$?4|_9^h%5=$HE}8dgjK zCD+v-Dj+u^6XtgiCzchIVD=%nDQ+www9W#YI+)^r>qrLoqVQ(Gy#rfD@KRshv${ zC7;XmyA{R=dTh{lz&(Pjg|2 zX@?B6&KZ8f2cY!C<1tj;50YH%Oy!x6JNV*rCV!UX;X$yINjJQ2uXMtpqe!txrQQj) zU}S-VC%PY{nq{~Ak5ab|lZ?qnVVK|$SW^I9@GrX*7re@LlKan5c-!&3471rEQ#Hr# zcyPVi3FjOm3+C~k#~{ooJO3C&iA&6x$Iz)jUwwn^tF9cX5f z4=KIz-F$M+V0nH%$?1Q}KcUY+%6c)M4wYqHd)k2#CLE@O&IRNngD;9#`ny9 zN%6psLlg%rI7RVKvQ5xC>abULN+A z*D^c64wk(zJcjy~%{KBBeI`-yTrzgNK>gmu=-?zuAko?jf1X-!qB=|zuqy9!X{=(x zwTY^S!6jUzt{q^tHBk*WF`^dm_!P$~U9j~cco}l1OEAcIm&RP8{iLx=^<>pga9gnB z2WpIQ)n8tsH?5Sph*AhJxa3msH@J*a81AT(s%zH1xGvh!3tOWdP3U5U*Myb5`dTTv zL`73n%|K~Z@H25tT`LQO?UV%D5rcPDgHqcm|QgV1q(di({7t3*?h$WEg4BsIV&#+pPW{K|G&6%?q zdpDdsn`9=@;~I5IqPS}?z(6&#X*Q&Dr#W;ywQTp|8jKV*j56lXrX;GGLyt-obA{Jo zpox*LE%T->75AhrS$*tXarIoPj;(dADiU&73C2ag()m}js!Dq_@HD*Vv%4!9VffmYESV{X!?v@=_Uuh-ytOJIZBwehXy zKy&f#$$5q3r)x`Y1NH2|4RFKko8V^HU$hF%Kre67L^ShXG4Zz`v^^tTOW6+epoq6H zs+ux1v3g#og!Q-RQyM?lg)erm0vA)G^jK|ANfy|IJC3cQT}qo-HcBE48kT_GtftOob?hPmUPAuQG4Z8Ge> zoek-R;p1xBDjTA6xd{8e3%$#ig%{4Pp&ECvnp!RbaDNSLD}z6aYhZYLM!Lp1rE#xX za5vP*S{N#}OSf7&7wM5#OE0NJf7C(`v4z!cx1q0rVsAsR*vwqYZFtk*vTj2!@dI<^ zY$sKvj*#R+>Y%4#_w0)3ZJ=-K=rvlw9`99%UO2Ro%3aQ%_v+}$UdG5)A$njOkB77~ z-=X@Jvat0IRUkR*9q4bksr7V`AMvRM)wtH9)ZyB6- z163;3F%95r`a_;Ro#$pe&;TB|zJWUXJG*2xP&$dK8=#M1Lv+4N56piVMcjqn1~>mM zC6Vg3yD;83l}C5MSM1~Ry&I|ge={1_Nbw|E-bnGJdbp9|?Pc|DBNZ*V9@j;HffBBZ zt~jBIda|2MK5U{;nT#@;D3nCkn<$h-4$Ty57psGtsWi#WY^KsA+SE*^lE>=tX6l$k z_nN6=$CwMMq`41kp+r)MZ=vIo!lD-1hvas*fS-Y`w7`(yHTUvc3-<)OZud){+-}O( z8_SmR$aS{2bD6o)^w`!b%JWcBTGwAbIO56w2kpe%NiFbxdRzZU|0}Ao^88{*n|Qfp oKyj`|Oh9hcN@Z@kxUr(3WLaDvds>24J6aC3)ZDl}j@5nt2N3`rKL7v# delta 1903 zcmaJ=YfO`86z(ana`9Fbl&Oor#1$-vR*GXz1>!(a8OvtmGAAPCCMZKtt65rupcprm z$vdGTipr2Fp?=9~G?_uXB9V3?ky+YW+5#-+ImKPR%mAfZjDbFh?+FmAc9NSi?$mpzAJdK-}xqQ#1 ziQL|#crh`n&$QRHuL<o$^hM_ z*q>}oBJTKFomZj-hZ*Ek2#7>pqjHhRZzp0KL%oO1?Y;T2%14Vh?Tz}9tNZRgn9fyy zlI7zcp1LD#x3uR(ab|(L$9qT9D0|79jVsA(MiSDblGzis+-_8c|7fCdk*~7n`g42J zHhWzAOJi2mVY+euD%IttpZzwx0?s?*9FV`6VOwTEq}0=z=PyisQ@PxzQ>q)6rf+ST z5y*{Zz7Qu})37(k@NbS*;G@T{LBu2CJh>UVWQ)Xob$e$0ab#8T*I}j|NGl`9bIz?kkHjD!^<^W+uTQ~|;(Z9bwYb#N$So^g%m_~0 z8FNtOT371bT#>?Ql&i(d)EedxjtF7!dg6ITy;CAlHV^RC<+8172r6C+@78YDj zJi4DPC1yK`7qTnVBWf5t8Fr5?NgwW2L_K?Tb3w5vy<#S}w6KYg(`Z&>+wLTdjCAd| zOSDM1lO>CLqt0!wSC-|f%9RRvesT7$yd2f8(vq^g=$yQ5Ic1V^;{|cRcZWl3i#q>m z6-~SKyT5)c5e{YOB{KM!=p9X9^CP1sR|mbPgMR%`f_H?JKKvD4l1eisyB>pr9ANC( zeG*QiOdt`$$Zj&1=Gigiby?jclxkVEf{EKqu<$>FZXy@htbR!L`Jvy{VUVkXYK>$j zz19!D^hiHU^KBB+r+^+ckok181&vsab{cR@9Sd0}{l|b~V$B$yW#M`=L<%~!8RPXL^>FYv_QPT(tp7jyeThfwZJlRok^ZrAX<=2dO%`*S(`!b16q38 z3bW|}D@-F!rqeti5%i7~XE<1BvO>HsV@!OCiRaLSClDZr<9djU9(?jvtbKwPBlm_b{6$rRyp zvkEo#k|5!8cd~Gw9TXGp@GUnF0enxN+hHc{vEzaGy2}nr1Qz0e_x!lG=9Fq2XrALh z;U#9Y*8z(KrgPv6n2?$r5J7Z|O>)9~As6Gsx9}ZGCxnu#top4JqR17-I-EF;XJbwX z6ROfd{0UxW)r>)q3e~DXbmn!J2jNrU_LGMo)bBa!*v}G9(vl$vq@NGrf-FhDctqw2 zci_V_L+H;l!w~vEV-l}n+?>w^^^pjHrS_2kx^oym)CiNP`tW?sEOfK5wGU0L|A(W) zXv*t?U1;iJlFwYYG%r@WaA}_DTo5urjsGuPRSi%}C2k0yFI~9n8`k6N#$)nqg&PmY zlr-NBGQDa9K4_?EE14r%HT<}<0<`HpBhl^pz@#+2rCMCjKC1hZv9W L)~^fmu`c)*4(@lN diff --git a/packages/kotlin-platform-sdk/.gradle/8.11.1/executionHistory/executionHistory.lock b/packages/kotlin-platform-sdk/.gradle/8.11.1/executionHistory/executionHistory.lock index add70be78ae01b7998323ae7d81c1afccaf6c50f..7e926425991f25711a712da3040f5ce62a6d3240 100644 GIT binary patch literal 17 UcmZQRaklaA=eihO1_)>X07EGSkpKVy literal 17 UcmZQRaklaA=eihO1_+1*07C)=Y5)KL diff --git a/packages/kotlin-platform-sdk/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/packages/kotlin-platform-sdk/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 314d2406914f5fc5cdfd9da95fe143860cd34ea5..e7a6002c7690738d76bf11c0ec926e8dcae86d8d 100644 GIT binary patch literal 17 VcmZS9SnuS&S8$dn0~jzL0{|t+1F`@B literal 17 VcmZS9SnuS&S8$dn0~jzJ001Sx1FHZ4 diff --git a/packages/kotlin-platform-sdk/.gradle/buildOutputCleanup/cache.properties b/packages/kotlin-platform-sdk/.gradle/buildOutputCleanup/cache.properties index 9aa6ad31..f7fad254 100644 --- a/packages/kotlin-platform-sdk/.gradle/buildOutputCleanup/cache.properties +++ b/packages/kotlin-platform-sdk/.gradle/buildOutputCleanup/cache.properties @@ -1,2 +1,2 @@ -#Thu Mar 19 14:49:02 PDT 2026 +#Thu Mar 19 17:37:32 PDT 2026 gradle.version=8.11.1 diff --git a/packages/kotlin-platform-sdk/.gradle/buildOutputCleanup/outputFiles.bin b/packages/kotlin-platform-sdk/.gradle/buildOutputCleanup/outputFiles.bin index cd7d3f54dc99e3a801215033aeb3d0b9fda9ed21..0488a2418f425acaa367b1f9abdcfcc1efb206d9 100644 GIT binary patch literal 20081 zcmeI&YfMvT7{KwmY6Q8XD2NUW12Y8&LkOa-atZdd=M-Uh8xg69BwoOY3{Z65P!OG5 z%upF9Ol3|)5G+h!h=9WxV2B9lM62R-ii9x{W$0kf`_wG^W}I8h(*!vA_3hJh{^#WM zL-7=eObuVKhv?0|zn!4#w2>oxu%*NRh~1cVJ_|_HBW)!{$c0!_$Htet!^u?9SoD!n(cSTrGM+ ze4pOX8&U6qOUu!B=j8H6bJqL_E-OM$KAt)DOrpdATwb8@U%oXrsrSidbK=ocuFSG^ zd9KO;=ep4M`hAd@VEBZ6mm!~xo_cMAqM$l;@DK+c+)VTX)h10b9W`3ud^-BU`DR0_ zrg-#$b4v8gd=t08eXCm7dOh?kk9GSL68-Do(qQzXColG>T}Rq7f2{FK(zPAxKy7gO zLiFsEq-n)=HM^PLM=zYTzsG%1UN<;58U3_>pxUZ6kOLRYHGX$z^s$XeW0*_ORd%68 zPqsfA4=zJ~@tyeTs(UqQR&4$aeB#XVoX+-Bx+TnG(Mu;6`T2k5zYbj5fL?wic9c}2 zv;>!Rpr3Ob`E%obduMR@3XSKjml%iX@Zh`ydZm<)JUqcG2wW&buf07!#bI0s_K!wy z=*{XpVcK>R)=MMNFVC2|r8&`cADjO~_->R{=>yF=QO*|@D=F8Jal@W zAK#V+>lFgJ_5;O2RsQfi=8Mtw(&I}j>$b4@T)4)s{xEuzu5~!9mxZD0i}mO91h)r( z^B(91x}Ls`!AUvb(lm{yDi`d!8*9Vd4c(|btZGib`!jIP8{O<)jm6ZiaqRr^?daBu z+`M{z^?Fz@bfep5@@^?1$%WwjTy#5|J!=!(E{ehBedrF~_q%fGO%=>HpnupYWL;lW z$L?=}Hu}_W9#{Tko#P4Xxe|1zVk55hWHOt>C!jk^M8R7QH{tbm0o^6aDQVkIt(7oG zb`9OFJ0hah%!r*w>1}k6&rjVFOUBP)`?&{QesKAy9ecEUz!k&LeN>0M*2J{2`-Y%K z_q7;T=JoqCb{#6FqtDCVwL%ecp$X=2!_ob-mp0q0E3&|4Dd+)xt_#Xs%h>))PoOW2 z3UIax^EPGcP0&}&6KU6NuN89j94IpZmtdTqhwRvM3Md3g4#a&vH@9(}cwQrFu& zpY@l1jQ**sOTVh;0o#B6q{i=;G!);-(1AIe8Ty*{zFe#}4^uHeh8}*jIHm5l0!whd z5q+(}vgyjKM)r9jn}{B{HM=-hti4X=C?o-U1!} zibPsI@EZC}1*iZOpaN8Y3Qz$mKn17(6`%rCfC^9nDnJFO02QDDRDcRl0V+TRr~nn9 z0#twsPys4H1*iZOpaN8Y3cSezjstHt_>b#D*pEp6Z!*YlqYNC@zjR!$YckClD4@AA j|9Kt$w|(B^yT96dy~$Vo-_Fk4x<9Y$oW9zezpDNTYdw>A literal 21053 zcmeI&3p7>v9tZH#m{t_Wqm<`JhFN)3DkW;vMklx6l8(_kZvI zT89IRrJ?egyjZ`z)PLKgTQmWh08M}SVTYbS3@=HT}S;oaXQs`45x z!)~Gs^u6JpOJvKo+JOrL(IfZnJZG+S--6Uzqeo}nKXItA{!4IG=ua>K~K23 z*wpe>@loPF=wCX|Op8%@M!qW%=%FXJ?hqB!CccSqzJh-3}J=I0? z8zEnfdywI$ClK%BB zaFIXysoZM=9aa-fN&Ps+uX6$(cX%p*3(V0o#74nF9Wys@aS`JQaSKY#uIvZr@zHai zq`J5A464DUy66ShzCISWTf>=n5PG3;@_^mSyrU6p_R z_7`v`O~$+S1!wMzn?mX%(TmMCmOR_rqYKWzh5n7)^9|isQVhU3OBt7i`}_HJ9RU{) zqnEAC?(ICMSPsrRiC(U@(%vOU+X!3~hF)P(;_Uu+_id#AJ@hKJii^=z*Y%`c$hb`0 z?0@iZ2e@k)feURIKWAN*C7OSd^x4k%!#pQrr56?8qHoZftMYwY2MWk}5q*VzGve^2 zD6Q-;sONo#-ZIX4RkU|KIj$g=@d76~#|2m@7?$zaWl}p(r;Nr{Z_sZALYrMYt7`QMQ z{eD@}9XOCC!Ylb`hvo6?B3N&!G4I@Oa_)6@?@>{zLR9r#x%tzh<9>dhsswzM|TA zBQL$<;2dVYKUFqtzx7!}G&oNV>-!_uw6v_(Ed!UHMIQ_*c2;o+o(e8dLVw<{yQQaZ zqc7>R0DVkJQ%reIZ4IdpMweUi(sE9A5qZ7{70}1pPZ)f4COie|MQ!K`k3??8`Q!44 z_o9y-Ui3a-tbO9pU`addmRxktj3%!{y1LiiTUj`Ag8* za%{{}1%E@aGY}yX>!l#Va+?>3yTSV^L(xvF8X#(2>zxYC^ z=OMo~GddU$V|z`G_)2`kOxC+Q)=d}!E^$Gh5#$zB(w~$?{4eM;^Sm46djd9ti^rgw ze>-f&J<@yuoO=g-cCRqKZDl=qjtiyeb5HkQ{N6Cz0qS|J=oY1_+`8O&(nrvQKF@~b zv*$!J&i6EQ%dHl1;rryt^MYT1z97lI$cwe22>MI=(LXolPfwZEK-NFfndr7p0|Res zs*>xU!_?a;42MSjnDvN^*AVONcb&T{XQR6qoZE-)P`iIo}|wgjAsVQ+Gob~flGZD zZ_POKx7j?heiKbc7c1yxPBESaDL||+OlGB-c_vM za;h|;{+EKO30Mh^4lAH~Oz9~S#R String? = { null }, +) { + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val json = Json { ignoreUnknownKeys = true } + private var pollingJob: kotlinx.coroutines.Job? = null + + enum class Priority { + LOW, NORMAL, HIGH, URGENT + } + + enum class Style { + BANNER, MODAL, TOAST, FULLSCREEN + } + + enum class Status { + UNREAD, READ, DISMISSED + } + + @Serializable + data class InAppMessage( + val id: String, + val userId: String, + val productId: String, + val broadcastId: String, + val title: String, + val body: String, + val bodyMarkdown: String? = null, + val ctaText: String? = null, + val ctaUrl: String? = null, + val priority: String, + val style: String, + val dismissible: Boolean, + val expiresAt: String? = null, + val status: String, + val createdAt: String, + val updatedAt: String, + ) + + @Serializable + private data class MessagesResponse( + val messages: List + ) + + @Serializable + private data class ClickResponse( + val success: Boolean, + val redirectUrl: String? = null, + ) + + /** + * List active in-app messages for the current user. + */ + suspend fun listMessages(): Result> = withContext(Dispatchers.IO) { + try { + val request = buildRequest(path = "/broadcasts") + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + val result = json.decodeFromString(MessagesResponse.serializer(), body) + Result.success(result.messages) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Mark a message as read. + */ + suspend fun markRead(messageId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/broadcasts/$messageId/read", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Mark a message as dismissed. + */ + suspend fun markDismissed(messageId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/broadcasts/$messageId/dismiss", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Track a CTA click and get the redirect URL. + */ + suspend fun trackClick(messageId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/broadcasts/$messageId/click", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val body = response.body?.string() ?: return@withContext Result.success(null) + val result = json.decodeFromString(ClickResponse.serializer(), body) + Result.success(result.redirectUrl) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Start polling for new messages. + */ + fun startPolling( + intervalMs: Long = 60000L, + onUpdate: (List) -> Unit, + ) { + stopPolling() + pollingJob = kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { + while (isActive) { + listMessages() + .onSuccess { messages -> onUpdate(messages) } + .onFailure { /* Silently ignore polling errors */ } + delay(intervalMs) + } + } + } + + /** + * Stop polling for messages. + */ + fun stopPolling() { + pollingJob?.cancel() + pollingJob = null + } + + private fun buildRequest( + path: String, + method: String = "GET", + body: String? = null, + ): Request { + val url = "${config.baseUrl}$path" + val token = tokenProvider() ?: "" + + val builder = Request.Builder() + .url(url) + .header("Authorization", "Bearer $token") + .header("x-product-id", config.productId) + .header("x-platform", "android") + .header("x-app-version", config.appVersion) + .header("x-os-version", config.osVersion) + + if (body != null) { + builder.method(method, body.toRequestBody("application/json".toMediaType())) + } else if (method != "GET") { + builder.method(method, "".toRequestBody(null)) + } + + return builder.build() + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt new file mode 100644 index 00000000..efd58d4c --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt @@ -0,0 +1,267 @@ +package com.bytelyst.platform + +import android.content.Context +import android.graphics.Bitmap +import android.view.View +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.ByteArrayOutputStream +import java.util.Locale +import java.util.concurrent.TimeUnit + +/** + * Feedback client for submitting user feedback with optional screenshots. + * + * TODO-3: Full implementation for Android + * + * Flow: + * 1. Capture screenshot (optional) + * 2. Get SAS URL for upload + * 3. Upload screenshot to blob storage + * 4. Submit feedback with metadata + */ +class BLFeedbackClient( + private val config: BLPlatformConfig, + private val tokenProvider: () -> String? = { null }, +) { + enum class FeedbackType { + BUG, FEATURE, PRAISE, OTHER + } + + enum class ScreenshotFormat { + PNG, JPEG, WEBP + } + + data class DeviceContext( + val osVersion: String, + val appVersion: String, + val deviceModel: String, + val screenResolution: String, + val locale: String, + ) { + companion object { + fun fromContext(context: Context): DeviceContext { + val displayMetrics = context.resources.displayMetrics + return DeviceContext( + osVersion = android.os.Build.VERSION.RELEASE, + appVersion = context.packageManager.getPackageInfo( + context.packageName, 0 + ).versionName ?: "unknown", + deviceModel = "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}", + screenResolution = "${displayMetrics.widthPixels}x${displayMetrics.heightPixels}", + locale = Locale.getDefault().toString(), + ) + } + } + } + + @Serializable + data class SasResponse( + val blobPath: String, + val uploadUrl: String, + val expiresIn: Int, + val maxSizeBytes: Int, + ) + + @Serializable + data class FeedbackResponse( + val id: String, + val productId: String, + val userId: String, + val type: String, + val title: String, + val status: String, + val createdAt: String, + val screenshotBlobPath: String? = null, + ) + + data class FeedbackParams( + val type: FeedbackType, + val title: String, + val body: String? = null, + val screen: String? = null, + val rating: Int? = null, + val screenshot: Pair? = null, + val deviceContext: DeviceContext? = null, + ) + + private val json = Json { ignoreUnknownKeys = true } + private val platformClient = BLPlatformClient(config, tokenProvider) + private val uploadClient = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + /** + * Submit feedback with optional screenshot. + * + * TODO-3: Full implementation + */ + suspend fun submitFeedback(params: FeedbackParams): FeedbackResponse? = withContext(Dispatchers.IO) { + try { + // Step 1: Handle screenshot upload if provided + var screenshotMeta: Triple? = null + params.screenshot?.let { (data, format) -> + val contentType = when (format) { + ScreenshotFormat.PNG -> "image/png" + ScreenshotFormat.JPEG -> "image/jpeg" + ScreenshotFormat.WEBP -> "image/webp" + } + + // Get SAS URL + val sas = generateSASUrl(contentType) ?: return@withContext null + + // Upload screenshot + val uploaded = uploadScreenshot(data, sas.uploadUrl, contentType) + if (!uploaded) return@withContext null + + screenshotMeta = Triple(sas.blobPath, contentType, data.size) + } + + // Step 2: Submit feedback + val body = buildMap { + put("type", params.type.name.lowercase()) + put("title", params.title) + params.body?.let { put("body", it) } + params.screen?.let { put("screen", it) } + params.rating?.let { put("rating", it) } + screenshotMeta?.let { (path, type, size) -> + put("screenshotBlobPath", path) + put("screenshotContentType", type) + put("screenshotSizeBytes", size) + } + params.deviceContext?.let { ctx -> + put("deviceContext", mapOf( + "osVersion" to ctx.osVersion, + "appVersion" to ctx.appVersion, + "deviceModel" to ctx.deviceModel, + "screenResolution" to ctx.screenResolution, + "locale" to ctx.locale, + )) + } + } + + // TODO-3: Implement actual API call + throw NotImplementedError( + "submitFeedback API call not yet implemented. " + + "Use platformClient.request(\"POST\", \"/api/feedback\", jsonBody)" + ) + } catch (_: Exception) { + null + } + } + + /** + * Capture screenshot and submit feedback in one operation. + * + * TODO-3: Full implementation using MediaProjection or View.draw() + */ + suspend fun captureAndSubmit( + context: Context, + type: FeedbackType, + title: String, + body: String? = null, + ): FeedbackResponse? { + throw NotImplementedError( + "captureAndSubmit not yet implemented.\n\n" + + "To implement:\n" + + "1. Option A - MediaProjection API (requires permission):\n" + + " - Request MediaProjection permission\n" + + " - Use MediaProjection.createVirtualDisplay()\n" + + " - Capture ImageReader frame\n\n" + + "2. Option B - View.draw() (limited to app window):\n" + + " - val view = window.decorView.rootView\n" + + " - val bitmap = Bitmap.createBitmap(view.width, view.height)\n" + + " - val canvas = Canvas(bitmap)\n" + + " - view.draw(canvas)\n\n" + + "3. Convert Bitmap to ByteArray\n" + + "4. Call submitFeedback with screenshot" + ) + } + + /** + * Capture current screen as Bitmap. + * + * TODO-3: Full implementation + */ + fun captureScreen(): Bitmap { + throw NotImplementedError( + "captureScreen requires MediaProjection API. " + + "See: https://developer.android.com/reference/android/media/projection/MediaProjection" + ) + } + + /** + * Capture specific View as Bitmap. + * + * TODO-3: Full implementation + */ + fun captureView(view: View): Bitmap { + val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(bitmap) + view.draw(canvas) + return bitmap + } + + /** + * Convert Bitmap to PNG ByteArray. + */ + fun bitmapToBytes(bitmap: Bitmap, format: ScreenshotFormat = ScreenshotFormat.PNG): ByteArray { + val androidFormat = when (format) { + ScreenshotFormat.PNG -> Bitmap.CompressFormat.PNG + ScreenshotFormat.JPEG -> Bitmap.CompressFormat.JPEG + ScreenshotFormat.WEBP -> Bitmap.CompressFormat.WEBP_LOSSY + } + val stream = ByteArrayOutputStream() + bitmap.compress(androidFormat, 90, stream) + return stream.toByteArray() + } + + // MARK: - Private + + private suspend fun generateSASUrl(contentType: String): SasResponse? = withContext(Dispatchers.IO) { + try { + val body = json.encodeToString( + kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + kotlinx.serialization.builtins.serializer(), + ), + mapOf("contentType" to contentType), + ) + val response = platformClient.request("POST", "/api/feedback/sas", body) + json.decodeFromString(SasResponse.serializer(), response) + } catch (_: Exception) { + null + } + } + + private suspend fun uploadScreenshot( + data: ByteArray, + sasUrl: String, + contentType: String, + ): Boolean = withContext(Dispatchers.IO) { + try { + val request = Request.Builder() + .url(sasUrl) + .put(data.toRequestBody(contentType.toMediaType())) + .header("x-ms-blob-type", "BlockBlob") + .header("x-ms-blob-content-type", contentType) + .build() + + uploadClient.newCall(request).execute().use { response -> + response.isSuccessful + } + } catch (_: Exception) { + false + } + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPasskeyManager.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPasskeyManager.kt new file mode 100644 index 00000000..a14f5965 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPasskeyManager.kt @@ -0,0 +1,132 @@ +package com.bytelyst.platform + +import android.content.Context +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Passkey manager wrapping Android Credential Manager API. + * + * Handles FIDO2/WebAuthn passkey registration and authentication + * by coordinating between the platform-service backend and the + * Android Credential Manager. + * + * Usage: + * ```kotlin + * val manager = BLPasskeyManager(context, authClient) + * // Register a new passkey + * manager.registerPasskey("My Pixel 9") + * // Authenticate with an existing passkey + * val user = manager.authenticateWithPasskey() + * ``` + */ +class BLPasskeyManager( + private val context: Context, + private val authClient: BLAuthClient, +) { + private val credentialManager = CredentialManager.create(context) + private val json get() = authClient.client.json + + /** + * Register a new passkey for the current user. + * + * 1. Fetches registration options from backend + * 2. Invokes Credential Manager to create credential + * 3. Sends attestation response to backend for verification + * + * @param friendlyName Human-readable name for this passkey (e.g. "Pixel 9") + * @throws Exception if any step fails + */ + suspend fun registerPasskey(friendlyName: String) { + // Step 1: Get registration options from backend + val optionsResponse = authClient.client.request( + "POST", + "/api/auth/passkeys/register/options", + ) + + // Step 2: Create credential via Credential Manager + val request = CreatePublicKeyCredentialRequest( + requestJson = optionsResponse, + ) + val result = credentialManager.createCredential(context, request) + val credential = result as? androidx.credentials.CreatePublicKeyCredentialResponse + ?: throw IllegalStateException("Unexpected credential type") + + // Step 3: Send attestation to backend + val attestationJson = credential.registrationResponseJson + // Append friendlyName to the response + val bodyObj = json.decodeFromString(attestationJson) + val mutableMap = bodyObj.toMutableMap() + mutableMap["friendlyName"] = kotlinx.serialization.json.JsonPrimitive(friendlyName) + val body = json.encodeToString(JsonObject.serializer(), JsonObject(mutableMap)) + + authClient.client.request( + "POST", + "/api/auth/passkeys/register/verify", + body, + ) + } + + /** + * Authenticate using an existing passkey. + * + * 1. Fetches authentication options from backend + * 2. Invokes Credential Manager to select and sign with credential + * 3. Sends assertion response to backend for verification + * 4. Returns authenticated user and stores tokens + * + * @return Authenticated user + * @throws Exception if any step fails + */ + suspend fun authenticateWithPasskey(): BLAuthClient.AuthUser { + // Step 1: Get authentication options from backend + val optionsResponse = authClient.client.request( + "POST", + "/api/auth/passkeys/authenticate/options", + skipAuth = true, + ) + + // Step 2: Get credential via Credential Manager + val getRequest = GetCredentialRequest( + listOf(GetPublicKeyCredentialOption(requestJson = optionsResponse)), + ) + val result = credentialManager.getCredential(context, getRequest) + val credential = result.credential as? PublicKeyCredential + ?: throw IllegalStateException("Unexpected credential type") + + // Step 3: Send assertion to backend + val assertionJson = credential.authenticationResponseJson + val response = authClient.client.request( + "POST", + "/api/auth/passkeys/authenticate/verify", + assertionJson, + skipAuth = true, + ) + + // Step 4: Parse tokens and update auth state + val tokenResult = json.decodeFromString(response) + // Use reflection-free approach: directly set tokens + authClient.handleLoginResult(tokenResult) + return tokenResult.user + } + + /** + * List registered passkeys for the current user. + */ + suspend fun listPasskeys(): List { + val response = authClient.client.request("GET", "/api/auth/passkeys") + return json.decodeFromString>(response) + } + + /** + * Delete a passkey (requires step-up authentication). + */ + suspend fun deletePasskey(passkeyId: String) { + authClient.client.request("DELETE", "/api/auth/passkeys/$passkeyId") + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt new file mode 100644 index 00000000..21bb834c --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt @@ -0,0 +1,366 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +/** + * Survey Client — In-app survey client for Android. + * Part of ByteLystPlatformSDK. + */ +class BLSurveyClient( + private val config: BLPlatformConfig, + private val tokenProvider: () -> String? = { null }, +) { + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val json = Json { ignoreUnknownKeys = true } + private var pollingJob: Job? = null + private val responseCache = mutableMapOf() + + enum class QuestionType { + SINGLE_CHOICE, MULTIPLE_CHOICE, RATING, NPS, TEXT_SHORT, TEXT_LONG, DROPDOWN, SCALE, RANKING + } + + @Serializable + data class QuestionOption( + val id: String, + val text: String, + val emoji: String? = null, + ) + + @Serializable + data class Question( + val id: String, + val type: String, + val text: String, + val description: String? = null, + val required: Boolean, + val options: List? = null, + val minLength: Int? = null, + val maxLength: Int? = null, + val minValue: Int? = null, + val maxValue: Int? = null, + ) + + @Serializable + data class SurveyIncentive( + val type: String, + val amount: Int, + ) + + @Serializable + data class SurveyTrigger( + val type: String, + val seconds: Int? = null, + val eventName: String? = null, + val pagePattern: String? = null, + ) + + @Serializable + data class ActiveSurvey( + val id: String, + val title: String, + val description: String? = null, + val questions: List, + val incentive: SurveyIncentive? = null, + val displayTrigger: SurveyTrigger, + ) + + @Serializable + data class SurveyAnswer( + val type: String, + val value: JsonObject, + ) + + @Serializable + data class SurveyResponse( + val id: String, + val surveyId: String, + val userId: String, + val answers: Map, + val currentQuestionIndex: Int, + val startedAt: String, + val completedAt: String? = null, + val isComplete: Boolean, + val incentiveClaimed: Boolean, + val incentiveClaimedAt: String? = null, + val createdAt: String, + val updatedAt: String, + ) + + @Serializable + private data class ActiveSurveyResponse( + val survey: ActiveSurvey?, + ) + + @Serializable + private data class StartSurveyResponse( + val responseId: String, + val startedAt: String, + val currentQuestionIndex: Int, + val answers: Map, + ) + + @Serializable + private data class SubmitAnswerResponse( + val responseId: String, + val currentQuestionIndex: Int, + val answers: Map, + ) + + @Serializable + data class SurveyCompletionResult( + val success: Boolean, + val timeSpentSeconds: Int, + val incentiveClaimed: Boolean, + ) + + /** + * Get active survey for the current user (if any). + */ + suspend fun getActiveSurvey(): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest(path = "/surveys/active") + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + val result = json.decodeFromString(ActiveSurveyResponse.serializer(), body) + Result.success(result.survey) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Start a survey session. + */ + suspend fun startSurvey(surveyId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/surveys/$surveyId/start", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + val result = json.decodeFromString(StartSurveyResponse.serializer(), body) + + val surveyResponse = SurveyResponse( + id = result.responseId, + surveyId = surveyId, + userId = "", + answers = result.answers, + currentQuestionIndex = result.currentQuestionIndex, + startedAt = result.startedAt, + completedAt = null, + isComplete = false, + incentiveClaimed = false, + incentiveClaimedAt = null, + createdAt = result.startedAt, + updatedAt = result.startedAt, + ) + + // Cache the response + responseCache[surveyId] = surveyResponse + Result.success(surveyResponse) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Submit an answer to a survey question. + */ + suspend fun submitAnswer( + surveyId: String, + questionId: String, + answer: SurveyAnswer, + ): Result = withContext(Dispatchers.IO) { + try { + val body = json.encodeToString( + SubmitAnswerRequest.serializer(), + SubmitAnswerRequest(questionId, answer) + ) + + val request = buildRequest( + path = "/surveys/$surveyId/response", + method = "POST", + body = body + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + val result = json.decodeFromString(SubmitAnswerResponse.serializer(), responseBody) + + // Update cache + val cached = responseCache[surveyId] + if (cached != null) { + val updated = cached.copy( + answers = result.answers, + currentQuestionIndex = result.currentQuestionIndex, + ) + responseCache[surveyId] = updated + } + + Result.success( + SurveyResponse( + id = result.responseId, + surveyId = surveyId, + userId = "", + answers = result.answers, + currentQuestionIndex = result.currentQuestionIndex, + startedAt = "", + completedAt = null, + isComplete = false, + incentiveClaimed = false, + incentiveClaimedAt = null, + createdAt = "", + updatedAt = java.time.Instant.now().toString(), + ) + ) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Complete a survey. + */ + suspend fun completeSurvey(surveyId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/surveys/$surveyId/complete", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + val result = json.decodeFromString(SurveyCompletionResult.serializer(), body) + + // Clear cache on completion + responseCache.remove(surveyId) + Result.success(result) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Dismiss a survey (won't show again). + */ + suspend fun dismissSurvey(surveyId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/surveys/$surveyId/dismiss", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + // Clear cache + responseCache.remove(surveyId) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Get cached response for a survey. + */ + fun getCachedResponse(surveyId: String): SurveyResponse? { + return responseCache[surveyId] + } + + /** + * Start polling for eligible surveys. + */ + fun startPolling( + intervalMs: Long = 60000L, + onUpdate: (ActiveSurvey?) -> Unit, + ) { + stopPolling() + pollingJob = CoroutineScope(Dispatchers.IO).launch { + while (isActive) { + getActiveSurvey() + .onSuccess { survey -> onUpdate(survey) } + .onFailure { /* Silently ignore polling errors */ } + delay(intervalMs) + } + } + } + + /** + * Stop polling for surveys. + */ + fun stopPolling() { + pollingJob?.cancel() + pollingJob = null + } + + @Serializable + private data class SubmitAnswerRequest( + val questionId: String, + val answer: SurveyAnswer, + ) + + private fun buildRequest( + path: String, + method: String = "GET", + body: String? = null, + ): Request { + val url = "${config.baseUrl}$path" + val token = tokenProvider() ?: "" + + val builder = Request.Builder() + .url(url) + .header("Authorization", "Bearer $token") + .header("x-product-id", config.productId) + .header("x-platform", "android") + .header("x-app-version", config.appVersion) + .header("x-os-version", config.osVersion) + + if (body != null) { + builder.method(method, body.toRequestBody("application/json".toMediaTypeOrNull())) + } else if (method != "GET") { + builder.method(method, "".toRequestBody(null)) + } + + return builder.build() + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/DeepLinkRouter.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/DeepLinkRouter.kt new file mode 100644 index 00000000..7b5772b2 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/DeepLinkRouter.kt @@ -0,0 +1,172 @@ +package com.bytelyst.platform + +import android.net.Uri +import android.util.Log + +/** + * Deep Link Route data class + */ +data class DeepLinkRoute( + val screen: String, + val params: Map = emptyMap() +) + +/** + * Deep link handler type alias + */ +typealias DeepLinkHandler = (DeepLinkRoute) -> Unit + +/** + * Deep Link Router class + * Handles routing from push notification deep links to app screens + */ +class DeepLinkRouter { + private val handlers = mutableMapOf() + private var fallbackHandler: DeepLinkHandler? = null + + companion object { + private const val TAG = "DeepLinkRouter" + } + + /** + * Register a handler for a specific screen + */ + fun register(screen: String, handler: DeepLinkHandler) { + handlers[screen] = handler + } + + /** + * Set a fallback handler for unregistered screens + */ + fun setFallback(handler: DeepLinkHandler) { + fallbackHandler = handler + } + + /** + * Parse a deep link URL and extract route + */ + fun parseDeepLink(urlString: String): DeepLinkRoute? { + return try { + val uri = Uri.parse(urlString) + + // Handle app-specific URLs: myapp://screen/params + if (uri.scheme != "http" && uri.scheme != "https") { + val pathSegments = uri.pathSegments + val screen = pathSegments.firstOrNull() ?: "home" + + val params = mutableMapOf() + uri.queryParameterNames.forEach { key -> + uri.getQueryParameter(key)?.let { value -> + params[key] = value + } + } + + DeepLinkRoute(screen, params) + } + // Handle web URLs with deep link params + else if (uri.getQueryParameter("dl") != null) { + parseDeepLink(uri.getQueryParameter("dl")!!) + } + // Handle path-based routing: /screen/params + else { + val pathSegments = uri.pathSegments + if (pathSegments.isNotEmpty()) { + val screen = pathSegments[0] + + val params = mutableMapOf() + uri.queryParameterNames.forEach { key -> + uri.getQueryParameter(key)?.let { value -> + params[key] = value + } + } + + DeepLinkRoute(screen, params) + } else { + null + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse deep link: $urlString", e) + null + } + } + + /** + * Handle a deep link route + */ + fun handle(route: DeepLinkRoute): Boolean { + val handler = handlers[route.screen] + + return if (handler != null) { + handler(route) + true + } else if (fallbackHandler != null) { + fallbackHandler?.invoke(route) + true + } else { + Log.w(TAG, "No handler for screen: ${route.screen}") + false + } + } + + /** + * Process a deep link URL end-to-end + */ + fun process(urlString: String): Boolean { + val route = parseDeepLink(urlString) + return if (route != null) { + handle(route) + } else { + Log.w(TAG, "Failed to parse deep link: $urlString") + false + } + } +} + +/** + * Create a broadcast deep link URL + */ +fun createBroadcastDeepLink( + baseUrl: String, + screen: String, + params: Map = emptyMap(), + broadcastId: String? = null +): String { + val uriBuilder = Uri.parse(baseUrl).buildUpon() + .path("/$screen") + + params.forEach { (key, value) -> + uriBuilder.appendQueryParameter(key, value) + } + + broadcastId?.let { + uriBuilder.appendQueryParameter("broadcastId", it) + } + + return uriBuilder.build().toString() +} + +/** + * Common deep link screens + */ +object DeepLinkScreens { + // Broadcasts + const val BROADCAST = "broadcast" + const val ANNOUNCEMENTS = "announcements" + + // Surveys + const val SURVEY = "survey" + const val SURVEY_LIST = "surveys" + + // Product-specific + const val SETTINGS = "settings" + const val PROFILE = "profile" + const val UPGRADE = "upgrade" + const val SUPPORT = "support" + + // Fallback + const val HOME = "home" +} + +// Singleton instance for app-wide use +val deepLinkRouter = DeepLinkRouter() diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt new file mode 100644 index 00000000..c8286cde --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt @@ -0,0 +1,74 @@ +package com.bytelyst.platform.diagnostics + +import java.text.SimpleDateFormat +import java.util.* + +/** + * Ring buffer for breadcrumbs with fixed max size + */ +class BreadcrumbTrail(private val maxSize: Int = 100) { + private val breadcrumbs = mutableListOf() + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + /** + * Add a breadcrumb to the trail + */ + @Synchronized + fun add(category: String, message: String, data: Map? = null) { + val breadcrumb = DiagnosticsBreadcrumb( + timestamp = dateFormat.format(Date()), + category = category, + message = message, + data = data + ) + + breadcrumbs.add(breadcrumb) + + // Evict oldest if over limit + if (breadcrumbs.size > maxSize) { + breadcrumbs.removeAt(0) + } + } + + /** + * Get all breadcrumbs (oldest first) + */ + @Synchronized + fun getAll(): List { + return breadcrumbs.toList() + } + + /** + * Get last N breadcrumbs + */ + @Synchronized + fun getLast(n: Int): List { + return breadcrumbs.takeLast(n) + } + + /** + * Get most recent breadcrumb + */ + @Synchronized + fun getMostRecent(): DiagnosticsBreadcrumb? { + return breadcrumbs.lastOrNull() + } + + /** + * Clear all breadcrumbs + */ + @Synchronized + fun clear() { + breadcrumbs.clear() + } + + /** + * Get current size + */ + @Synchronized + fun size(): Int { + return breadcrumbs.size + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt new file mode 100644 index 00000000..4da2ec81 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt @@ -0,0 +1,114 @@ +package com.bytelyst.platform.diagnostics + +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.BatteryManager +import android.os.Build +import android.os.StatFs + +/** + * Device state collector for Android + */ +object DeviceStateCollector { + + /** + * Collect current device state + */ + fun collect(context: Context): DiagnosticsDeviceState { + return DiagnosticsDeviceState( + memoryMB = getMemoryUsage(context), + batteryLevel = getBatteryLevel(context), + isCharging = getIsCharging(context), + storageMB = getStorageUsage(context), + networkType = getNetworkType(context), + isOnline = getIsOnline(context), + thermalState = null // Android doesn't expose thermal state easily + ) + } + + private fun getMemoryUsage(context: Context): Int? { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + ?: return null + + val runtime = Runtime.getRuntime() + val usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024) + + return usedMemory.toInt() + } + + private fun getBatteryLevel(context: Context): Float? { + val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + ?: return null + + val level = batteryIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale = batteryIntent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) + + if (level == -1 || scale == -1) return null + + return level / scale.toFloat() + } + + private fun getIsCharging(context: Context): Boolean? { + val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + ?: return null + + val status = batteryIntent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) + return status == BatteryManager.BATTERY_STATUS_CHARGING || + status == BatteryManager.BATTERY_STATUS_FULL + } + + private fun getStorageUsage(context: Context): Int? { + val stat = StatFs(context.filesDir.path) + val blockSize = stat.blockSizeLong + val availableBlocks = stat.availableBlocksLong + val totalBlocks = stat.blockCountLong + + val usedBytes = (totalBlocks - availableBlocks) * blockSize + return (usedBytes / (1024 * 1024)).toInt() + } + + private fun getNetworkType(context: Context): String? { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return null + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork ?: return "offline" + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return "offline" + + when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + else -> "unknown" + } + } else { + @Suppress("DEPRECATION") + val networkInfo = connectivityManager.activeNetworkInfo + when (networkInfo?.type) { + ConnectivityManager.TYPE_WIFI -> "wifi" + ConnectivityManager.TYPE_MOBILE -> "cellular" + ConnectivityManager.TYPE_ETHERNET -> "ethernet" + else -> if (networkInfo?.isConnected == true) "unknown" else "offline" + } + } + } + + private fun getIsOnline(context: Context): Boolean { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return true // Assume online if can't determine + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(network) + capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + } else { + @Suppress("DEPRECATION") + val networkInfo = connectivityManager.activeNetworkInfo + networkInfo?.isConnected == true + } + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt new file mode 100644 index 00000000..fd256765 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt @@ -0,0 +1,534 @@ +package com.bytelyst.platform.diagnostics + +import android.content.Context +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.TimeUnit + +/** + * Client state + */ +sealed class DiagnosticsClientState { + object Idle : DiagnosticsClientState() + data class Polling(val session: DiagnosticsSession?) : DiagnosticsClientState() + data class Active(val session: DiagnosticsSession) : DiagnosticsClientState() + data class Error(val exception: Throwable) : DiagnosticsClientState() +} + +/** + * Diagnostics client configuration + */ +data class DiagnosticsConfiguration( + val productId: String, + val userId: String? = null, + val anonymousInstallId: String, + val platform: String, + val channel: String, + val osFamily: String, + val appVersion: String, + val buildNumber: String, + val releaseChannel: String, + val serverUrl: String, + val pollIntervalMs: Long = 5000, + val maxBreadcrumbs: Int = 100, + val captureConsole: Boolean = true, + val captureErrors: Boolean = true, + val captureNetwork: Boolean = true, + val getAuthToken: (suspend () -> String)? = null +) + +/** + * Logger interface + */ +interface DiagnosticsLogger { + fun debug(message: String, metadata: Map? = null) + fun info(message: String, metadata: Map? = null) + fun warn(message: String, metadata: Map? = null) + fun error(message: String, metadata: Map? = null) +} + +/** + * No-op logger + */ +class NoOpDiagnosticsLogger : DiagnosticsLogger { + override fun debug(message: String, metadata: Map?) {} + override fun info(message: String, metadata: Map?) {} + override fun warn(message: String, metadata: Map?) {} + override fun error(message: String, metadata: Map?) {} +} + +/** + * Android Log-based logger + */ +class AndroidDiagnosticsLogger(private val tag: String = "ByteLystDiagnostics") : DiagnosticsLogger { + override fun debug(message: String, metadata: Map?) { + android.util.Log.d(tag, message) + } + override fun info(message: String, metadata: Map?) { + android.util.Log.i(tag, message) + } + override fun warn(message: String, metadata: Map?) { + android.util.Log.w(tag, message) + } + override fun error(message: String, metadata: Map?) { + android.util.Log.e(tag, message) + } +} + +/** + * Main diagnostics client + */ +class DiagnosticsClient private constructor( + private val context: Context, + private val config: DiagnosticsConfiguration, + private val logger: DiagnosticsLogger +) { + companion object { + @Volatile + private var instance: DiagnosticsClient? = null + + fun getInstance( + context: Context, + config: DiagnosticsConfiguration, + logger: DiagnosticsLogger = NoOpDiagnosticsLogger() + ): DiagnosticsClient { + return instance ?: synchronized(this) { + instance ?: DiagnosticsClient(context.applicationContext, config, logger).also { + instance = it + } + } + } + + fun reset() { + instance?.stop() + instance = null + } + } + + private val _state = MutableStateFlow(DiagnosticsClientState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val breadcrumbs = BreadcrumbTrail(maxSize = config.maxBreadcrumbs) + private val logBuffer = mutableListOf() + private val traceBuffer = mutableListOf() + private val networkBuffer = mutableListOf() + + private var pollJob: Job? = null + private var flushJob: Job? = null + private var networkInterceptor: NetworkInterceptor? = null + private var lastEtag: String? = null + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val json = Json { ignoreUnknownKeys = true } + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + /** + * Start polling for active debug sessions + */ + fun start() { + if (_state.value != DiagnosticsClientState.Idle) { + logger.warn("[diagnostics] Already started") + return + } + + logger.info("[diagnostics] Starting diagnostics client") + _state.value = DiagnosticsClientState.Polling(null) + + // Initial poll + scope.launch { + pollForSession() + } + + // Start polling timer + pollJob = scope.launch { + while (isActive) { + delay(config.pollIntervalMs) + pollForSession() + } + } + + // Start auto-flush timer (every 30 seconds) + flushJob = scope.launch { + while (isActive) { + delay(30000) + flush() + } + } + + // Setup network capture if enabled + if (config.captureNetwork) { + setupNetworkCapture() + } + + breadcrumbs.add(category = "diagnostics", message = "Client started") + } + + /** + * Stop polling and cleanup + */ + fun stop() { + logger.info("[diagnostics] Stopping diagnostics client") + + pollJob?.cancel() + pollJob = null + + flushJob?.cancel() + flushJob = null + + networkInterceptor?.stop() + networkInterceptor = null + + // Final flush + scope.launch { + flush() + } + + _state.value = DiagnosticsClientState.Idle + breadcrumbs.add(category = "diagnostics", message = "Client stopped") + } + + /** + * Check if a debug session is currently active + */ + fun isSessionActive(): Boolean { + return _state.value is DiagnosticsClientState.Active + } + + /** + * Get current session if active + */ + fun getCurrentSession(): DiagnosticsSession? { + return when (val current = _state.value) { + is DiagnosticsClientState.Active -> current.session + is DiagnosticsClientState.Polling -> current.session + else -> null + } + } + + /** + * Record a log entry + */ + fun log( + level: DiagnosticsLogLevel, + message: String, + module: String = "unknown", + file: String? = null, + line: Int? = null, + function: String? = null, + context: Map = emptyMap(), + correlationId: String? = null + ) { + val entry = DiagnosticsLogEntry( + level = level, + message = message, + timestamp = dateFormat.format(Date()), + module = module, + file = file, + line = line, + function = function, + context = context, + correlationId = correlationId + ) + + synchronized(logBuffer) { + logBuffer.add(entry) + } + + breadcrumbs.add( + category = "log", + message = "[${level.name}] ${message.take(100)}", + data = mapOf("level" to level.name) + ) + + // Auto-flush on fatal + if (level == DiagnosticsLogLevel.FATAL) { + scope.launch { flush() } + } + } + + /** + * Record a trace span (auto-instrumented) + */ + suspend fun trace(name: String, operation: suspend () -> T): T { + val spanId = generateId() + val startTime = dateFormat.format(Date()) + + breadcrumbs.add( + category = "trace", + message = "Starting: $name", + data = mapOf("spanId" to spanId) + ) + + return try { + val result = operation() + val endTime = dateFormat.format(Date()) + val durationMs = calculateDuration(startTime, endTime) + + val span = DiagnosticsTraceSpan( + spanId = spanId, + name = name, + startTime = startTime, + endTime = endTime, + durationMs = durationMs, + status = DiagnosticsSpanStatus.OK + ) + + synchronized(traceBuffer) { + traceBuffer.add(span) + } + + breadcrumbs.add( + category = "trace", + message = "Completed: $name", + data = mapOf("spanId" to spanId, "durationMs" to durationMs.toString()) + ) + + result + } catch (e: Exception) { + val endTime = dateFormat.format(Date()) + val durationMs = calculateDuration(startTime, endTime) + + val span = DiagnosticsTraceSpan( + spanId = spanId, + name = name, + startTime = startTime, + endTime = endTime, + durationMs = durationMs, + status = DiagnosticsSpanStatus.ERROR, + statusMessage = e.message + ) + + synchronized(traceBuffer) { + traceBuffer.add(span) + } + + breadcrumbs.add( + category = "trace", + message = "Failed: $name", + data = mapOf("spanId" to spanId, "error" to (e.message ?: "Unknown")) + ) + + throw e + } + } + + /** + * Add a manual breadcrumb + */ + fun breadcrumb(category: String, message: String, data: Map? = null) { + breadcrumbs.add(category = category, message = message, data = data) + } + + /** + * Get all breadcrumbs + */ + fun getBreadcrumbs(): List { + return breadcrumbs.getAll() + } + + /** + * Collect and return device state + */ + fun collectDeviceState(): DiagnosticsDeviceState { + return DeviceStateCollector.collect(context) + } + + // Private methods + + private suspend fun pollForSession() { + try { + val url = "${config.serverUrl}/api/diagnostics/config" + + "?productId=${config.productId}" + + "&installId=${config.anonymousInstallId}" + + val requestBuilder = Request.Builder() + .url(url) + .header("Accept", "application/json") + + lastEtag?.let { etag -> + requestBuilder.header("If-None-Match", etag) + } + + config.getAuthToken?.let { getToken -> + try { + val token = getToken() + requestBuilder.header("Authorization", "Bearer $token") + } catch (e: Exception) { + logger.error("[diagnostics] Failed to get auth token", mapOf("error" to e.message)) + } + } + + val request = requestBuilder.build() + + httpClient.newCall(request).execute().use { response -> + if (response.code == 304) { + // No change + return + } + + if (!response.isSuccessful) { + throw IOException("HTTP ${response.code}") + } + + // Store ETag + response.header("ETag")?.let { etag -> + lastEtag = etag + } + + val body = response.body?.string() + val session = body?.let { + try { + json.decodeFromString(it) + } catch (e: Exception) { + null + } + } + + // Update state + if (session != null && session.status == DiagnosticsSessionStatus.ACTIVE) { + if (_state.value !is DiagnosticsClientState.Active) { + logger.info("[diagnostics] Session activated", mapOf("sessionId" to session.id)) + breadcrumbs.add( + category = "diagnostics", + message = "Session activated", + data = mapOf("sessionId" to session.id) + ) + } + _state.value = DiagnosticsClientState.Active(session) + } else { + if (_state.value is DiagnosticsClientState.Active) { + logger.info("[diagnostics] Session ended") + breadcrumbs.add(category = "diagnostics", message = "Session ended") + } + _state.value = DiagnosticsClientState.Polling(null) + } + } + } catch (e: Exception) { + logger.error("[diagnostics] Failed to poll for session", mapOf("error" to e.message)) + _state.value = DiagnosticsClientState.Error(e) + } + } + + private suspend fun flush() { + val session = getCurrentSession() + if (session == null) { + // No active session, clear buffers + synchronized(logBuffer) { logBuffer.clear() } + synchronized(traceBuffer) { traceBuffer.clear() } + synchronized(networkBuffer) { networkBuffer.clear() } + return + } + + // Build batch + val batch = DiagnosticsIngestBatch( + sessionId = session.id, + traces = synchronized(traceBuffer) { + if (traceBuffer.isEmpty()) null else traceBuffer.take(50).also { + repeat(it.size) { traceBuffer.removeAt(0) } + } + }, + logs = synchronized(logBuffer) { + if (logBuffer.isEmpty()) null else logBuffer.take(50).also { + repeat(it.size) { logBuffer.removeAt(0) } + } + }, + network = synchronized(networkBuffer) { + if (networkBuffer.isEmpty()) null else networkBuffer.take(50).also { + repeat(it.size) { networkBuffer.removeAt(0) } + } + }, + breadcrumbs = breadcrumbs.getAll().takeIf { it.isNotEmpty() }?.also { + breadcrumbs.clear() + } + ) + + // Skip if nothing to send + if (batch.traces == null && batch.logs == null && batch.network == null && batch.breadcrumbs == null) { + return + } + + try { + val url = "${config.serverUrl}/api/diagnostics/ingest" + + val requestBody = json.encodeToString(batch) + .toRequestBody("application/json".toMediaType()) + + val requestBuilder = Request.Builder() + .url(url) + .post(requestBody) + + config.getAuthToken?.let { getToken -> + try { + val token = getToken() + requestBuilder.header("Authorization", "Bearer $token") + } catch (e: Exception) { + logger.error("[diagnostics] Failed to get auth token for flush", mapOf("error" to e.message)) + } + } + + val request = requestBuilder.build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IOException("HTTP ${response.code}") + } + + logger.debug( + "[diagnostics] Flushed batch", + mapOf( + "logs" to (batch.logs?.size ?: 0), + "traces" to (batch.traces?.size ?: 0), + "network" to (batch.network?.size ?: 0) + ) + ) + } + } catch (e: Exception) { + logger.error("[diagnostics] Failed to flush batch", mapOf("error" to e.message)) + + // Put items back in buffers for retry + synchronized(logBuffer) { batch.logs?.let { logBuffer.addAll(0, it) } } + synchronized(traceBuffer) { batch.traces?.let { traceBuffer.addAll(0, it) } } + synchronized(networkBuffer) { batch.network?.let { networkBuffer.addAll(0, it) } } + } + } + + private fun setupNetworkCapture() { + networkInterceptor = NetworkInterceptor { request -> + synchronized(networkBuffer) { + networkBuffer.add(request) + } + } + networkInterceptor?.start(httpClient) + breadcrumbs.add(category = "diagnostics", message = "Network capture enabled") + } + + private fun generateId(): String { + return "${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(7)}" + } + + private fun calculateDuration(startTime: String, endTime: String): Double { + return try { + val start = dateFormat.parse(startTime)?.time ?: 0 + val end = dateFormat.parse(endTime)?.time ?: 0 + (end - start).toDouble() + } catch (e: Exception) { + 0.0 + } + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt new file mode 100644 index 00000000..2e63fd1c --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt @@ -0,0 +1,152 @@ +package com.bytelyst.platform.diagnostics + +import kotlinx.serialization.Serializable + +/** + * Log severity levels (matches syslog/OpenTelemetry) + */ +enum class DiagnosticsLogLevel { + DEBUG, INFO, WARN, ERROR, FATAL +} + +/** + * Session status from the server + */ +enum class DiagnosticsSessionStatus { + PENDING, ACTIVE, PAUSED, COMPLETED, CANCELLED +} + +/** + * Collection level determines verbosity of captured data + */ +enum class DiagnosticsCollectionLevel { + STANDARD, DEBUG, TRACE +} + +/** + * Diagnostic session configuration from server + */ +@Serializable +data class DiagnosticsSession( + val id: String, + val productId: String, + val status: DiagnosticsSessionStatus, + val collectionLevel: DiagnosticsCollectionLevel, + val captureLogs: Boolean, + val captureNetwork: Boolean, + val captureScreenshots: Boolean, + val screenshotOnError: Boolean, + val maxDurationMinutes: Int, + val createdAt: String, + val expiresAt: String +) + +/** + * Span kind for OpenTelemetry compatibility + */ +enum class DiagnosticsSpanKind { + INTERNAL, SERVER, CLIENT, PRODUCER, CONSUMER +} + +/** + * Span status + */ +enum class DiagnosticsSpanStatus { + OK, ERROR, UNSET +} + +/** + * OpenTelemetry-compatible trace span + */ +@Serializable +data class DiagnosticsTraceSpan( + val spanId: String, + val parentId: String? = null, + val name: String, + val kind: DiagnosticsSpanKind? = null, + val startTime: String, + val endTime: String? = null, + val durationMs: Double? = null, + val attributes: Map = emptyMap(), + val status: DiagnosticsSpanStatus, + val statusMessage: String? = null +) + +/** + * Structured log entry + */ +@Serializable +data class DiagnosticsLogEntry( + val level: DiagnosticsLogLevel, + val message: String, + val timestamp: String, + val module: String, + val file: String? = null, + val line: Int? = null, + val function: String? = null, + val context: Map = emptyMap(), + val correlationId: String? = null +) + +/** + * Breadcrumb for timeline navigation + */ +@Serializable +data class DiagnosticsBreadcrumb( + val timestamp: String, + val category: String, + val message: String, + val data: Map? = null +) + +/** + * Network request/response capture + */ +@Serializable +data class DiagnosticsNetworkRequest( + val id: String, + val url: String, + val method: String, + val requestHeaders: Map = emptyMap(), + val requestBody: String? = null, + val status: Int? = null, + val responseHeaders: Map? = null, + val responseBody: String? = null, + val startTime: String, + val endTime: String? = null, + val durationMs: Double? = null, + val error: String? = null +) + +/** + * Device state snapshot + */ +@Serializable +data class DiagnosticsDeviceState( + val memoryMB: Int? = null, + val batteryLevel: Float? = null, + val isCharging: Boolean? = null, + val storageMB: Int? = null, + val networkType: String? = null, + val isOnline: Boolean, + val thermalState: DiagnosticsThermalState? = null +) + +/** + * Thermal state + */ +enum class DiagnosticsThermalState { + NOMINAL, FAIR, SERIOUS, CRITICAL +} + +/** + * Ingest batch for sending to server + */ +@Serializable +data class DiagnosticsIngestBatch( + val sessionId: String, + val traces: List? = null, + val logs: List? = null, + val breadcrumbs: List? = null, + val network: List? = null +) diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt new file mode 100644 index 00000000..3233937c --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt @@ -0,0 +1,120 @@ +package com.bytelyst.platform.diagnostics + +import okhttp3.* +import okhttp3.Interceptor.Chain +import okio.Buffer +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.* + +/** + * Network interceptor for OkHttp to capture HTTP requests/responses + */ +class NetworkInterceptor( + private val onRequest: (DiagnosticsNetworkRequest) -> Unit +) : Interceptor { + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private var isActive = false + private lateinit var httpClient: OkHttpClient + + fun start(client: OkHttpClient) { + this.httpClient = client + isActive = true + } + + fun stop() { + isActive = false + } + + override fun intercept(chain: Chain): Response { + if (!isActive) { + return chain.proceed(chain.request()) + } + + val request = chain.request() + val requestId = generateId() + val startTime = System.currentTimeMillis() + + // Capture request details + val requestHeaders = mutableMapOf() + request.headers.forEach { name, value -> + requestHeaders[name] = sanitizeHeader(value, name) + } + + val requestBody = request.body?.let { body -> + val buffer = Buffer() + try { + body.writeTo(buffer) + buffer.readUtf8() + } catch (e: Exception) { + null + } + } + + // Proceed with request + val response: Response + try { + response = chain.proceed(request) + } catch (e: Exception) { + // Capture failed request + val networkRequest = DiagnosticsNetworkRequest( + id = requestId, + url = request.url.toString().take(2048), + method = request.method, + requestHeaders = requestHeaders, + requestBody = requestBody?.take(100 * 1024), // Limit to 100KB + startTime = dateFormat.format(Date(startTime)), + endTime = dateFormat.format(Date()), + durationMs = (System.currentTimeMillis() - startTime).toDouble(), + error = e.message + ) + onRequest(networkRequest) + throw e + } + + // Capture response + val endTime = System.currentTimeMillis() + val responseHeaders = mutableMapOf() + response.headers.forEach { name, value -> + responseHeaders[name] = sanitizeHeader(value, name) + } + + val networkRequest = DiagnosticsNetworkRequest( + id = requestId, + url = request.url.toString().take(2048), + method = request.method, + requestHeaders = requestHeaders, + requestBody = requestBody?.take(100 * 1024), + status = response.code, + responseHeaders = responseHeaders, + responseBody = null, // Don't capture response body (too large) + startTime = dateFormat.format(Date(startTime)), + endTime = dateFormat.format(Date(endTime)), + durationMs = (endTime - startTime).toDouble(), + error = null + ) + + onRequest(networkRequest) + + return response + } + + private fun sanitizeHeader(value: String, key: String): String { + val sensitivePatterns = listOf("authorization", "cookie", "token", "api-key") + val lowerKey = key.lowercase() + + for (pattern in sensitivePatterns) { + if (lowerKey.contains(pattern)) { + return "[REDACTED]" + } + } + return value + } + + private fun generateId(): String { + return "${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(7)}" + } +}