diff --git a/.gitignore b/.gitignore index d6d3870..9b3dadf 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,9 @@ .cxx local.properties *.aab +.idea/ +app/src/main/res/drawable/ +.kotlin/errors/ +app/release +app/src/main/ic_launcher-playstore.png +app/src/main/res/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d2e8e32..85e67ba 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") + id("kotlinx-serialization") } android { @@ -72,4 +73,5 @@ dependencies { implementation("io.branch.sdk.android:library:5.9.0") implementation("com.google.android.gms:play-services-ads-identifier:18.0.1") implementation("com.android.installreferrer:installreferrer:2.2") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") } \ No newline at end of file diff --git a/app/src/main/java/io/branch/branchlinksimulator/ApiConfigManager.kt b/app/src/main/java/io/branch/branchlinksimulator/ApiConfigManager.kt new file mode 100644 index 0000000..603f5c0 --- /dev/null +++ b/app/src/main/java/io/branch/branchlinksimulator/ApiConfigManager.kt @@ -0,0 +1,14 @@ +package io.branch.branchlinksimulator + +import android.content.SharedPreferences + +object ApiConfigManager { + fun loadConfigOrDefault(preferences: SharedPreferences): ApiConfiguration { + return loadConfig(preferences) ?: apiConfigurationsMap[STAGING] ?: ApiConfiguration("N/A", "N/A", "N/A", false) + } + + private fun loadConfig(preferences: SharedPreferences): ApiConfiguration? { + val configName = preferences.getString(SELECTED_CONFIG_NAME, null) + return configName?.let { apiConfigurationsMap[it] } + } +} diff --git a/app/src/main/java/io/branch/branchlinksimulator/ApiConfiguration.kt b/app/src/main/java/io/branch/branchlinksimulator/ApiConfiguration.kt new file mode 100644 index 0000000..91b07fa --- /dev/null +++ b/app/src/main/java/io/branch/branchlinksimulator/ApiConfiguration.kt @@ -0,0 +1,40 @@ +package io.branch.branchlinksimulator + +data class ApiConfiguration( + val branchKey: String, + val apiUrl: String, + val appId: String, + val staging: Boolean +) + +const val STAGING = "Staging" +const val PRODUCTION = "Production" +const val STAGING_AC = "Staging AC" +const val PRODUCTION_AC = "Production AC" + +val apiConfigurationsMap: Map = mapOf( + STAGING_AC to ApiConfiguration( + branchKey = "key_live_juoZrlpzQZvBQbwR33GO5hicszlTGnVT", + apiUrl = "https://protected-api.stage.branch.io/", + appId = "1387589751543976586", + staging = true + ), + STAGING to ApiConfiguration( + branchKey = "key_live_plqOidX7fW71Gzt0LdCThkemDEjCbTgx", + apiUrl = "https://api.stage.branch.io/", + appId = "436637608899006753", + staging = true + ), + PRODUCTION_AC to ApiConfiguration( + branchKey = "key_live_hshD4wiPK2sSxfkZqkH30ggmyBfmGmD7", + apiUrl = "https://protected-api.branch.io/", + appId = "1284289243903971463", + staging = false + ), + PRODUCTION to ApiConfiguration( + branchKey = "key_live_iDiV7ZewvDm9GIYxUnwdFdmmvrc9m3Aw", + apiUrl = "https://api2.branch.io/", + appId = "1364964166783226677", + staging = false + ) +) diff --git a/app/src/main/java/io/branch/branchlinksimulator/ApiSettingsPanel.kt b/app/src/main/java/io/branch/branchlinksimulator/ApiSettingsPanel.kt new file mode 100644 index 0000000..537fd79 --- /dev/null +++ b/app/src/main/java/io/branch/branchlinksimulator/ApiSettingsPanel.kt @@ -0,0 +1,162 @@ +package io.branch.branchlinksimulator + +import androidx.compose.ui.platform.ClipboardManager +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlin.system.exitProcess + +const val SELECTED_CONFIG_NAME = "selectedConfigName" +const val PREFERENCES_KEY = "ApiPreferences" + +@Composable +fun ApiSettingsPanel() { + val context = LocalContext.current + val preferences = remember { context.getSharedPreferences(PREFERENCES_KEY, Context.MODE_PRIVATE) } + var selectedConfig by remember { mutableStateOf(ApiConfigManager.loadConfigOrDefault(preferences)) } + + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(16.dp) + ) { + ApiInfoRow(label = "Branch Key", value = selectedConfig.branchKey) + ApiInfoRow(label = "API URL", value = selectedConfig.apiUrl) + ApiInfoRow(label = "App ID", value = selectedConfig.appId) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + ApiButton(configName = STAGING, selectedConfig = selectedConfig, Modifier.weight(1f), onSelect = { + selectedConfig = it + saveConfig(preferences, it) + }) + ApiButton(configName = PRODUCTION, selectedConfig = selectedConfig, Modifier.weight(1f), onSelect = { + selectedConfig = it + saveConfig(preferences, it) + }) + } + + Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + ApiButton(configName = STAGING_AC, selectedConfig = selectedConfig, Modifier.weight(1f), onSelect = { + selectedConfig = it + saveConfig(preferences, it) + }) + ApiButton(configName = PRODUCTION_AC, selectedConfig = selectedConfig, Modifier.weight(1f), onSelect = { + selectedConfig = it + saveConfig(preferences, it) + }) + } + } + } +} + +@Composable +fun ApiInfoRow(label: String, value: String) { + val localClipboardManager = LocalClipboardManager.current + + Row( + modifier = Modifier + .background(Color.LightGray, RoundedCornerShape(10.dp)) + .padding(horizontal = 10.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = "$label:", + style = MaterialTheme.typography.labelMedium, + color = Color.Black + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + color = Color.Black + ) + } + + Button( + onClick = { copyToClipboard(localClipboardManager, value) }, + ) { + Text("Copy") + } + } +} + + +@Composable +fun ApiButton( + configName: String, + selectedConfig: ApiConfiguration, + modifier: Modifier = Modifier, + onSelect: (ApiConfiguration) -> Unit +) { + val config = apiConfigurationsMap[configName] + val isSelected = selectedConfig == config + + var showDialog by remember { mutableStateOf(false) } + + Button( + onClick = { + if (config != null) { + onSelect(config) + showDialog = true + } + }, + modifier = modifier.padding(0.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isSelected) Color.Blue else Color.Gray, + contentColor = Color.White + ) + ) { + Text( + text = configName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + + if (showDialog) { + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { + Text(text = "Configuration Changed") + }, + text = { + Text(text = "You need to restart the app for the changes to take effect.") + }, + dismissButton = { + Button(onClick = { showDialog = false }) { + Text("Cancel") + } + }, + confirmButton = { + Button(onClick = { exitProcess(0) }) { + Text("OK") + } + } + ) + } +} + +fun saveConfig(preferences: SharedPreferences, config: ApiConfiguration) { + val configName = apiConfigurationsMap.entries.firstOrNull { it.value == config }?.key ?: STAGING + preferences.edit().putString(SELECTED_CONFIG_NAME, configName).apply() +} + +fun copyToClipboard(manager: ClipboardManager, text: String) { + manager.setText(AnnotatedString(text)) +} diff --git a/app/src/main/java/io/branch/branchlinksimulator/BranchLinkSimulatorApplication.kt b/app/src/main/java/io/branch/branchlinksimulator/BranchLinkSimulatorApplication.kt index 8e1da38..a9a09f9 100644 --- a/app/src/main/java/io/branch/branchlinksimulator/BranchLinkSimulatorApplication.kt +++ b/app/src/main/java/io/branch/branchlinksimulator/BranchLinkSimulatorApplication.kt @@ -6,15 +6,22 @@ import io.branch.referral.Branch import java.util.UUID class BranchLinkSimulatorApplication: Application() { + private lateinit var currentConfig: ApiConfiguration + lateinit var roundTripStore: RoundTripStore + private set + override fun onCreate() { super.onCreate() - // Branch logging for debugging - Branch.enableLogging() - Branch.setAPIUrl("https://protected-api.branch.io/") + val preferences = getSharedPreferences(PREFERENCES_KEY, Context.MODE_PRIVATE) + currentConfig = ApiConfigManager.loadConfigOrDefault(preferences) + + Branch.setAPIUrl(currentConfig.apiUrl) + roundTripStore = RoundTripStore(this) + Branch.enableLogging(roundTripStore) // Branch object initialization - Branch.getAutoInstance(this) + Branch.getAutoInstance(this, currentConfig.branchKey) // Retrieve or create the bls_session_id val sharedPreferences = getSharedPreferences("branch_session_prefs", Context.MODE_PRIVATE) @@ -26,6 +33,5 @@ class BranchLinkSimulatorApplication: Application() { // Set the bls_session_id in Branch request metadata Branch.getInstance().setRequestMetadata("bls_session_id", blsSessionId) - } } \ No newline at end of file diff --git a/app/src/main/java/io/branch/branchlinksimulator/MainActivity.kt b/app/src/main/java/io/branch/branchlinksimulator/MainActivity.kt index e26bdd6..d5fe448 100644 --- a/app/src/main/java/io/branch/branchlinksimulator/MainActivity.kt +++ b/app/src/main/java/io/branch/branchlinksimulator/MainActivity.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.navigation.NavController import androidx.navigation.NavHostController @@ -55,14 +54,12 @@ import io.branch.referral.util.BranchEvent.BranchLogEventCallback import io.branch.referral.util.LinkProperties import java.net.URLEncoder import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.unit.sp -import io.branch.referral.PrefHelper import java.util.UUID var customerEventAlias = "" @@ -70,10 +67,13 @@ var sessionID = "" class MainActivity : ComponentActivity() { private var navController: NavHostController? = null + private lateinit var roundTripStore: RoundTripStore override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + this.roundTripStore = (application as BranchLinkSimulatorApplication).roundTripStore + setContent { BranchLinkSimulatorTheme { Surface( @@ -85,6 +85,11 @@ class MainActivity : ComponentActivity() { composable("main") { MainContent(navController!!) } + + composable("request") { + RoundTripsNavHost(navController = rememberNavController(), roundTripStore = roundTripStore) + } + composable( route = "details/{title}/{params}", arguments = listOf( @@ -151,12 +156,9 @@ class MainActivity : ComponentActivity() { @Composable fun MainContent(navController: NavController) { val context = LocalContext.current - var showAPIDialog by remember { mutableStateOf(false) } var showAliasDialog by remember { mutableStateOf(false) } var showSessionIdDialog by remember { mutableStateOf(false) } - var textFieldValue by remember { mutableStateOf(PrefHelper.getInstance(context).apiBaseUrl) } - val sharedPreferences = context.getSharedPreferences("branch_session_prefs", Context.MODE_PRIVATE) val blsSessionId = sharedPreferences.getString("bls_session_id", null) ?: UUID.randomUUID().toString().also { sharedPreferences.edit().putString("bls_session_id", it).apply() @@ -170,110 +172,97 @@ fun MainContent(navController: NavController) { customerEventAlias = savedAlias var aliasValue by remember { mutableStateOf(savedAlias) } - Column(modifier = Modifier.padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Image( - painter = painterResource(id = R.drawable.branch_badge_all_white), - contentDescription = "App Logo", - modifier = Modifier.size(36.dp), - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Branch Link Simulator", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground) + LazyColumn(modifier = Modifier.padding(16.dp)) { + item { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Image( + painter = painterResource(id = R.drawable.branch_badge_all_white), + contentDescription = "App Logo", + modifier = Modifier.size(36.dp), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Branch Link Simulator", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground) + } } - Spacer(modifier = Modifier.height(16.dp)) - SectionHeader(title = "Deep Link Pages") - ButtonRow(navController, Modifier.fillMaxWidth()) - - SectionHeader(title = "Events") - FunctionButtonRow(Modifier.fillMaxWidth(), LocalContext.current) + item { Spacer(modifier = Modifier.height(16.dp)) } - SectionHeader(title = "Settings") - - RoundedButton(title = "Change Branch API URL", icon = R.drawable.api) { - showAPIDialog = true + item { SectionHeader(title = "Deep Link Pages") } + item { + ButtonRow(navController, Modifier.fillMaxWidth()) } - RoundedButton(title = "Set Customer Event Alias", icon = R.drawable.badge) { - showAliasDialog = true + item { SectionHeader(title = "Events") } + item { + EventsColumn(navController, Modifier.fillMaxWidth(), LocalContext.current) } - RoundedButton(title = "Change App's Session ID", icon = R.drawable.branch_badge_all_white) { - showSessionIdDialog = true - } + item { SectionHeader(title = "Api Settings")} + item { ApiSettingsPanel() } - if (showAPIDialog) { - AlertDialog( - onDismissRequest = { showAPIDialog = false }, - title = { Text("Enter API URL") }, - text = { - TextField( - value = textFieldValue, - onValueChange = { textFieldValue = it }, - label = { Text("Ex. https://api2.branch.io/") }, - ) - }, - confirmButton = { - TextButton(onClick = { - Branch.setAPIUrl(textFieldValue) - Toast.makeText(context, "Set Branch API URL to $textFieldValue", Toast.LENGTH_SHORT).show() - showAPIDialog = false - }) { - Text("Save") - } - } - ) + item { SectionHeader(title = "Event Settings") } + item { + RoundedButton(title = "Set Customer Event Alias", icon = R.drawable.badge) { + showAliasDialog = true + } + } + item { + RoundedButton(title = "Change App's Session ID", icon = R.drawable.branch_badge_all_white) { + showSessionIdDialog = true + } } - if (showAliasDialog) { - AlertDialog( - onDismissRequest = { showAliasDialog = false }, - title = { Text("Enter Customer Event Alias") }, - text = { - TextField( - value = aliasValue, - onValueChange = { aliasValue = it }, - label = { Text("Ex. mainAlias") }, - ) - }, - confirmButton = { - TextButton(onClick = { - sharedPreferences.edit().putString("customer_event_alias", aliasValue).apply() - customerEventAlias = aliasValue - Toast.makeText(context, "Set Customer Event Alias to $aliasValue", Toast.LENGTH_SHORT).show() - showAliasDialog = false - }) { - Text("Save") + if (showAliasDialog) { + item { + AlertDialog( + onDismissRequest = { showAliasDialog = false }, + title = { Text("Enter Customer Event Alias") }, + text = { + TextField( + value = aliasValue, + onValueChange = { aliasValue = it }, + label = { Text("Ex. mainAlias") }, + ) + }, + confirmButton = { + TextButton(onClick = { + sharedPreferences.edit().putString("customer_event_alias", aliasValue).apply() + customerEventAlias = aliasValue + Toast.makeText(context, "Set Customer Event Alias to $aliasValue", Toast.LENGTH_SHORT).show() + showAliasDialog = false + }) { + Text("Save") + } } - } - ) + ) + } } if (showSessionIdDialog) { - - AlertDialog( - onDismissRequest = { showSessionIdDialog = false }, - title = { Text("Enter A Session ID") }, - text = { - TextField( - value = sessionIdValue, - onValueChange = { sessionIdValue = it }, - label = { Text("Ex. testingSession02") }, - ) - }, - confirmButton = { - TextButton(onClick = { - Branch.getInstance().setRequestMetadata("bls_session_id", sessionIdValue) - sharedPreferences.edit().putString("bls_session_id", sessionIdValue).apply() - - Toast.makeText(context, "Set App's Session ID to $sessionIdValue", Toast.LENGTH_SHORT).show() - showSessionIdDialog = false - }) { - Text("Save") + item { + AlertDialog( + onDismissRequest = { showSessionIdDialog = false }, + title = { Text("Enter A Session ID") }, + text = { + TextField( + value = sessionIdValue, + onValueChange = { sessionIdValue = it }, + label = { Text("Ex. testingSession02") }, + ) + }, + confirmButton = { + TextButton(onClick = { + Branch.getInstance().setRequestMetadata("bls_session_id", sessionIdValue) + sharedPreferences.edit().putString("bls_session_id", sessionIdValue).apply() + Toast.makeText(context, "Set App's Session ID to $sessionIdValue", Toast.LENGTH_SHORT).show() + showSessionIdDialog = false + }) { + Text("Save") + } } - } - ) + ) + } } } } @@ -308,7 +297,7 @@ fun ButtonRow(navController: NavController, modifier: Modifier = Modifier) { @Composable -fun FunctionButtonRow(modifier: Modifier = Modifier, context: android.content.Context) { +fun EventsColumn(navController: NavController, modifier: Modifier = Modifier, context: Context) { val showDialog = remember { mutableStateOf(false) } fun sendEvent(eventType: String) { @@ -355,6 +344,10 @@ fun FunctionButtonRow(modifier: Modifier = Modifier, context: android.content.Co icon = R.drawable.send_custom ) { sendCustomEvent(context) } + + RoundedButton(title = "See Requests", icon = R.drawable.branch_badge_all_white) { + navController.navigate("request") + } } } @@ -372,7 +365,7 @@ fun sendStandardEvent(context: Context, event: BRANCH_STANDARD_EVENT) { } }) } -fun sendCustomEvent(context: android.content.Context) { +fun sendCustomEvent(context: Context) { BranchEvent("My Custom Event") .setCustomerEventAlias(customerEventAlias) .addCustomDataProperty("bls_session_id", sessionID) diff --git a/app/src/main/java/io/branch/branchlinksimulator/RoundTrip.kt b/app/src/main/java/io/branch/branchlinksimulator/RoundTrip.kt new file mode 100644 index 0000000..ceed7cd --- /dev/null +++ b/app/src/main/java/io/branch/branchlinksimulator/RoundTrip.kt @@ -0,0 +1,28 @@ +package io.branch.branchlinksimulator + +import kotlinx.serialization.Serializable +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + +@Serializable +data class RoundTrip( + val url: String, + val id: String = UUID.randomUUID().toString(), + val timestamp: String = LocalDateTime.now().format(formatter), + val request: BranchRequest? = null, + var response: BranchResponse? = null +) + +@Serializable +data class BranchRequest( + val body: String? +) + +@Serializable +data class BranchResponse( + val statusCode: String, + val body: String +) \ No newline at end of file diff --git a/app/src/main/java/io/branch/branchlinksimulator/RoundTripStore.kt b/app/src/main/java/io/branch/branchlinksimulator/RoundTripStore.kt new file mode 100644 index 0000000..3fb67f6 --- /dev/null +++ b/app/src/main/java/io/branch/branchlinksimulator/RoundTripStore.kt @@ -0,0 +1,138 @@ +package io.branch.branchlinksimulator + +import android.content.Context +import android.content.SharedPreferences +import io.branch.interfaces.IBranchLoggingCallbacks +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class RoundTripStore(context: Context) : IBranchLoggingCallbacks { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val storageKey = "savedRoundTrips" + private val preferences: SharedPreferences = + context.getSharedPreferences("RoundTripStorePrefs", Context.MODE_PRIVATE) + + private val _roundTrips = MutableStateFlow(loadRoundTrips()) + val roundTrips: StateFlow> = _roundTrips + + private val FAILED = "failed to parse" + + override fun onBranchLog(logMessage: String?, severityConstantName: String?) { + scope.launch(Dispatchers.Main) { + processLog(logMessage) + } + } + + private suspend fun addRoundTrip(url: String) { + withContext(Dispatchers.Main) { + val newTrip = RoundTrip( + url = url, + ) + _roundTrips.value = listOf(newTrip) + _roundTrips.value + trimToLimit() + saveRoundTrips() + } + } + + private suspend fun addRequest(request: BranchRequest) { + withContext(Dispatchers.Main) { + val trips = _roundTrips.value.toMutableList() + _roundTrips.value[0].let { currentTrip -> + val updatedTrip = currentTrip.copy(request = request) + trips[0] = updatedTrip + _roundTrips.value = trips + saveRoundTrips() + } + } + } + + private suspend fun addResponse(response: BranchResponse) { + withContext(Dispatchers.Main) { + val trips = _roundTrips.value.toMutableList() + _roundTrips.value[0].let { currentTrip -> + val updatedTrip = currentTrip.copy(response = response) + trips[0] = updatedTrip + _roundTrips.value = trips + saveRoundTrips() + } + } + } + + private suspend fun processLog(log: String?) { + if (log == null) return + when { + log.contains("posting to") -> { + addRoundTrip(parseUrl(log) ?: FAILED) + } + log.contains("Post value =") -> { + val request = parseRequestLog(log) + addRequest(request) + } + log.contains("Server returned") -> { + val response = parseResponseLog(log) + addResponse(response) + } + else -> println(log) + } + } + + private fun parseUrl(log: String): String? { + val regex = "(?<=posting to\\s)(https?://\\S+)".toRegex() + return regex.find(log)?.value + } + + private fun parseBody(log: String, identifier: String): String? { + val bodyStart = log.indexOf(identifier).takeIf { it != -1 }?.let { it + identifier.length } + return bodyStart?.let { + log.substring(it).trim() + } + } + + private fun parseRequestLog(log: String): BranchRequest { + return BranchRequest( + body = parseBody(log, "Post value = ") ?: FAILED + ) + } + + private fun parseResponseLog(log: String): BranchResponse { + val statusCode = "(?<=Status: \\[)\\d+(?=])".toRegex().find(log)?.value + return BranchResponse( + statusCode = statusCode ?: FAILED, + body = parseBody(log, "Data: ") ?: FAILED + ) + } + + + private fun trimToLimit() { + if (_roundTrips.value.size > 30) { + _roundTrips.value = _roundTrips.value.take(30) + } + } + + private fun saveRoundTrips() { + try { + val data = Json.encodeToString(_roundTrips.value) + preferences.edit().putString(storageKey, data).apply() + } catch (e: Exception) { + println("Failed to save round trips: ${e.message}") + } + } + + private fun loadRoundTrips(): List { + return try { + val data = preferences.getString(storageKey, null) + if (data != null) Json.decodeFromString(data) else emptyList() + } catch (e: Exception) { + println("Failed to load round trips: ${e.message}") + emptyList() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/branch/branchlinksimulator/RoundTripsView.kt b/app/src/main/java/io/branch/branchlinksimulator/RoundTripsView.kt new file mode 100644 index 0000000..326de48 --- /dev/null +++ b/app/src/main/java/io/branch/branchlinksimulator/RoundTripsView.kt @@ -0,0 +1,131 @@ +package io.branch.branchlinksimulator + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.* +import androidx.navigation.navArgument + +@Composable +fun RoundTripsNavHost(navController: NavHostController, roundTripStore: RoundTripStore) { + NavHost(navController = navController, startDestination = "requestList") { + composable("requestList") { + RoundTripsView(navController, roundTripStore) + } + composable( + "detail/{roundTripId}", + arguments = listOf(navArgument("roundTripId") { type = NavType.StringType }) + ) { backStackEntry -> + val roundTripId = backStackEntry.arguments?.getString("roundTripId") + val roundTrip = roundTripStore.roundTrips.collectAsState().value.find { it.id == roundTripId } + if (roundTrip != null) { + RoundTripDetailView(roundTrip) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RoundTripsView(navController: NavHostController, viewModel: RoundTripStore) { + Scaffold( + topBar = { + TopAppBar(title = { Text("Requests") }) + }, + content = { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + items(viewModel.roundTrips.value) { roundTrip -> + RoundTripViewItem(navController, roundTrip) + Divider(color = Color.Gray, thickness = 1.dp) + } + } + } + ) +} + +@Composable +fun RoundTripViewItem(navController: NavHostController, roundTrip: RoundTrip) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { navController.navigate("detail/${roundTrip.id}") } + .padding(vertical = 12.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(roundTrip.url, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground) + Text( + roundTrip.timestamp, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + roundTrip.response?.let { + Text( + "Status Code: ${it.statusCode}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + } + } + } +} + +@Composable +fun RoundTripDetailView(roundTrip: RoundTrip) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + VariableView(label = "URL", value = roundTrip.url) + Spacer(modifier = Modifier.height(8.dp)) + Text("Request", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + VariableView(label = "Body", value = roundTrip.request?.body ?: "FAILED") + Spacer(modifier = Modifier.height(16.dp)) + roundTrip.response?.let { + Text("Response", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Text("Status Code: ${it.statusCode}", style = MaterialTheme.typography.titleSmall, color = Color.White) + VariableView(label = "Body", value = it.body) + } + } +} + +@Composable +fun VariableView(label: String, value: String) { + val localClipboardManager = LocalClipboardManager.current + + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("$label:", style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.weight(1f)) + Button(onClick = { copyToClipboard(localClipboardManager, value) }) { + Text("Copy") + } + } + Spacer(modifier = Modifier.height(4.dp)) + Text(value, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onBackground) + } +} + + diff --git a/build.gradle.kts b/build.gradle.kts index 4645626..57e1092 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,5 @@ plugins { id("com.android.application") version "8.2.2" apply false id("org.jetbrains.kotlin.android") version "1.9.0" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0" } \ No newline at end of file