- Swift SDK README: Installation, Broadcast/Survey clients, SwiftUI integration, push notifications - Kotlin SDK README: Gradle setup, Jetpack Compose components, FCM integration
575 lines
16 KiB
Markdown
575 lines
16 KiB
Markdown
# ByteLyst Platform SDK — Android (Kotlin)
|
|
|
|
Kotlin SDK for the ByteLyst platform. Provides broadcast messaging, surveys, authentication, telemetry, and more.
|
|
|
|
## Installation
|
|
|
|
### Gradle
|
|
|
|
Add to your `build.gradle.kts`:
|
|
|
|
```kotlin
|
|
dependencies {
|
|
implementation("com.bytelyst:platform-sdk:1.0.0")
|
|
}
|
|
```
|
|
|
|
### Maven
|
|
|
|
```xml
|
|
<dependency>
|
|
<groupId>com.bytelyst</groupId>
|
|
<artifactId>platform-sdk</artifactId>
|
|
<version>1.0.0</version>
|
|
</dependency>
|
|
```
|
|
|
|
## Quick Start
|
|
|
|
```kotlin
|
|
import com.bytelyst.platform.*
|
|
|
|
// Configure the SDK
|
|
val config = BLPlatformConfig(
|
|
productId = "lysnrai",
|
|
baseURL = "https://api.bytelyst.io/v1",
|
|
getAuthToken = { authRepository.getToken() }
|
|
)
|
|
|
|
// Create clients
|
|
val broadcastClient = BLBroadcastClient(config)
|
|
val surveyClient = BLSurveyClient(config)
|
|
```
|
|
|
|
## Broadcast Client
|
|
|
|
### Basic Usage
|
|
|
|
```kotlin
|
|
import com.bytelyst.platform.*
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
|
|
class BroadcastManager(
|
|
private val client: BLBroadcastClient
|
|
) {
|
|
private val _messages = MutableStateFlow<List<InAppMessage>>(emptyList())
|
|
val messages: StateFlow<List<InAppMessage>> = _messages
|
|
|
|
private val _unreadCount = MutableStateFlow(0)
|
|
val unreadCount: StateFlow<Int> = _unreadCount
|
|
|
|
fun startListening() {
|
|
client.startPolling(60000L) { messages ->
|
|
_messages.value = messages
|
|
_unreadCount.value = messages.count { it.status == MessageStatus.UNREAD }
|
|
}
|
|
}
|
|
|
|
fun stopListening() {
|
|
client.stopPolling()
|
|
}
|
|
|
|
suspend fun markRead(messageId: String) {
|
|
client.markRead(messageId)
|
|
}
|
|
|
|
suspend fun dismiss(messageId: String) {
|
|
client.markDismissed(messageId)
|
|
}
|
|
|
|
suspend fun handleTap(message: InAppMessage) {
|
|
client.trackClick(message.id)
|
|
|
|
message.ctaUrl?.let { url ->
|
|
// Open URL with your navigation system
|
|
navigationService.openUrl(url)
|
|
}
|
|
|
|
markRead(message.id)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Jetpack Compose Integration
|
|
|
|
```kotlin
|
|
import com.bytelyst.platform.ui.*
|
|
|
|
@Composable
|
|
fun AppContent() {
|
|
val broadcastManager = remember { BroadcastManager(broadcastClient) }
|
|
val messages by broadcastManager.messages.collectAsState()
|
|
val unreadCount by broadcastManager.unreadCount.collectAsState()
|
|
|
|
LaunchedEffect(Unit) {
|
|
broadcastManager.startListening()
|
|
}
|
|
|
|
Scaffold(
|
|
topBar = {
|
|
// Banner for unread messages
|
|
InAppMessageBanner(
|
|
client = broadcastClient,
|
|
position = BannerPosition.TOP
|
|
)
|
|
}
|
|
) { padding ->
|
|
MainContent(modifier = Modifier.padding(padding))
|
|
}
|
|
}
|
|
```
|
|
|
|
### Modal Messages
|
|
|
|
```kotlin
|
|
@Composable
|
|
fun AppRoot() {
|
|
val broadcastClient = remember { BLBroadcastClient(config) }
|
|
|
|
Box(modifier = Modifier.fillMaxSize()) {
|
|
NavigationHost()
|
|
|
|
// Modal overlay
|
|
BroadcastModal(client = broadcastClient)
|
|
}
|
|
}
|
|
```
|
|
|
|
## Survey Client
|
|
|
|
### Basic Usage
|
|
|
|
```kotlin
|
|
import com.bytelyst.platform.*
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
|
|
class SurveyManager(
|
|
private val client: BLSurveyClient
|
|
) {
|
|
private val _activeSurvey = MutableStateFlow<ActiveSurvey?>(null)
|
|
val activeSurvey: StateFlow<ActiveSurvey?> = _activeSurvey
|
|
|
|
private val _currentQuestionIndex = MutableStateFlow(0)
|
|
val currentQuestionIndex: StateFlow<Int> = _currentQuestionIndex
|
|
|
|
private val _answers = MutableStateFlow<Map<String, SurveyAnswer>>(emptyMap())
|
|
val answers: StateFlow<Map<String, SurveyAnswer>> = _answers
|
|
|
|
private val _isComplete = MutableStateFlow(false)
|
|
val isComplete: StateFlow<Boolean> = _isComplete
|
|
|
|
suspend fun checkForSurveys() {
|
|
val result = client.getActiveSurvey()
|
|
result.onSuccess { survey ->
|
|
survey?.let {
|
|
_activeSurvey.value = it
|
|
client.startSurvey(it.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
suspend fun submitAnswer(question: Question, answer: SurveyAnswer) {
|
|
val survey = _activeSurvey.value ?: return
|
|
|
|
val result = client.submitAnswer(
|
|
surveyId = survey.id,
|
|
questionId = question.id,
|
|
answer = answer
|
|
)
|
|
|
|
result.onSuccess { response ->
|
|
_currentQuestionIndex.value = response.currentQuestionIndex
|
|
_answers.value = _answers.value + (question.id to answer)
|
|
|
|
if (response.isComplete) {
|
|
completeSurvey()
|
|
}
|
|
}
|
|
}
|
|
|
|
suspend fun completeSurvey() {
|
|
val survey = _activeSurvey.value ?: return
|
|
|
|
val result = client.completeSurvey(survey.id)
|
|
result.onSuccess { completion ->
|
|
if (completion.success) {
|
|
_isComplete.value = true
|
|
|
|
if (completion.incentiveClaimed) {
|
|
showIncentiveToast(
|
|
amount = completion.incentiveAmount,
|
|
type = completion.incentiveType
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
suspend fun dismiss() {
|
|
val survey = _activeSurvey.value ?: return
|
|
client.dismissSurvey(survey.id)
|
|
|
|
_activeSurvey.value = null
|
|
_currentQuestionIndex.value = 0
|
|
_answers.value = emptyMap()
|
|
}
|
|
}
|
|
```
|
|
|
|
### Jetpack Compose Survey Modal
|
|
|
|
```kotlin
|
|
@Composable
|
|
fun AppRoot() {
|
|
val surveyClient = remember { BLSurveyClient(config) }
|
|
|
|
Box(modifier = Modifier.fillMaxSize()) {
|
|
NavigationHost()
|
|
|
|
// Survey modal overlay
|
|
SurveyModal(client = surveyClient)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Custom Survey UI
|
|
|
|
```kotlin
|
|
@Composable
|
|
fun CustomSurveyView(
|
|
manager: SurveyManager = viewModel()
|
|
) {
|
|
val survey by manager.activeSurvey.collectAsState()
|
|
val currentIndex by manager.currentQuestionIndex.collectAsState()
|
|
val isComplete by manager.isComplete.collectAsState()
|
|
|
|
if (survey != null && !isComplete) {
|
|
val question = survey!!.questions[currentIndex]
|
|
val isLast = currentIndex == survey!!.questions.size - 1
|
|
|
|
Column(modifier = Modifier.padding(16.dp)) {
|
|
// Progress
|
|
LinearProgressIndicator(
|
|
progress = (currentIndex + 1) / survey!!.questions.size.toFloat(),
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
|
|
Text(
|
|
text = "Question ${currentIndex + 1} of ${survey!!.questions.size}",
|
|
style = MaterialTheme.typography.labelSmall
|
|
)
|
|
|
|
// Question
|
|
Text(
|
|
text = question.text,
|
|
style = MaterialTheme.typography.titleLarge
|
|
)
|
|
|
|
question.description?.let {
|
|
Text(
|
|
text = it,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
|
|
// Answer input based on type
|
|
QuestionInput(
|
|
question = question,
|
|
onAnswer = { answer ->
|
|
coroutineScope.launch {
|
|
manager.submitAnswer(question, answer)
|
|
}
|
|
}
|
|
)
|
|
|
|
// Navigation
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween
|
|
) {
|
|
if (!question.required) {
|
|
TextButton(onClick = { /* Skip */ }) {
|
|
Text("Skip")
|
|
}
|
|
}
|
|
|
|
Button(
|
|
onClick = { /* Submit */ },
|
|
enabled = canSubmit(question)
|
|
) {
|
|
Text(if (isLast) "Complete" else "Next")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun QuestionInput(
|
|
question: Question,
|
|
onAnswer: (SurveyAnswer) -> Unit
|
|
) {
|
|
when (question.type) {
|
|
QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> {
|
|
var selected by remember { mutableStateOf<String?>(null) }
|
|
|
|
Column {
|
|
question.options?.forEach { option ->
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable {
|
|
selected = option.id
|
|
onAnswer(SurveyAnswer(
|
|
type = "single_choice",
|
|
value = JsonObject(mapOf("value" to JsonPrimitive(option.id)))
|
|
))
|
|
}
|
|
.padding(12.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(text = option.emoji ?: "")
|
|
Text(text = option.text, modifier = Modifier.weight(1f))
|
|
RadioButton(
|
|
selected = selected == option.id,
|
|
onClick = null
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
QuestionType.MULTIPLE_CHOICE -> {
|
|
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
|
|
|
|
Column {
|
|
question.options?.forEach { option ->
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable {
|
|
selected = if (selected.contains(option.id)) {
|
|
selected - option.id
|
|
} else {
|
|
selected + option.id
|
|
}
|
|
onAnswer(SurveyAnswer(
|
|
type = "multiple_choice",
|
|
value = JsonObject(mapOf("values" to JsonArray(selected.map { JsonPrimitive(it) })))
|
|
))
|
|
}
|
|
.padding(12.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(text = option.emoji ?: "")
|
|
Text(text = option.text, modifier = Modifier.weight(1f))
|
|
Checkbox(
|
|
checked = selected.contains(option.id),
|
|
onCheckedChange = null
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
QuestionType.NPS, QuestionType.RATING, QuestionType.SCALE -> {
|
|
var rating by remember { mutableIntStateOf(0) }
|
|
val minValue = question.minValue ?: if (question.type == QuestionType.NPS) 0 else 1
|
|
val maxValue = question.maxValue ?: if (question.type == QuestionType.NPS) 10 else 5
|
|
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
(minValue..maxValue).forEach { value ->
|
|
Button(
|
|
onClick = {
|
|
rating = value
|
|
onAnswer(SurveyAnswer(
|
|
type = "rating",
|
|
value = JsonObject(mapOf("value" to JsonPrimitive(value)))
|
|
))
|
|
},
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = if (rating == value) MaterialTheme.colorScheme.primary
|
|
else MaterialTheme.colorScheme.surfaceVariant
|
|
)
|
|
) {
|
|
Text("$value")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
QuestionType.TEXT_SHORT -> {
|
|
var text by remember { mutableStateOf("") }
|
|
OutlinedTextField(
|
|
value = text,
|
|
onValueChange = {
|
|
text = it
|
|
onAnswer(SurveyAnswer(
|
|
type = "text",
|
|
value = JsonObject(mapOf("value" to JsonPrimitive(it)))
|
|
))
|
|
},
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
}
|
|
|
|
QuestionType.TEXT_LONG -> {
|
|
var text by remember { mutableStateOf("") }
|
|
OutlinedTextField(
|
|
value = text,
|
|
onValueChange = {
|
|
text = it
|
|
onAnswer(SurveyAnswer(
|
|
type = "text",
|
|
value = JsonObject(mapOf("value" to JsonPrimitive(it)))
|
|
))
|
|
},
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.heightIn(min = 120.dp),
|
|
maxLines = 6
|
|
)
|
|
}
|
|
|
|
else -> { /* Handle other types */ }
|
|
}
|
|
}
|
|
```
|
|
|
|
## Push Notifications
|
|
|
|
### Firebase Cloud Messaging
|
|
|
|
```kotlin
|
|
import com.google.firebase.messaging.FirebaseMessagingService
|
|
import com.google.firebase.messaging.RemoteMessage
|
|
import com.bytelyst.platform.*
|
|
|
|
class PushNotificationService : FirebaseMessagingService() {
|
|
private lateinit var broadcastClient: BLBroadcastClient
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
val config = BLPlatformConfig(
|
|
productId = "lysnrai",
|
|
baseURL = "https://api.bytelyst.io/v1",
|
|
getAuthToken = { authRepository.getToken() }
|
|
)
|
|
broadcastClient = BLBroadcastClient(config)
|
|
}
|
|
|
|
override fun onNewToken(token: String) {
|
|
super.onNewToken(token)
|
|
|
|
// Register device token
|
|
coroutineScope.launch {
|
|
broadcastClient.registerDeviceToken(token, Platform.ANDROID)
|
|
}
|
|
}
|
|
|
|
override fun onMessageReceived(message: RemoteMessage) {
|
|
super.onMessageReceived(message)
|
|
|
|
// Handle broadcast notification
|
|
message.data["broadcastId"]?.let { broadcastId ->
|
|
showNotification(message)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Register Device Token
|
|
|
|
```kotlin
|
|
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
|
if (task.isSuccessful) {
|
|
val token = task.result
|
|
coroutineScope.launch {
|
|
broadcastClient.registerDeviceToken(token, Platform.ANDROID)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
```kotlin
|
|
val result = client.getActiveSurvey()
|
|
|
|
result.onSuccess { survey ->
|
|
survey?.let { showSurvey(it) }
|
|
}.onError { error ->
|
|
when (error) {
|
|
is BLApiError.Unauthorized -> {
|
|
// Re-authenticate user
|
|
authRepository.refreshToken()
|
|
}
|
|
is BLApiError.NetworkError -> {
|
|
// Retry with exponential backoff
|
|
Log.w("Survey", "Network error, will retry")
|
|
}
|
|
is BLApiError.ServerError -> {
|
|
Log.e("Survey", "Server error: ${error.code}")
|
|
}
|
|
else -> {
|
|
Log.e("Survey", "Unknown error: ${error.message}")
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Configuration
|
|
|
|
### Environment-Based Config
|
|
|
|
```kotlin
|
|
sealed class Environment(
|
|
val baseURL: String
|
|
) {
|
|
object Development : Environment("http://localhost:4003")
|
|
object Staging : Environment("https://api-staging.bytelyst.io/v1")
|
|
object Production : Environment("https://api.bytelyst.io/v1")
|
|
}
|
|
|
|
val config = BLPlatformConfig(
|
|
productId = "lysnrai",
|
|
baseURL = Environment.Production.baseURL,
|
|
getAuthToken = { authRepository.getToken() },
|
|
enableLogging = BuildConfig.DEBUG
|
|
)
|
|
```
|
|
|
|
## Offline Support
|
|
|
|
The SDK automatically caches survey responses offline:
|
|
|
|
```kotlin
|
|
val client = BLSurveyClient(
|
|
config = config,
|
|
enableOfflineCache = true // Enabled by default
|
|
)
|
|
|
|
// Responses are queued when offline
|
|
// Flush manually or on network restore
|
|
connectivityManager.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
|
|
override fun onAvailable(network: Network) {
|
|
coroutineScope.launch {
|
|
client.flushOfflineQueue()
|
|
}
|
|
}
|
|
})
|
|
```
|
|
|
|
## Requirements
|
|
|
|
- Android 8.0+ (API 26+)
|
|
- Kotlin 1.9+
|
|
- Jetpack Compose (optional, for UI components)
|
|
|
|
## License
|
|
|
|
MIT © ByteLyst
|