From 35487137e19d5f999c4f4708d7b7bc293cc2ef24 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 19 Mar 2026 18:25:35 -0700 Subject: [PATCH] =?UTF-8?q?feat(kotlin-sdk):=20restore=2013=20deferred=20U?= =?UTF-8?q?I=20files=20=E2=80=94=20diagnostics,=20clients,=20Compose=20UI,?= =?UTF-8?q?=20passkeys,=20deep=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move 5 diagnostics files into src/main/.../diagnostics/ (DiagnosticsTypes, DiagnosticsClient, BreadcrumbTrail, DeviceStateCollector, NetworkInterceptor) - Move 3 API clients (BLBroadcastClient, BLSurveyClient, BLFeedbackClient) — fix toMediaType, serializer pattern, coroutine imports - Move 2 pure Kotlin files (DeepLinkRouter, BLPasskeyManager) - Move 3 Compose UI files into src/main/.../ui/ (BLAuthUI, SurveyUI, BroadcastUI) - Move 2 test files (DiagnosticsTypesTest, BLAuthClientSmartAuthTest) — fix JUnit5, Device JSON, serializer - Add coil-compose dependency for AsyncImage - Add appVersion/osVersion fields to BLPlatformConfig - Fix OkHttp Headers iteration (name/value indexed access) - Fix BroadcastUI string comparisons for status/style/priority - Remove _deferred_ui/ directory — all files now compile in src/ - 57 tests total, 56 pass (1 pre-existing BLKillSwitchClientTest failure) --- .gitignore | 4 + .../_deferred_ui/BLBroadcastClient.kt | 207 ------- .../_deferred_ui/BLFeedbackClient.kt | 267 --------- .../_deferred_ui/BLPasskeyManager.kt | 132 ----- .../_deferred_ui/BLSurveyClient.kt | 366 ------------ .../_deferred_ui/BreadcrumbTrail.kt | 74 --- .../_deferred_ui/DeepLinkRouter.kt | 172 ------ .../_deferred_ui/DeviceStateCollector.kt | 114 ---- .../_deferred_ui/DiagnosticsClient.kt | 534 ------------------ .../_deferred_ui/DiagnosticsTypes.kt | 152 ----- .../_deferred_ui/NetworkInterceptor.kt | 120 ---- packages/kotlin-platform-sdk/build.gradle.kts | 3 + .../bytelyst/platform/BLBroadcastClient.kt | 15 + .../com/bytelyst/platform/BLFeedbackClient.kt | 7 +- .../com/bytelyst/platform/BLPlatformConfig.kt | 6 + .../com/bytelyst/platform/BLSurveyClient.kt | 3 +- .../platform/diagnostics/DiagnosticsClient.kt | 15 +- .../diagnostics/NetworkInterceptor.kt | 8 +- .../com/bytelyst/platform/ui}/BLAuthUI.kt | 6 +- .../com/bytelyst/platform/ui}/BroadcastUI.kt | 22 +- .../com/bytelyst/platform/ui}/SurveyUI.kt | 0 .../platform}/BLAuthClientSmartAuthTest.kt | 14 +- .../diagnostics}/DiagnosticsTypesTest.kt | 10 +- 23 files changed, 71 insertions(+), 2180 deletions(-) delete mode 100644 packages/kotlin-platform-sdk/_deferred_ui/BLBroadcastClient.kt delete mode 100644 packages/kotlin-platform-sdk/_deferred_ui/BLFeedbackClient.kt delete mode 100644 packages/kotlin-platform-sdk/_deferred_ui/BLPasskeyManager.kt delete mode 100644 packages/kotlin-platform-sdk/_deferred_ui/BLSurveyClient.kt delete mode 100644 packages/kotlin-platform-sdk/_deferred_ui/BreadcrumbTrail.kt delete mode 100644 packages/kotlin-platform-sdk/_deferred_ui/DeepLinkRouter.kt delete mode 100644 packages/kotlin-platform-sdk/_deferred_ui/DeviceStateCollector.kt delete mode 100644 packages/kotlin-platform-sdk/_deferred_ui/DiagnosticsClient.kt delete mode 100644 packages/kotlin-platform-sdk/_deferred_ui/DiagnosticsTypes.kt delete mode 100644 packages/kotlin-platform-sdk/_deferred_ui/NetworkInterceptor.kt rename packages/kotlin-platform-sdk/{_deferred_ui => src/main/kotlin/com/bytelyst/platform/ui}/BLAuthUI.kt (99%) rename packages/kotlin-platform-sdk/{_deferred_ui => src/main/kotlin/com/bytelyst/platform/ui}/BroadcastUI.kt (93%) rename packages/kotlin-platform-sdk/{_deferred_ui => src/main/kotlin/com/bytelyst/platform/ui}/SurveyUI.kt (100%) rename packages/kotlin-platform-sdk/{_deferred_ui => src/test/kotlin/com/bytelyst/platform}/BLAuthClientSmartAuthTest.kt (92%) rename packages/kotlin-platform-sdk/{_deferred_ui => src/test/kotlin/com/bytelyst/platform/diagnostics}/DiagnosticsTypesTest.kt (97%) diff --git a/.gitignore b/.gitignore index 591098dd..62a44966 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ __LOCAL_LLMs/models/ __LOCAL_LLMs/.venv-*/ __LOCAL_LLMs/*.wav packages/swift-platform-sdk/build/ +packages/kotlin-platform-sdk/build/ +packages/kotlin-platform-sdk/.gradle/ +packages/kotlin-platform-sdk/gradle/ +packages/kotlin-platform-sdk/gradlew diff --git a/packages/kotlin-platform-sdk/_deferred_ui/BLBroadcastClient.kt b/packages/kotlin-platform-sdk/_deferred_ui/BLBroadcastClient.kt deleted file mode 100644 index 53443dd8..00000000 --- a/packages/kotlin-platform-sdk/_deferred_ui/BLBroadcastClient.kt +++ /dev/null @@ -1,207 +0,0 @@ -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.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".toMediaTypeOrNull())) - } else if (method != "GET") { - builder.method(method, "".toRequestBody(null)) - } - - return builder.build() - } -} diff --git a/packages/kotlin-platform-sdk/_deferred_ui/BLFeedbackClient.kt b/packages/kotlin-platform-sdk/_deferred_ui/BLFeedbackClient.kt deleted file mode 100644 index efd58d4c..00000000 --- a/packages/kotlin-platform-sdk/_deferred_ui/BLFeedbackClient.kt +++ /dev/null @@ -1,267 +0,0 @@ -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/_deferred_ui/BLPasskeyManager.kt b/packages/kotlin-platform-sdk/_deferred_ui/BLPasskeyManager.kt deleted file mode 100644 index a14f5965..00000000 --- a/packages/kotlin-platform-sdk/_deferred_ui/BLPasskeyManager.kt +++ /dev/null @@ -1,132 +0,0 @@ -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/_deferred_ui/BLSurveyClient.kt b/packages/kotlin-platform-sdk/_deferred_ui/BLSurveyClient.kt deleted file mode 100644 index 21bb834c..00000000 --- a/packages/kotlin-platform-sdk/_deferred_ui/BLSurveyClient.kt +++ /dev/null @@ -1,366 +0,0 @@ -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/_deferred_ui/BreadcrumbTrail.kt b/packages/kotlin-platform-sdk/_deferred_ui/BreadcrumbTrail.kt deleted file mode 100644 index c8286cde..00000000 --- a/packages/kotlin-platform-sdk/_deferred_ui/BreadcrumbTrail.kt +++ /dev/null @@ -1,74 +0,0 @@ -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/_deferred_ui/DeepLinkRouter.kt b/packages/kotlin-platform-sdk/_deferred_ui/DeepLinkRouter.kt deleted file mode 100644 index 7b5772b2..00000000 --- a/packages/kotlin-platform-sdk/_deferred_ui/DeepLinkRouter.kt +++ /dev/null @@ -1,172 +0,0 @@ -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/_deferred_ui/DeviceStateCollector.kt b/packages/kotlin-platform-sdk/_deferred_ui/DeviceStateCollector.kt deleted file mode 100644 index 4da2ec81..00000000 --- a/packages/kotlin-platform-sdk/_deferred_ui/DeviceStateCollector.kt +++ /dev/null @@ -1,114 +0,0 @@ -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/_deferred_ui/DiagnosticsClient.kt b/packages/kotlin-platform-sdk/_deferred_ui/DiagnosticsClient.kt deleted file mode 100644 index fd256765..00000000 --- a/packages/kotlin-platform-sdk/_deferred_ui/DiagnosticsClient.kt +++ /dev/null @@ -1,534 +0,0 @@ -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/_deferred_ui/DiagnosticsTypes.kt b/packages/kotlin-platform-sdk/_deferred_ui/DiagnosticsTypes.kt deleted file mode 100644 index 2e63fd1c..00000000 --- a/packages/kotlin-platform-sdk/_deferred_ui/DiagnosticsTypes.kt +++ /dev/null @@ -1,152 +0,0 @@ -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/_deferred_ui/NetworkInterceptor.kt b/packages/kotlin-platform-sdk/_deferred_ui/NetworkInterceptor.kt deleted file mode 100644 index 3233937c..00000000 --- a/packages/kotlin-platform-sdk/_deferred_ui/NetworkInterceptor.kt +++ /dev/null @@ -1,120 +0,0 @@ -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)}" - } -} diff --git a/packages/kotlin-platform-sdk/build.gradle.kts b/packages/kotlin-platform-sdk/build.gradle.kts index f765dfb9..e0e47a57 100644 --- a/packages/kotlin-platform-sdk/build.gradle.kts +++ b/packages/kotlin-platform-sdk/build.gradle.kts @@ -51,6 +51,9 @@ dependencies { implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.foundation:foundation") + // Image loading (for BroadcastUI AsyncImage) + implementation("io.coil-kt:coil-compose:2.7.0") + // Android implementation("androidx.security:security-crypto:1.0.0") implementation("androidx.biometric:biometric:1.1.0") 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 index 5078f517..52d12c2e 100644 --- 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 @@ -1,7 +1,11 @@ package com.bytelyst.platform +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -11,6 +15,14 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import java.util.concurrent.TimeUnit +// Top-level enums used by BroadcastUI +enum class MessagePriority { LOW, NORMAL, HIGH, URGENT } +enum class MessageStyle { BANNER, MODAL, TOAST, FULLSCREEN } +enum class MessageStatus { UNREAD, READ, DISMISSED } + +// Top-level type alias for convenience +typealias InAppMessage = BLBroadcastClient.InAppMessage + /** * Broadcast Client — In-app message client for Android. * Part of ByteLystPlatformSDK. @@ -54,6 +66,7 @@ class BLBroadcastClient( val style: String, val dismissible: Boolean, val expiresAt: String? = null, + val imageUrl: String? = null, val status: String, val createdAt: String, val updatedAt: String, @@ -73,6 +86,8 @@ class BLBroadcastClient( /** * List active in-app messages for the current user. */ + suspend fun getMessages() = listMessages() + suspend fun listMessages(): Result> = withContext(Dispatchers.IO) { try { val request = buildRequest(path = "/broadcasts") 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 index efd58d4c..6fefb58d 100644 --- 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 @@ -6,6 +6,8 @@ import android.view.View import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonPrimitive @@ -231,10 +233,7 @@ class BLFeedbackClient( 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(), - ), + MapSerializer(String.serializer(), String.serializer()), mapOf("contentType" to contentType), ) val response = platformClient.request("POST", "/api/feedback/sas", body) diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt index bf128db1..62520ddd 100644 --- a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt @@ -22,6 +22,12 @@ data class BLPlatformConfig( /** Application ID / bundle ID (e.g., "com.chronomind.app"). */ val applicationId: String, + /** App version string for telemetry headers (e.g., "1.2.0"). */ + val appVersion: String = "0.0.0", + + /** OS version string for telemetry headers (e.g., "14"). */ + val osVersion: String = "unknown", + /** Request timeout in milliseconds. Default: 15 seconds. */ val timeoutMs: Long = 15_000L, ) 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 index 21bb834c..21587149 100644 --- 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 @@ -13,6 +13,7 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonPrimitive import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import java.util.concurrent.TimeUnit @@ -356,7 +357,7 @@ class BLSurveyClient( .header("x-os-version", config.osVersion) if (body != null) { - builder.method(method, body.toRequestBody("application/json".toMediaTypeOrNull())) + builder.method(method, body.toRequestBody("application/json".toMediaType())) } else if (method != "GET") { builder.method(method, "".toRequestBody(null)) } 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 index fd256765..50c8b2d8 100644 --- 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 @@ -9,6 +9,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import okhttp3.* import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody import java.io.IOException import java.text.SimpleDateFormat import java.util.* @@ -370,7 +371,7 @@ class DiagnosticsClient private constructor( val token = getToken() requestBuilder.header("Authorization", "Bearer $token") } catch (e: Exception) { - logger.error("[diagnostics] Failed to get auth token", mapOf("error" to e.message)) + logger.error("[diagnostics] Failed to get auth token", mapOf("error" to (e.message ?: "unknown"))) } } @@ -420,7 +421,7 @@ class DiagnosticsClient private constructor( } } } catch (e: Exception) { - logger.error("[diagnostics] Failed to poll for session", mapOf("error" to e.message)) + logger.error("[diagnostics] Failed to poll for session", mapOf("error" to (e.message ?: "unknown"))) _state.value = DiagnosticsClientState.Error(e) } } @@ -478,7 +479,7 @@ class DiagnosticsClient private constructor( 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)) + logger.error("[diagnostics] Failed to get auth token for flush", mapOf("error" to (e.message ?: "unknown"))) } } @@ -492,14 +493,14 @@ class DiagnosticsClient private constructor( logger.debug( "[diagnostics] Flushed batch", mapOf( - "logs" to (batch.logs?.size ?: 0), - "traces" to (batch.traces?.size ?: 0), - "network" to (batch.network?.size ?: 0) + "logs" to (batch.logs?.size ?: 0).toString(), + "traces" to (batch.traces?.size ?: 0).toString(), + "network" to (batch.network?.size ?: 0).toString() ) ) } } catch (e: Exception) { - logger.error("[diagnostics] Failed to flush batch", mapOf("error" to e.message)) + logger.error("[diagnostics] Failed to flush batch", mapOf("error" to (e.message ?: "unknown"))) // Put items back in buffers for retry synchronized(logBuffer) { batch.logs?.let { logBuffer.addAll(0, it) } } 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 index 3233937c..3b88b304 100644 --- 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 @@ -40,7 +40,9 @@ class NetworkInterceptor( // Capture request details val requestHeaders = mutableMapOf() - request.headers.forEach { name, value -> + for (i in 0 until request.headers.size) { + val name = request.headers.name(i) + val value = request.headers.value(i) requestHeaders[name] = sanitizeHeader(value, name) } @@ -78,7 +80,9 @@ class NetworkInterceptor( // Capture response val endTime = System.currentTimeMillis() val responseHeaders = mutableMapOf() - response.headers.forEach { name, value -> + for (i in 0 until response.headers.size) { + val name = response.headers.name(i) + val value = response.headers.value(i) responseHeaders[name] = sanitizeHeader(value, name) } diff --git a/packages/kotlin-platform-sdk/_deferred_ui/BLAuthUI.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BLAuthUI.kt similarity index 99% rename from packages/kotlin-platform-sdk/_deferred_ui/BLAuthUI.kt rename to packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BLAuthUI.kt index d7843408..e9538a71 100644 --- a/packages/kotlin-platform-sdk/_deferred_ui/BLAuthUI.kt +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BLAuthUI.kt @@ -257,7 +257,7 @@ fun BLMfaChallengeScreen( ), singleLine = true, textStyle = LocalTextStyle.current.copy( - fontFamily = FontFamily.Monospaced, + fontFamily = FontFamily.Monospace, textAlign = TextAlign.Center, ), modifier = Modifier.width(200.dp), @@ -459,7 +459,7 @@ fun BLDeviceListScreen( ) } else { devices.forEach { device -> - DeviceCard(device = device, onRevoke = { onRevokeDevice(device.id) }) + DeviceCard(device = device, onRevoke = { onRevokeDevice(device.fingerprint) }) Spacer(Modifier.height(8.dp)) } } @@ -628,7 +628,7 @@ private fun SocialButton( imageVector = when (provider) { BLAuthProvider.GOOGLE -> Icons.Default.Public BLAuthProvider.MICROSOFT -> Icons.Default.Business - BLAuthProvider.APPLE -> Icons.Default.Apple + BLAuthProvider.APPLE -> Icons.Default.Star }, contentDescription = null, modifier = Modifier.size(20.dp), diff --git a/packages/kotlin-platform-sdk/_deferred_ui/BroadcastUI.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt similarity index 93% rename from packages/kotlin-platform-sdk/_deferred_ui/BroadcastUI.kt rename to packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt index 6874c302..a69ff9ec 100644 --- a/packages/kotlin-platform-sdk/_deferred_ui/BroadcastUI.kt +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt @@ -42,20 +42,20 @@ fun InAppMessageBanner( LaunchedEffect(client) { // Initial load val result = client.getMessages() - result.onSuccess { response -> - messages = response.messages - unreadCount = messages.count { it.status == MessageStatus.UNREAD } + result.onSuccess { list -> + messages = list + unreadCount = messages.count { it.status == "UNREAD" } } // Start polling client.startPolling(60000L) { updatedMessages -> messages = updatedMessages - unreadCount = updatedMessages.count { it.status == MessageStatus.UNREAD } + unreadCount = updatedMessages.count { it.status == "UNREAD" } } } val bannerMessages = messages.filter { - it.status == MessageStatus.UNREAD && (it.style == MessageStyle.BANNER || it.style == MessageStyle.TOAST) + it.status == "UNREAD" && (it.style == "BANNER" || it.style == "TOAST") } if (bannerMessages.isEmpty()) return @@ -89,7 +89,7 @@ fun InAppMessageBanner( } client.markRead(message.id) messages = messages.map { - if (it.id == message.id) it.copy(status = MessageStatus.READ) + if (it.id == message.id) it.copy(status = "READ") else it } } @@ -109,9 +109,9 @@ private fun BannerCard( onDismiss: () -> Unit, onTap: () -> Unit ) { - val backgroundColor = when (message.priority) { - MessagePriority.URGENT -> MaterialTheme.colorScheme.errorContainer - MessagePriority.HIGH -> Color(0xFFFFF3E0) // Orange-ish + val backgroundColor = when (message.priority.uppercase()) { + "URGENT" -> MaterialTheme.colorScheme.errorContainer + "HIGH" -> Color(0xFFFFF3E0) // Orange-ish else -> MaterialTheme.colorScheme.surface } @@ -196,6 +196,7 @@ fun BroadcastModal( modifier: Modifier = Modifier ) { val scope = rememberCoroutineScope() + val context = LocalContext.current var currentMessage by remember { mutableStateOf(null) } var showDialog by remember { mutableStateOf(false) } @@ -203,7 +204,7 @@ fun BroadcastModal( // Start polling for modal messages client.startPolling(30000L) { messages -> val modalMessages = messages.filter { - it.status == MessageStatus.UNREAD && (it.style == MessageStyle.MODAL || it.style == MessageStyle.FULLSCREEN) + it.status == "UNREAD" && (it.style == "MODAL" || it.style == "FULLSCREEN") } if (modalMessages.isNotEmpty() && currentMessage == null) { currentMessage = modalMessages.first() @@ -291,7 +292,6 @@ fun BroadcastModal( scope.launch { client.trackClick(message.id) message.ctaUrl?.let { url -> - val context = androidx.compose.ui.platform.LocalContext.current val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)) context.startActivity(intent) } diff --git a/packages/kotlin-platform-sdk/_deferred_ui/SurveyUI.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt similarity index 100% rename from packages/kotlin-platform-sdk/_deferred_ui/SurveyUI.kt rename to packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt diff --git a/packages/kotlin-platform-sdk/_deferred_ui/BLAuthClientSmartAuthTest.kt b/packages/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLAuthClientSmartAuthTest.kt similarity index 92% rename from packages/kotlin-platform-sdk/_deferred_ui/BLAuthClientSmartAuthTest.kt rename to packages/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLAuthClientSmartAuthTest.kt index 37f55f0a..82c17225 100644 --- a/packages/kotlin-platform-sdk/_deferred_ui/BLAuthClientSmartAuthTest.kt +++ b/packages/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLAuthClientSmartAuthTest.kt @@ -1,6 +1,8 @@ package com.bytelyst.platform import kotlinx.coroutines.test.runTest +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -62,10 +64,7 @@ class BLAuthClientSmartAuthTest { // We can't easily use BLAuthClient directly (needs BLSecureStore with Context), // so we test the HTTP layer directly via BLPlatformClient val body = json.encodeToString( - kotlinx.serialization.builtins.MapSerializer( - kotlinx.serialization.builtins.serializer(), - kotlinx.serialization.builtins.serializer(), - ), + MapSerializer(String.serializer(), String.serializer()), mapOf("idToken" to "mock_google_id_token"), ) val response = client.request("POST", "/api/auth/oauth/google", body, skipAuth = true) @@ -103,10 +102,7 @@ class BLAuthClientSmartAuthTest { // Act: call the endpoint val client = BLPlatformClient(config) { null } val body = json.encodeToString( - kotlinx.serialization.builtins.MapSerializer( - kotlinx.serialization.builtins.serializer(), - kotlinx.serialization.builtins.serializer(), - ), + MapSerializer(String.serializer(), String.serializer()), mapOf("idToken" to "mock_token"), ) val response = client.request("POST", "/api/auth/oauth/google", body, skipAuth = true) @@ -135,7 +131,7 @@ class BLAuthClientSmartAuthTest { assertNull(provider.lastUsedAt) // Device - val deviceJson = """{"id":"d1","name":"Pixel 9","platform":"android","trustLevel":"trusted","trustExpiresAt":"2026-06-01","lastLoginAt":"2026-03-01"}""" + val deviceJson = """{"fingerprint":"fp_d1","trustLevel":"trusted","trustExpiresAt":"2026-06-01","createdAt":"2026-01-01","lastSeenAt":"2026-03-01","isTrusted":true}""" val device = json.decodeFromString(deviceJson) assertEquals("trusted", device.trustLevel) diff --git a/packages/kotlin-platform-sdk/_deferred_ui/DiagnosticsTypesTest.kt b/packages/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt similarity index 97% rename from packages/kotlin-platform-sdk/_deferred_ui/DiagnosticsTypesTest.kt rename to packages/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt index caae2e5d..5b1309b1 100644 --- a/packages/kotlin-platform-sdk/_deferred_ui/DiagnosticsTypesTest.kt +++ b/packages/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt @@ -1,8 +1,8 @@ package com.bytelyst.platform.diagnostics -import org.junit.Test -import org.junit.Assert.* -import org.junit.Before +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach class DiagnosticsTypesTest { @@ -95,7 +95,7 @@ class DiagnosticsTypesTest { assertEquals("req-123", request.id) assertEquals(200, request.status) - assertEquals(100.0, request.durationMs, 0.0) + assertEquals(100.0, request.durationMs!!, 0.0) } @Test @@ -121,7 +121,7 @@ class BreadcrumbTrailTest { private lateinit var trail: BreadcrumbTrail - @Before + @BeforeEach fun setup() { trail = BreadcrumbTrail(maxSize = 3) }