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:
parent
e90b2f67e7
commit
35487137e1
4
.gitignore
vendored
4
.gitignore
vendored
@ -21,3 +21,7 @@ __LOCAL_LLMs/models/
|
|||||||
__LOCAL_LLMs/.venv-*/
|
__LOCAL_LLMs/.venv-*/
|
||||||
__LOCAL_LLMs/*.wav
|
__LOCAL_LLMs/*.wav
|
||||||
packages/swift-platform-sdk/build/
|
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
|
||||||
|
|||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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)}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -51,6 +51,9 @@ dependencies {
|
|||||||
implementation("androidx.compose.material:material-icons-extended")
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
implementation("androidx.compose.foundation:foundation")
|
implementation("androidx.compose.foundation:foundation")
|
||||||
|
|
||||||
|
// Image loading (for BroadcastUI AsyncImage)
|
||||||
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
|
|
||||||
// Android
|
// Android
|
||||||
implementation("androidx.security:security-crypto:1.0.0")
|
implementation("androidx.security:security-crypto:1.0.0")
|
||||||
implementation("androidx.biometric:biometric:1.1.0")
|
implementation("androidx.biometric:biometric:1.1.0")
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
package com.bytelyst.platform
|
package com.bytelyst.platform
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@ -11,6 +15,14 @@ import okhttp3.MediaType.Companion.toMediaType
|
|||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.util.concurrent.TimeUnit
|
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.
|
* Broadcast Client — In-app message client for Android.
|
||||||
* Part of ByteLystPlatformSDK.
|
* Part of ByteLystPlatformSDK.
|
||||||
@ -54,6 +66,7 @@ class BLBroadcastClient(
|
|||||||
val style: String,
|
val style: String,
|
||||||
val dismissible: Boolean,
|
val dismissible: Boolean,
|
||||||
val expiresAt: String? = null,
|
val expiresAt: String? = null,
|
||||||
|
val imageUrl: String? = null,
|
||||||
val status: String,
|
val status: String,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val updatedAt: String,
|
val updatedAt: String,
|
||||||
@ -73,6 +86,8 @@ class BLBroadcastClient(
|
|||||||
/**
|
/**
|
||||||
* List active in-app messages for the current user.
|
* List active in-app messages for the current user.
|
||||||
*/
|
*/
|
||||||
|
suspend fun getMessages() = listMessages()
|
||||||
|
|
||||||
suspend fun listMessages(): Result<List<InAppMessage>> = withContext(Dispatchers.IO) {
|
suspend fun listMessages(): Result<List<InAppMessage>> = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val request = buildRequest(path = "/broadcasts")
|
val request = buildRequest(path = "/broadcasts")
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import android.view.View
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.builtins.MapSerializer
|
||||||
|
import kotlinx.serialization.builtins.serializer
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
@ -231,10 +233,7 @@ class BLFeedbackClient(
|
|||||||
private suspend fun generateSASUrl(contentType: String): SasResponse? = withContext(Dispatchers.IO) {
|
private suspend fun generateSASUrl(contentType: String): SasResponse? = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val body = json.encodeToString(
|
val body = json.encodeToString(
|
||||||
kotlinx.serialization.builtins.MapSerializer(
|
MapSerializer(String.serializer(), String.serializer()),
|
||||||
kotlinx.serialization.builtins.serializer<String>(),
|
|
||||||
kotlinx.serialization.builtins.serializer<String>(),
|
|
||||||
),
|
|
||||||
mapOf("contentType" to contentType),
|
mapOf("contentType" to contentType),
|
||||||
)
|
)
|
||||||
val response = platformClient.request("POST", "/api/feedback/sas", body)
|
val response = platformClient.request("POST", "/api/feedback/sas", body)
|
||||||
|
|||||||
@ -22,6 +22,12 @@ data class BLPlatformConfig(
|
|||||||
/** Application ID / bundle ID (e.g., "com.chronomind.app"). */
|
/** Application ID / bundle ID (e.g., "com.chronomind.app"). */
|
||||||
val applicationId: String,
|
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. */
|
/** Request timeout in milliseconds. Default: 15 seconds. */
|
||||||
val timeoutMs: Long = 15_000L,
|
val timeoutMs: Long = 15_000L,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import kotlinx.serialization.json.JsonObject
|
|||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@ -356,7 +357,7 @@ class BLSurveyClient(
|
|||||||
.header("x-os-version", config.osVersion)
|
.header("x-os-version", config.osVersion)
|
||||||
|
|
||||||
if (body != null) {
|
if (body != null) {
|
||||||
builder.method(method, body.toRequestBody("application/json".toMediaTypeOrNull()))
|
builder.method(method, body.toRequestBody("application/json".toMediaType()))
|
||||||
} else if (method != "GET") {
|
} else if (method != "GET") {
|
||||||
builder.method(method, "".toRequestBody(null))
|
builder.method(method, "".toRequestBody(null))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import kotlinx.serialization.encodeToString
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@ -370,7 +371,7 @@ class DiagnosticsClient private constructor(
|
|||||||
val token = getToken()
|
val token = getToken()
|
||||||
requestBuilder.header("Authorization", "Bearer $token")
|
requestBuilder.header("Authorization", "Bearer $token")
|
||||||
} catch (e: Exception) {
|
} 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) {
|
} 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)
|
_state.value = DiagnosticsClientState.Error(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -478,7 +479,7 @@ class DiagnosticsClient private constructor(
|
|||||||
val token = getToken()
|
val token = getToken()
|
||||||
requestBuilder.header("Authorization", "Bearer $token")
|
requestBuilder.header("Authorization", "Bearer $token")
|
||||||
} catch (e: Exception) {
|
} 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(
|
logger.debug(
|
||||||
"[diagnostics] Flushed batch",
|
"[diagnostics] Flushed batch",
|
||||||
mapOf(
|
mapOf(
|
||||||
"logs" to (batch.logs?.size ?: 0),
|
"logs" to (batch.logs?.size ?: 0).toString(),
|
||||||
"traces" to (batch.traces?.size ?: 0),
|
"traces" to (batch.traces?.size ?: 0).toString(),
|
||||||
"network" to (batch.network?.size ?: 0)
|
"network" to (batch.network?.size ?: 0).toString()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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
|
// Put items back in buffers for retry
|
||||||
synchronized(logBuffer) { batch.logs?.let { logBuffer.addAll(0, it) } }
|
synchronized(logBuffer) { batch.logs?.let { logBuffer.addAll(0, it) } }
|
||||||
|
|||||||
@ -40,7 +40,9 @@ class NetworkInterceptor(
|
|||||||
|
|
||||||
// Capture request details
|
// Capture request details
|
||||||
val requestHeaders = mutableMapOf<String, String>()
|
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)
|
requestHeaders[name] = sanitizeHeader(value, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,7 +80,9 @@ class NetworkInterceptor(
|
|||||||
// Capture response
|
// Capture response
|
||||||
val endTime = System.currentTimeMillis()
|
val endTime = System.currentTimeMillis()
|
||||||
val responseHeaders = mutableMapOf<String, String>()
|
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)
|
responseHeaders[name] = sanitizeHeader(value, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -257,7 +257,7 @@ fun BLMfaChallengeScreen(
|
|||||||
),
|
),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
textStyle = LocalTextStyle.current.copy(
|
textStyle = LocalTextStyle.current.copy(
|
||||||
fontFamily = FontFamily.Monospaced,
|
fontFamily = FontFamily.Monospace,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
),
|
),
|
||||||
modifier = Modifier.width(200.dp),
|
modifier = Modifier.width(200.dp),
|
||||||
@ -459,7 +459,7 @@ fun BLDeviceListScreen(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
devices.forEach { device ->
|
devices.forEach { device ->
|
||||||
DeviceCard(device = device, onRevoke = { onRevokeDevice(device.id) })
|
DeviceCard(device = device, onRevoke = { onRevokeDevice(device.fingerprint) })
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -628,7 +628,7 @@ private fun SocialButton(
|
|||||||
imageVector = when (provider) {
|
imageVector = when (provider) {
|
||||||
BLAuthProvider.GOOGLE -> Icons.Default.Public
|
BLAuthProvider.GOOGLE -> Icons.Default.Public
|
||||||
BLAuthProvider.MICROSOFT -> Icons.Default.Business
|
BLAuthProvider.MICROSOFT -> Icons.Default.Business
|
||||||
BLAuthProvider.APPLE -> Icons.Default.Apple
|
BLAuthProvider.APPLE -> Icons.Default.Star
|
||||||
},
|
},
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
@ -42,20 +42,20 @@ fun InAppMessageBanner(
|
|||||||
LaunchedEffect(client) {
|
LaunchedEffect(client) {
|
||||||
// Initial load
|
// Initial load
|
||||||
val result = client.getMessages()
|
val result = client.getMessages()
|
||||||
result.onSuccess { response ->
|
result.onSuccess { list ->
|
||||||
messages = response.messages
|
messages = list
|
||||||
unreadCount = messages.count { it.status == MessageStatus.UNREAD }
|
unreadCount = messages.count { it.status == "UNREAD" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start polling
|
// Start polling
|
||||||
client.startPolling(60000L) { updatedMessages ->
|
client.startPolling(60000L) { updatedMessages ->
|
||||||
messages = updatedMessages
|
messages = updatedMessages
|
||||||
unreadCount = updatedMessages.count { it.status == MessageStatus.UNREAD }
|
unreadCount = updatedMessages.count { it.status == "UNREAD" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val bannerMessages = messages.filter {
|
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
|
if (bannerMessages.isEmpty()) return
|
||||||
@ -89,7 +89,7 @@ fun InAppMessageBanner(
|
|||||||
}
|
}
|
||||||
client.markRead(message.id)
|
client.markRead(message.id)
|
||||||
messages = messages.map {
|
messages = messages.map {
|
||||||
if (it.id == message.id) it.copy(status = MessageStatus.READ)
|
if (it.id == message.id) it.copy(status = "READ")
|
||||||
else it
|
else it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -109,9 +109,9 @@ private fun BannerCard(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onTap: () -> Unit
|
onTap: () -> Unit
|
||||||
) {
|
) {
|
||||||
val backgroundColor = when (message.priority) {
|
val backgroundColor = when (message.priority.uppercase()) {
|
||||||
MessagePriority.URGENT -> MaterialTheme.colorScheme.errorContainer
|
"URGENT" -> MaterialTheme.colorScheme.errorContainer
|
||||||
MessagePriority.HIGH -> Color(0xFFFFF3E0) // Orange-ish
|
"HIGH" -> Color(0xFFFFF3E0) // Orange-ish
|
||||||
else -> MaterialTheme.colorScheme.surface
|
else -> MaterialTheme.colorScheme.surface
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,6 +196,7 @@ fun BroadcastModal(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
var currentMessage by remember { mutableStateOf<InAppMessage?>(null) }
|
var currentMessage by remember { mutableStateOf<InAppMessage?>(null) }
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@ -203,7 +204,7 @@ fun BroadcastModal(
|
|||||||
// Start polling for modal messages
|
// Start polling for modal messages
|
||||||
client.startPolling(30000L) { messages ->
|
client.startPolling(30000L) { messages ->
|
||||||
val modalMessages = messages.filter {
|
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) {
|
if (modalMessages.isNotEmpty() && currentMessage == null) {
|
||||||
currentMessage = modalMessages.first()
|
currentMessage = modalMessages.first()
|
||||||
@ -291,7 +292,6 @@ fun BroadcastModal(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
client.trackClick(message.id)
|
client.trackClick(message.id)
|
||||||
message.ctaUrl?.let { url ->
|
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))
|
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
}
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
package com.bytelyst.platform
|
package com.bytelyst.platform
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.builtins.MapSerializer
|
||||||
|
import kotlinx.serialization.builtins.serializer
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
@ -62,10 +64,7 @@ class BLAuthClientSmartAuthTest {
|
|||||||
// We can't easily use BLAuthClient directly (needs BLSecureStore with Context),
|
// We can't easily use BLAuthClient directly (needs BLSecureStore with Context),
|
||||||
// so we test the HTTP layer directly via BLPlatformClient
|
// so we test the HTTP layer directly via BLPlatformClient
|
||||||
val body = json.encodeToString(
|
val body = json.encodeToString(
|
||||||
kotlinx.serialization.builtins.MapSerializer(
|
MapSerializer(String.serializer(), String.serializer()),
|
||||||
kotlinx.serialization.builtins.serializer<String>(),
|
|
||||||
kotlinx.serialization.builtins.serializer<String>(),
|
|
||||||
),
|
|
||||||
mapOf("idToken" to "mock_google_id_token"),
|
mapOf("idToken" to "mock_google_id_token"),
|
||||||
)
|
)
|
||||||
val response = client.request("POST", "/api/auth/oauth/google", body, skipAuth = true)
|
val response = client.request("POST", "/api/auth/oauth/google", body, skipAuth = true)
|
||||||
@ -103,10 +102,7 @@ class BLAuthClientSmartAuthTest {
|
|||||||
// Act: call the endpoint
|
// Act: call the endpoint
|
||||||
val client = BLPlatformClient(config) { null }
|
val client = BLPlatformClient(config) { null }
|
||||||
val body = json.encodeToString(
|
val body = json.encodeToString(
|
||||||
kotlinx.serialization.builtins.MapSerializer(
|
MapSerializer(String.serializer(), String.serializer()),
|
||||||
kotlinx.serialization.builtins.serializer<String>(),
|
|
||||||
kotlinx.serialization.builtins.serializer<String>(),
|
|
||||||
),
|
|
||||||
mapOf("idToken" to "mock_token"),
|
mapOf("idToken" to "mock_token"),
|
||||||
)
|
)
|
||||||
val response = client.request("POST", "/api/auth/oauth/google", body, skipAuth = true)
|
val response = client.request("POST", "/api/auth/oauth/google", body, skipAuth = true)
|
||||||
@ -135,7 +131,7 @@ class BLAuthClientSmartAuthTest {
|
|||||||
assertNull(provider.lastUsedAt)
|
assertNull(provider.lastUsedAt)
|
||||||
|
|
||||||
// Device
|
// 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)
|
val device = json.decodeFromString<BLAuthClient.Device>(deviceJson)
|
||||||
assertEquals("trusted", device.trustLevel)
|
assertEquals("trusted", device.trustLevel)
|
||||||
|
|
||||||
@ -1,8 +1,8 @@
|
|||||||
package com.bytelyst.platform.diagnostics
|
package com.bytelyst.platform.diagnostics
|
||||||
|
|
||||||
import org.junit.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.Assert.*
|
import org.junit.jupiter.api.Assertions.*
|
||||||
import org.junit.Before
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
|
||||||
class DiagnosticsTypesTest {
|
class DiagnosticsTypesTest {
|
||||||
|
|
||||||
@ -95,7 +95,7 @@ class DiagnosticsTypesTest {
|
|||||||
|
|
||||||
assertEquals("req-123", request.id)
|
assertEquals("req-123", request.id)
|
||||||
assertEquals(200, request.status)
|
assertEquals(200, request.status)
|
||||||
assertEquals(100.0, request.durationMs, 0.0)
|
assertEquals(100.0, request.durationMs!!, 0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -121,7 +121,7 @@ class BreadcrumbTrailTest {
|
|||||||
|
|
||||||
private lateinit var trail: BreadcrumbTrail
|
private lateinit var trail: BreadcrumbTrail
|
||||||
|
|
||||||
@Before
|
@BeforeEach
|
||||||
fun setup() {
|
fun setup() {
|
||||||
trail = BreadcrumbTrail(maxSize = 3)
|
trail = BreadcrumbTrail(maxSize = 3)
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user