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:
parent
b96503dc2d
commit
6027d618b7
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user