feat(kotlin-sdk): restore 13 deferred UI files — diagnostics, clients, Compose UI, passkeys, deep links

- 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)
This commit is contained in:
saravanakumardb1 2026-03-19 18:25:35 -07:00
parent e90b2f67e7
commit 35487137e1
23 changed files with 71 additions and 2180 deletions

4
.gitignore vendored
View File

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

View File

@ -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<InAppMessage>
)
@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<List<InAppMessage>> = 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<Unit> = 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<Unit> = 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<String?> = 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<InAppMessage>) -> 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()
}
}

View File

@ -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<ByteArray, ScreenshotFormat>? = 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<String, String, Int>? = 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<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
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
}
}
}

View File

@ -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<JsonObject>(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<BLAuthClient.TokenResponse>(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<BLAuthClient.Passkey> {
val response = authClient.client.request("GET", "/api/auth/passkeys")
return json.decodeFromString<List<BLAuthClient.Passkey>>(response)
}
/**
* Delete a passkey (requires step-up authentication).
*/
suspend fun deletePasskey(passkeyId: String) {
authClient.client.request("DELETE", "/api/auth/passkeys/$passkeyId")
}
}

View File

@ -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<String, SurveyResponse>()
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<QuestionOption>? = 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<Question>,
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<String, SurveyAnswer>,
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<String, SurveyAnswer>,
)
@Serializable
private data class SubmitAnswerResponse(
val responseId: String,
val currentQuestionIndex: Int,
val answers: Map<String, SurveyAnswer>,
)
@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<ActiveSurvey?> = 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<SurveyResponse> = 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<SurveyResponse> = 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<SurveyCompletionResult> = 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<Unit> = 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()
}
}

View File

@ -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<DiagnosticsBreadcrumb>()
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<String, String>? = 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<DiagnosticsBreadcrumb> {
return breadcrumbs.toList()
}
/**
* Get last N breadcrumbs
*/
@Synchronized
fun getLast(n: Int): List<DiagnosticsBreadcrumb> {
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
}
}

View File

@ -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<String, String> = 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<String, DeepLinkHandler>()
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<String, String>()
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<String, String>()
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<String, String> = 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()

View File

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

View File

@ -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<String, Any>? = null)
fun info(message: String, metadata: Map<String, Any>? = null)
fun warn(message: String, metadata: Map<String, Any>? = null)
fun error(message: String, metadata: Map<String, Any>? = null)
}
/**
* No-op logger
*/
class NoOpDiagnosticsLogger : DiagnosticsLogger {
override fun debug(message: String, metadata: Map<String, Any>?) {}
override fun info(message: String, metadata: Map<String, Any>?) {}
override fun warn(message: String, metadata: Map<String, Any>?) {}
override fun error(message: String, metadata: Map<String, Any>?) {}
}
/**
* Android Log-based logger
*/
class AndroidDiagnosticsLogger(private val tag: String = "ByteLystDiagnostics") : DiagnosticsLogger {
override fun debug(message: String, metadata: Map<String, Any>?) {
android.util.Log.d(tag, message)
}
override fun info(message: String, metadata: Map<String, Any>?) {
android.util.Log.i(tag, message)
}
override fun warn(message: String, metadata: Map<String, Any>?) {
android.util.Log.w(tag, message)
}
override fun error(message: String, metadata: Map<String, Any>?) {
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>(DiagnosticsClientState.Idle)
val state: StateFlow<DiagnosticsClientState> = _state.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val breadcrumbs = BreadcrumbTrail(maxSize = config.maxBreadcrumbs)
private val logBuffer = mutableListOf<DiagnosticsLogEntry>()
private val traceBuffer = mutableListOf<DiagnosticsTraceSpan>()
private val networkBuffer = mutableListOf<DiagnosticsNetworkRequest>()
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<String, String> = 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 <T> 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<String, String>? = null) {
breadcrumbs.add(category = category, message = message, data = data)
}
/**
* Get all breadcrumbs
*/
fun getBreadcrumbs(): List<DiagnosticsBreadcrumb> {
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<DiagnosticsSession>(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
}
}
}

View File

@ -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<String, String> = 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<String, String> = 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<String, String>? = null
)
/**
* Network request/response capture
*/
@Serializable
data class DiagnosticsNetworkRequest(
val id: String,
val url: String,
val method: String,
val requestHeaders: Map<String, String> = emptyMap(),
val requestBody: String? = null,
val status: Int? = null,
val responseHeaders: Map<String, String>? = 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<DiagnosticsTraceSpan>? = null,
val logs: List<DiagnosticsLogEntry>? = null,
val breadcrumbs: List<DiagnosticsBreadcrumb>? = null,
val network: List<DiagnosticsNetworkRequest>? = null
)

View File

@ -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<String, String>()
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<String, String>()
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)}"
}
}

View File

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

View File

@ -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<List<InAppMessage>> = withContext(Dispatchers.IO) {
try {
val request = buildRequest(path = "/broadcasts")

View File

@ -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<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
MapSerializer(String.serializer(), String.serializer()),
mapOf("contentType" to contentType),
)
val response = platformClient.request("POST", "/api/feedback/sas", body)

View File

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

View File

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

View File

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

View File

@ -40,7 +40,9 @@ class NetworkInterceptor(
// Capture request details
val requestHeaders = mutableMapOf<String, String>()
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<String, String>()
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)
}

View File

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

View File

@ -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<InAppMessage?>(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)
}

View File

@ -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<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
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<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
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<BLAuthClient.Device>(deviceJson)
assertEquals("trusted", device.trustLevel)

View File

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