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 e8d731f6..0493d27e 100644 Binary files a/packages/kotlin-platform-sdk/.gradle/8.11.1/executionHistory/executionHistory.bin and b/packages/kotlin-platform-sdk/.gradle/8.11.1/executionHistory/executionHistory.bin differ 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 add70be7..7e926425 100644 Binary files a/packages/kotlin-platform-sdk/.gradle/8.11.1/executionHistory/executionHistory.lock and b/packages/kotlin-platform-sdk/.gradle/8.11.1/executionHistory/executionHistory.lock differ diff --git a/packages/kotlin-platform-sdk/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/packages/kotlin-platform-sdk/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 314d2406..e7a6002c 100644 Binary files a/packages/kotlin-platform-sdk/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/packages/kotlin-platform-sdk/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ 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 cd7d3f54..0488a241 100644 Binary files a/packages/kotlin-platform-sdk/.gradle/buildOutputCleanup/outputFiles.bin and b/packages/kotlin-platform-sdk/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/packages/kotlin-platform-sdk/.gradle/file-system.probe b/packages/kotlin-platform-sdk/.gradle/file-system.probe index e96c45ca..f16f03b3 100644 Binary files a/packages/kotlin-platform-sdk/.gradle/file-system.probe and b/packages/kotlin-platform-sdk/.gradle/file-system.probe differ diff --git a/packages/kotlin-platform-sdk/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties b/packages/kotlin-platform-sdk/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties index 1a3ead1a..f14dedbe 100644 --- a/packages/kotlin-platform-sdk/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties +++ b/packages/kotlin-platform-sdk/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties @@ -1 +1 @@ -#Thu Mar 19 15:13:01 PDT 2026 +#Thu Mar 19 17:40:07 PDT 2026 diff --git a/packages/kotlin-platform-sdk/build/kotlin/compileDebugKotlin/cacheable/last-build.bin b/packages/kotlin-platform-sdk/build/kotlin/compileDebugKotlin/cacheable/last-build.bin index 54263685..29aa19e7 100644 Binary files a/packages/kotlin-platform-sdk/build/kotlin/compileDebugKotlin/cacheable/last-build.bin and b/packages/kotlin-platform-sdk/build/kotlin/compileDebugKotlin/cacheable/last-build.bin differ diff --git a/packages/kotlin-platform-sdk/build/kotlin/compileDebugKotlin/local-state/build-history.bin b/packages/kotlin-platform-sdk/build/kotlin/compileDebugKotlin/local-state/build-history.bin index b039b3d3..abb27257 100644 Binary files a/packages/kotlin-platform-sdk/build/kotlin/compileDebugKotlin/local-state/build-history.bin and b/packages/kotlin-platform-sdk/build/kotlin/compileDebugKotlin/local-state/build-history.bin differ diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt new file mode 100644 index 00000000..5078f517 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt @@ -0,0 +1,208 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +/** + * Broadcast Client — In-app message client for Android. + * Part of ByteLystPlatformSDK. + */ +class BLBroadcastClient( + 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: 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)}" + } +}