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<T> return types
This commit is contained in:
saravanakumardb1 2026-03-03 07:44:39 -08:00
parent b96503dc2d
commit 6027d618b7
2 changed files with 573 additions and 0 deletions

View File

@ -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<InAppMessage>
)
@Serializable
private data class ClickResponse(
val success: Boolean,
val redirectUrl: String? = null,
)
/**
* List active in-app messages for the current user.
*/
suspend fun listMessages(): Result<List<InAppMessage>> = withContext(Dispatchers.IO) {
try {
val request = buildRequest(path = "/broadcasts")
val response = httpClient.newCall(request).execute()
if (!response.isSuccessful) {
return@withContext Result.failure(Exception("HTTP ${response.code}"))
}
val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
val result = json.decodeFromString(MessagesResponse.serializer(), body)
Result.success(result.messages)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Mark a message as read.
*/
suspend fun markRead(messageId: String): Result<Unit> = withContext(Dispatchers.IO) {
try {
val request = buildRequest(
path = "/broadcasts/$messageId/read",
method = "POST"
)
val response = httpClient.newCall(request).execute()
if (!response.isSuccessful) {
return@withContext Result.failure(Exception("HTTP ${response.code}"))
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Mark a message as dismissed.
*/
suspend fun markDismissed(messageId: String): Result<Unit> = withContext(Dispatchers.IO) {
try {
val request = buildRequest(
path = "/broadcasts/$messageId/dismiss",
method = "POST"
)
val response = httpClient.newCall(request).execute()
if (!response.isSuccessful) {
return@withContext Result.failure(Exception("HTTP ${response.code}"))
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Track a CTA click and get the redirect URL.
*/
suspend fun trackClick(messageId: String): Result<String?> = withContext(Dispatchers.IO) {
try {
val request = buildRequest(
path = "/broadcasts/$messageId/click",
method = "POST"
)
val response = httpClient.newCall(request).execute()
if (!response.isSuccessful) {
return@withContext Result.failure(Exception("HTTP ${response.code}"))
}
val body = response.body?.string() ?: return@withContext Result.success(null)
val result = json.decodeFromString(ClickResponse.serializer(), body)
Result.success(result.redirectUrl)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Start polling for new messages.
*/
fun startPolling(
intervalMs: Long = 60000L,
onUpdate: (List<InAppMessage>) -> Unit,
) {
stopPolling()
pollingJob = kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
while (isActive) {
listMessages()
.onSuccess { messages -> onUpdate(messages) }
.onFailure { /* Silently ignore polling errors */ }
delay(intervalMs)
}
}
}
/**
* Stop polling for messages.
*/
fun stopPolling() {
pollingJob?.cancel()
pollingJob = null
}
private fun buildRequest(
path: String,
method: String = "GET",
body: String? = null,
): Request {
val url = "${config.baseUrl}$path"
val token = tokenProvider() ?: ""
val builder = Request.Builder()
.url(url)
.header("Authorization", "Bearer $token")
.header("x-product-id", config.productId)
.header("x-platform", "android")
.header("x-app-version", config.appVersion)
.header("x-os-version", config.osVersion)
if (body != null) {
builder.method(method, body.toRequestBody("application/json".toMediaTypeOrNull()))
} else if (method != "GET") {
builder.method(method, "".toRequestBody(null))
}
return builder.build()
}
}

View File

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