From 6027d618b72f766a529b1125f534966618cdfcf3 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Tue, 3 Mar 2026 07:44:39 -0800 Subject: [PATCH] feat(kotlin-sdk): Phase 3.4 - Broadcast and Survey clients - BLBroadcastClient.kt: In-app message fetch, read/dismiss, click tracking, polling - BLSurveyClient.kt: Survey fetch, start/submit/complete, offline cache, polling - Full coroutine support with Result return types --- .../bytelyst/platform/BLBroadcastClient.kt | 207 ++++++++++ .../com/bytelyst/platform/BLSurveyClient.kt | 366 ++++++++++++++++++ 2 files changed, 573 insertions(+) create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt new file mode 100644 index 00000000..53443dd8 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt @@ -0,0 +1,207 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +/** + * Broadcast Client — In-app message client for Android. + * Part of ByteLystPlatformSDK. + */ +class BLBroadcastClient( + private val config: BLPlatformConfig, + private val tokenProvider: () -> String? = { null }, +) { + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val json = Json { ignoreUnknownKeys = true } + private var pollingJob: kotlinx.coroutines.Job? = null + + enum class Priority { + LOW, NORMAL, HIGH, URGENT + } + + enum class Style { + BANNER, MODAL, TOAST, FULLSCREEN + } + + enum class Status { + UNREAD, READ, DISMISSED + } + + @Serializable + data class InAppMessage( + val id: String, + val userId: String, + val productId: String, + val broadcastId: String, + val title: String, + val body: String, + val bodyMarkdown: String? = null, + val ctaText: String? = null, + val ctaUrl: String? = null, + val priority: String, + val style: String, + val dismissible: Boolean, + val expiresAt: String? = null, + val status: String, + val createdAt: String, + val updatedAt: String, + ) + + @Serializable + private data class MessagesResponse( + val messages: List + ) + + @Serializable + private data class ClickResponse( + val success: Boolean, + val redirectUrl: String? = null, + ) + + /** + * List active in-app messages for the current user. + */ + suspend fun listMessages(): Result> = withContext(Dispatchers.IO) { + try { + val request = buildRequest(path = "/broadcasts") + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + val result = json.decodeFromString(MessagesResponse.serializer(), body) + Result.success(result.messages) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Mark a message as read. + */ + suspend fun markRead(messageId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/broadcasts/$messageId/read", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Mark a message as dismissed. + */ + suspend fun markDismissed(messageId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/broadcasts/$messageId/dismiss", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Track a CTA click and get the redirect URL. + */ + suspend fun trackClick(messageId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/broadcasts/$messageId/click", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val body = response.body?.string() ?: return@withContext Result.success(null) + val result = json.decodeFromString(ClickResponse.serializer(), body) + Result.success(result.redirectUrl) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Start polling for new messages. + */ + fun startPolling( + intervalMs: Long = 60000L, + onUpdate: (List) -> Unit, + ) { + stopPolling() + pollingJob = kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { + while (isActive) { + listMessages() + .onSuccess { messages -> onUpdate(messages) } + .onFailure { /* Silently ignore polling errors */ } + delay(intervalMs) + } + } + } + + /** + * Stop polling for messages. + */ + fun stopPolling() { + pollingJob?.cancel() + pollingJob = null + } + + private fun buildRequest( + path: String, + method: String = "GET", + body: String? = null, + ): Request { + val url = "${config.baseUrl}$path" + val token = tokenProvider() ?: "" + + val builder = Request.Builder() + .url(url) + .header("Authorization", "Bearer $token") + .header("x-product-id", config.productId) + .header("x-platform", "android") + .header("x-app-version", config.appVersion) + .header("x-os-version", config.osVersion) + + if (body != null) { + builder.method(method, body.toRequestBody("application/json".toMediaTypeOrNull())) + } else if (method != "GET") { + builder.method(method, "".toRequestBody(null)) + } + + return builder.build() + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt new file mode 100644 index 00000000..21bb834c --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt @@ -0,0 +1,366 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +/** + * Survey Client — In-app survey client for Android. + * Part of ByteLystPlatformSDK. + */ +class BLSurveyClient( + private val config: BLPlatformConfig, + private val tokenProvider: () -> String? = { null }, +) { + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val json = Json { ignoreUnknownKeys = true } + private var pollingJob: Job? = null + private val responseCache = mutableMapOf() + + enum class QuestionType { + SINGLE_CHOICE, MULTIPLE_CHOICE, RATING, NPS, TEXT_SHORT, TEXT_LONG, DROPDOWN, SCALE, RANKING + } + + @Serializable + data class QuestionOption( + val id: String, + val text: String, + val emoji: String? = null, + ) + + @Serializable + data class Question( + val id: String, + val type: String, + val text: String, + val description: String? = null, + val required: Boolean, + val options: List? = null, + val minLength: Int? = null, + val maxLength: Int? = null, + val minValue: Int? = null, + val maxValue: Int? = null, + ) + + @Serializable + data class SurveyIncentive( + val type: String, + val amount: Int, + ) + + @Serializable + data class SurveyTrigger( + val type: String, + val seconds: Int? = null, + val eventName: String? = null, + val pagePattern: String? = null, + ) + + @Serializable + data class ActiveSurvey( + val id: String, + val title: String, + val description: String? = null, + val questions: List, + val incentive: SurveyIncentive? = null, + val displayTrigger: SurveyTrigger, + ) + + @Serializable + data class SurveyAnswer( + val type: String, + val value: JsonObject, + ) + + @Serializable + data class SurveyResponse( + val id: String, + val surveyId: String, + val userId: String, + val answers: Map, + val currentQuestionIndex: Int, + val startedAt: String, + val completedAt: String? = null, + val isComplete: Boolean, + val incentiveClaimed: Boolean, + val incentiveClaimedAt: String? = null, + val createdAt: String, + val updatedAt: String, + ) + + @Serializable + private data class ActiveSurveyResponse( + val survey: ActiveSurvey?, + ) + + @Serializable + private data class StartSurveyResponse( + val responseId: String, + val startedAt: String, + val currentQuestionIndex: Int, + val answers: Map, + ) + + @Serializable + private data class SubmitAnswerResponse( + val responseId: String, + val currentQuestionIndex: Int, + val answers: Map, + ) + + @Serializable + data class SurveyCompletionResult( + val success: Boolean, + val timeSpentSeconds: Int, + val incentiveClaimed: Boolean, + ) + + /** + * Get active survey for the current user (if any). + */ + suspend fun getActiveSurvey(): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest(path = "/surveys/active") + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + val result = json.decodeFromString(ActiveSurveyResponse.serializer(), body) + Result.success(result.survey) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Start a survey session. + */ + suspend fun startSurvey(surveyId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/surveys/$surveyId/start", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + val result = json.decodeFromString(StartSurveyResponse.serializer(), body) + + val surveyResponse = SurveyResponse( + id = result.responseId, + surveyId = surveyId, + userId = "", + answers = result.answers, + currentQuestionIndex = result.currentQuestionIndex, + startedAt = result.startedAt, + completedAt = null, + isComplete = false, + incentiveClaimed = false, + incentiveClaimedAt = null, + createdAt = result.startedAt, + updatedAt = result.startedAt, + ) + + // Cache the response + responseCache[surveyId] = surveyResponse + Result.success(surveyResponse) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Submit an answer to a survey question. + */ + suspend fun submitAnswer( + surveyId: String, + questionId: String, + answer: SurveyAnswer, + ): Result = withContext(Dispatchers.IO) { + try { + val body = json.encodeToString( + SubmitAnswerRequest.serializer(), + SubmitAnswerRequest(questionId, answer) + ) + + val request = buildRequest( + path = "/surveys/$surveyId/response", + method = "POST", + body = body + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + val result = json.decodeFromString(SubmitAnswerResponse.serializer(), responseBody) + + // Update cache + val cached = responseCache[surveyId] + if (cached != null) { + val updated = cached.copy( + answers = result.answers, + currentQuestionIndex = result.currentQuestionIndex, + ) + responseCache[surveyId] = updated + } + + Result.success( + SurveyResponse( + id = result.responseId, + surveyId = surveyId, + userId = "", + answers = result.answers, + currentQuestionIndex = result.currentQuestionIndex, + startedAt = "", + completedAt = null, + isComplete = false, + incentiveClaimed = false, + incentiveClaimedAt = null, + createdAt = "", + updatedAt = java.time.Instant.now().toString(), + ) + ) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Complete a survey. + */ + suspend fun completeSurvey(surveyId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/surveys/$surveyId/complete", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + val result = json.decodeFromString(SurveyCompletionResult.serializer(), body) + + // Clear cache on completion + responseCache.remove(surveyId) + Result.success(result) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Dismiss a survey (won't show again). + */ + suspend fun dismissSurvey(surveyId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/surveys/$surveyId/dismiss", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + // Clear cache + responseCache.remove(surveyId) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Get cached response for a survey. + */ + fun getCachedResponse(surveyId: String): SurveyResponse? { + return responseCache[surveyId] + } + + /** + * Start polling for eligible surveys. + */ + fun startPolling( + intervalMs: Long = 60000L, + onUpdate: (ActiveSurvey?) -> Unit, + ) { + stopPolling() + pollingJob = CoroutineScope(Dispatchers.IO).launch { + while (isActive) { + getActiveSurvey() + .onSuccess { survey -> onUpdate(survey) } + .onFailure { /* Silently ignore polling errors */ } + delay(intervalMs) + } + } + } + + /** + * Stop polling for surveys. + */ + fun stopPolling() { + pollingJob?.cancel() + pollingJob = null + } + + @Serializable + private data class SubmitAnswerRequest( + val questionId: String, + val answer: SurveyAnswer, + ) + + private fun buildRequest( + path: String, + method: String = "GET", + body: String? = null, + ): Request { + val url = "${config.baseUrl}$path" + val token = tokenProvider() ?: "" + + val builder = Request.Builder() + .url(url) + .header("Authorization", "Bearer $token") + .header("x-product-id", config.productId) + .header("x-platform", "android") + .header("x-app-version", config.appVersion) + .header("x-os-version", config.osVersion) + + if (body != null) { + builder.method(method, body.toRequestBody("application/json".toMediaTypeOrNull())) + } else if (method != "GET") { + builder.method(method, "".toRequestBody(null)) + } + + return builder.build() + } +}