From 0b50d48211e176aa75f3807cd760043c513a6d9b Mon Sep 17 00:00:00 2001 From: Ankush Bose Date: Fri, 5 Sep 2025 14:44:24 +0530 Subject: [PATCH 1/4] feat: Enhance sample app with interactive terminal and property editor This commit significantly revamps the sample Compose Multiplatform application to provide an interactive "Polling Terminal" experience. **Key changes:** * **Interactive UI:** * Replaced the basic demo with a terminal-like interface (`TerminalLog`) that displays real-time polling logs. * Added an expandable "Properties" card (`PropertiesCard`) allowing users to dynamically configure `BackoffPolicy` parameters (initial delay, max delay, multiplier, jitter, max attempts, overall timeout, per-attempt timeout) via `OutlinedTextField`s. * Introduced a "Start Polling" button (`GlowingButton`) to initiate the polling process with the configured properties. * Displays a countdown timer for the `overallTimeoutMs`. * **Dynamic Polling Configuration:** * The `PollingEngine` is now configured at runtime based on user input from the properties panel. * Input validation is performed before starting the poll. * The sample polling logic now simulates success on the 8th attempt to demonstrate exponential backoff logging. * **Logging and Feedback:** * Detailed logging of polling attempts, results (`describeResult`), and final outcomes (`describeOutcome`) in the terminal. * Log entries are styled (`LogEntryCard`) based on type (info, error, done). * Improved visual feedback for running state and remaining time. * **Theming and Styling:** * Implemented a custom dark theme (`darkScheme`) with neon accents and a specific background color. * Applied a custom sans-serif font family (`buildTechTypography`) to the MaterialTheme. * **Android Specific Changes:** * `MainActivity` now enables edge-to-edge display and enters immersive mode, hiding system bars. * **iOS Specific Changes:** * Added `MainViewController.kt` to provide the `ComposeUIViewController` for the iOS app. * **Default BackoffPolicy Update:** * In `BackoffPolicies.kt`, the `DEFAULT` policy now has `perAttemptTimeoutMs` set to 10000 (previously null). **Functionality:** * The sample app now serves as a more comprehensive demonstration of the `PollingEngine`'s capabilities. * Users can experiment with different `BackoffPolicy` settings and observe their impact on the polling behavior through the live log. * The UI provides clear feedback on the polling process, including individual attempts, results, and overall completion status. --- .../androidplay/pollingengine/MainActivity.kt | 10 + .../in/androidplay/pollingengine/App.kt | 479 ++++++++++++++++-- .../pollingengine/MainViewController.kt | 8 + .../pollingengine/polling/BackoffPolicies.kt | 4 +- 4 files changed, 449 insertions(+), 52 deletions(-) create mode 100644 composeApp/src/iosMain/kotlin/in/androidplay/pollingengine/MainViewController.kt diff --git a/composeApp/src/androidMain/kotlin/in/androidplay/pollingengine/MainActivity.kt b/composeApp/src/androidMain/kotlin/in/androidplay/pollingengine/MainActivity.kt index 04db59e..c2496d7 100644 --- a/composeApp/src/androidMain/kotlin/in/androidplay/pollingengine/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/in/androidplay/pollingengine/MainActivity.kt @@ -6,12 +6,22 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + // Enter immersive mode: hide status and navigation bars + WindowCompat.setDecorFitsSystemWindows(window, false) + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(WindowInsetsCompat.Type.systemBars()) + setContent { App() } diff --git a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt index 7fdbced..8b59e99 100644 --- a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt +++ b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt @@ -1,74 +1,453 @@ package `in`.androidplay.pollingengine -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import org.jetbrains.compose.resources.painterResource -import org.jetbrains.compose.ui.tooling.preview.Preview -import androidx.compose.runtime.rememberCoroutineScope -import `in`.androidplay.pollingengine.sample.PollingSamples.demoPoll +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import `in`.androidplay.pollingengine.models.PollingResult +import `in`.androidplay.pollingengine.polling.BackoffPolicy +import `in`.androidplay.pollingengine.polling.PollingConfig +import `in`.androidplay.pollingengine.polling.PollingEngine +import `in`.androidplay.pollingengine.polling.PollingOutcome import kotlinx.coroutines.launch +import org.jetbrains.compose.ui.tooling.preview.Preview + +// ------- Models and helpers (moved above App to avoid any potential local-declaration parsing issues) ------- -import pollingengine.composeapp.generated.resources.Res -import pollingengine.composeapp.generated.resources.compose_multiplatform @Composable -@Preview -fun App() { - MaterialTheme { - var showContent by remember { mutableStateOf(false) } - var demoResult by remember { mutableStateOf(null) } - var isRunning by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - Column( - modifier = Modifier - .background(MaterialTheme.colorScheme.primaryContainer) - .safeContentPadding() - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Button(onClick = { showContent = !showContent }) { - Text("Toggle Greeting") +private fun TerminalLog(modifier: Modifier = Modifier, logs: List) { + val bg = Color(0xFF0F1115) + val border = Color(0xFF2A2F3A) + + val listState = rememberLazyListState() + + LaunchedEffect(logs.size) { + if (logs.isNotEmpty()) { + listState.animateScrollToItem(logs.lastIndex.coerceAtLeast(0)) + } + } + + Box( + modifier = modifier + .fillMaxWidth() + .clip(androidx.compose.foundation.shape.RoundedCornerShape(8.dp)) + .background(bg) + .border(1.dp, border, androidx.compose.foundation.shape.RoundedCornerShape(8.dp)) + .padding(12.dp) + ) { + SelectionContainer { + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(top = 6.dp, bottom = 24.dp) + ) { + items(logs) { line -> + LogEntryCard(line) + } } - AnimatedVisibility(showContent) { - val greeting = remember { Greeting().greet() } + } + } +} + +// --- Simple log entry card without icons for multiplatform compatibility --- +@Composable +private fun LogEntryCard(line: String) { + val (bgColor, textColor) = when { + line.startsWith("[error]") -> Color(0x22FF5A5A) to Color(0xFFFFB4B4) + line.startsWith("[done]") -> Color(0x2220E5A8) to Color(0xFFB2FFE5) + line.startsWith("[info]") -> Color(0x222A7FFF) to Color(0xFFB7D8FF) + else -> Color(0x222A2F3A) to Color(0xFFE5E7EB) + } + Surface( + tonalElevation = 2.dp, + shadowElevation = 1.dp, + color = bgColor, + shape = androidx.compose.foundation.shape.RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 6.dp, horizontal = 10.dp) + ) { + Text( + text = line, + color = textColor, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun PropertiesCard( + initialDelayText: String, onInitialChange: (String) -> Unit, + maxDelayText: String, onMaxDelayChange: (String) -> Unit, + multiplierText: String, onMultiplierChange: (String) -> Unit, + jitterText: String, onJitterChange: (String) -> Unit, + maxAttemptsText: String, onMaxAttemptsChange: (String) -> Unit, + overallTimeoutText: String, onOverallTimeoutChange: (String) -> Unit, + perAttemptTimeoutText: String, onPerAttemptTimeoutChange: (String) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)) + .border( + 1.dp, + MaterialTheme.colorScheme.outline, + androidx.compose.foundation.shape.RoundedCornerShape(12.dp) + ) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(12.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Image(painterResource(Res.drawable.compose_multiplatform), null) - Text("Compose: $greeting") + LabeledField("initialDelayMs", initialDelayText, onInitialChange) + LabeledField("multiplier", multiplierText, onMultiplierChange) + LabeledField("maxAttempts", maxAttemptsText, onMaxAttemptsChange) + LabeledField( + "perAttemptTimeoutMs", + perAttemptTimeoutText, + onPerAttemptTimeoutChange + ) } - } - Button(onClick = { - if (!isRunning) { - isRunning = true - demoResult = null - scope.launch { - demoResult = demoPoll() - isRunning = false - } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + LabeledField("maxDelayMs", maxDelayText, onMaxDelayChange) + LabeledField("jitterRatio", jitterText, onJitterChange) + LabeledField("overallTimeoutMs", overallTimeoutText, onOverallTimeoutChange) } - }) { - Text(if (isRunning) "Running…" else "Run Polling Demo") - } - val resultText = when { - isRunning -> "Polling in progress..." - demoResult != null -> "Result: ${demoResult}" - else -> "" } - if (resultText.isNotEmpty()) { - Text(resultText) + } + } +} + +@Composable +private fun LabeledField(label: String, value: String, onValueChange: (String) -> Unit) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + label = { Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant) }, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), + modifier = Modifier.fillMaxWidth() + ) +} + +private fun describeResult(result: PollingResult): String = when (result) { + is PollingResult.Success -> "Success(${result.data})" + is PollingResult.Failure -> "Failure(code=${result.error.code}, msg=${result.error.message})" + is PollingResult.Waiting -> "Waiting" + is PollingResult.Cancelled -> "Cancelled" + is PollingResult.Unknown -> "Unknown" +} + +private fun describeOutcome(outcome: PollingOutcome): String = when (outcome) { + is PollingOutcome.Success -> "Success(value=${outcome.value}, attempts=${outcome.attempts}, elapsedMs=${outcome.elapsedMs})" + is PollingOutcome.Exhausted -> "Exhausted(attempts=${outcome.attempts}, elapsedMs=${outcome.elapsedMs})" + is PollingOutcome.Timeout -> "Timeout(attempts=${outcome.attempts}, elapsedMs=${outcome.elapsedMs})" + is PollingOutcome.Cancelled -> "Cancelled(attempts=${outcome.attempts}, elapsedMs=${outcome.elapsedMs})" +} + +@Composable +private fun GlowingButton( + enabled: Boolean, + text: String, + onClick: () -> Unit, +) { + Button( + enabled = enabled, + onClick = onClick, + shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.9f)), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Text(text, fontWeight = FontWeight.SemiBold) + } +} + +private fun buildTechTypography( + base: androidx.compose.material3.Typography, + family: FontFamily +): androidx.compose.material3.Typography { + return androidx.compose.material3.Typography( + displayLarge = base.displayLarge.copy(fontFamily = family), + displayMedium = base.displayMedium.copy(fontFamily = family), + displaySmall = base.displaySmall.copy(fontFamily = family), + headlineLarge = base.headlineLarge.copy(fontFamily = family), + headlineMedium = base.headlineMedium.copy(fontFamily = family), + headlineSmall = base.headlineSmall.copy(fontFamily = family), + titleLarge = base.titleLarge.copy(fontFamily = family), + titleMedium = base.titleMedium.copy(fontFamily = family), + titleSmall = base.titleSmall.copy(fontFamily = family), + bodyLarge = base.bodyLarge.copy(fontFamily = family), + bodyMedium = base.bodyMedium.copy(fontFamily = family), + bodySmall = base.bodySmall.copy(fontFamily = family), + labelLarge = base.labelLarge.copy(fontFamily = family), + labelMedium = base.labelMedium.copy(fontFamily = family), + labelSmall = base.labelSmall.copy(fontFamily = family), + ) +} + +// ------- Main App ------- + +@Composable +@Preview +fun App() { + // Global advanced dark theme with custom typography + val neonPrimary = Color(0xFF00E5A8) + val bg = Color(0xFF0B1015) + val onBg = Color(0xFFE6F1FF) + val darkScheme = androidx.compose.material3.darkColorScheme( + primary = neonPrimary, + onPrimary = Color(0xFF00110A), + background = bg, + onBackground = onBg, + surface = Color(0xFF111823), + onSurface = onBg, + surfaceVariant = Color(0xFF172232), + onSurfaceVariant = Color(0xFFB7C4D6), + outline = Color(0xFF334155), + ) + val baseTypography = androidx.compose.material3.Typography() + val techTypography = buildTechTypography(baseTypography, FontFamily.SansSerif) + + MaterialTheme(colorScheme = darkScheme, typography = techTypography) { + val scope = rememberCoroutineScope() + var isRunning by remember { mutableStateOf(false) } + val logs = remember { mutableStateListOf() } + var remainingMs by remember { mutableStateOf(0L) } + var showProperties by remember { mutableStateOf(false) } + + // Editable property state (as text for easy input/validation) + var initialDelayText by remember { mutableStateOf("500") } + var maxDelayText by remember { mutableStateOf("5000") } + var multiplierText by remember { mutableStateOf("1.8") } + var jitterText by remember { mutableStateOf("0.15") } + var maxAttemptsText by remember { mutableStateOf("12") } + var overallTimeoutText by remember { mutableStateOf("30000") } + var perAttemptTimeoutText by remember { mutableStateOf("") } // empty = null + + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top + ) { + Spacer(Modifier.height(40.dp)) + // Heading + Text( + text = "Polling Terminal", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(12.dp)) + + // Start button + countdown + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + GlowingButton( + enabled = !isRunning, + text = if (isRunning) "Running…" else "Start Polling", + onClick = { + if (isRunning) return@GlowingButton + fun appendLog(msg: String) { + scope.launch { logs.add(msg) } + } + logs.clear() + + // Parse and validate inputs + val initialDelay = initialDelayText.toLongOrNull() + val maxDelay = maxDelayText.toLongOrNull() + val multiplier = multiplierText.toDoubleOrNull() + val jitter = jitterText.toDoubleOrNull() + val maxAttempts = maxAttemptsText.toIntOrNull() + val overallTimeout = overallTimeoutText.toLongOrNull() + val perAttemptTimeout = + perAttemptTimeoutText.trim().ifEmpty { null }?.toLongOrNull() + + if (initialDelay == null || maxDelay == null || multiplier == null || jitter == null || maxAttempts == null || overallTimeout == null || (perAttemptTimeoutText.isNotEmpty() && perAttemptTimeout == null)) { + appendLog("[error] Invalid properties. Please enter valid numbers.") + return@GlowingButton + } + + val backoff = try { + BackoffPolicy( + initialDelayMs = initialDelay, + maxDelayMs = maxDelay, + multiplier = multiplier, + jitterRatio = jitter, + maxAttempts = maxAttempts, + overallTimeoutMs = overallTimeout, + perAttemptTimeoutMs = perAttemptTimeout, + ) + } catch (t: Throwable) { + appendLog("[error] ${t.message}") + return@GlowingButton + } + + isRunning = true + remainingMs = backoff.overallTimeoutMs + + // Sample finish logic: succeed on the 8th attempt (to show exponential logs) + var attemptCounter = 0 + + val config = PollingConfig( + fetch = { + attemptCounter++ + if (attemptCounter < 8) { + PollingResult.Waiting + } else { + PollingResult.Success("Ready at attempt #$attemptCounter") + } + }, + isTerminalSuccess = { value -> value.isNotEmpty() }, + backoff = backoff, + onAttempt = { attempt, delayMs -> + appendLog("[info] Attempt #$attempt (after ${delayMs ?: 0} ms)") + }, + onResult = { attempt, result -> + appendLog("[info] Result at #$attempt: ${describeResult(result)}") + }, + onComplete = { attempts, durationMs, outcome -> + appendLog( + "[done] Completed after $attempts attempts in $durationMs ms: ${ + describeOutcome( + outcome + ) + }" + ) + } + ) + + // Start countdown ticker + scope.launch { + while (isRunning && remainingMs > 0) { + kotlinx.coroutines.delay(100) + remainingMs = (remainingMs - 100).coerceAtLeast(0) + } + } + + scope.launch { + val outcome = PollingEngine.pollUntil(config) + appendLog("[done] Final Outcome: ${describeOutcome(outcome)}") + isRunning = false + remainingMs = 0 + } + } + ) + Spacer(Modifier.weight(1f)) + val secs = (remainingMs / 100L).toFloat() / 10f + val secsStr = ((kotlin.math.round(secs * 10f)) / 10f).toString() + Text( + text = if (isRunning) "${secsStr}s left" else "", + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Medium + ) + } + + Spacer(Modifier.height(16.dp)) + + // Editable Properties panel (not in logs) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { showProperties = !showProperties }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + (if (showProperties) "▼ " else "▶ ") + "Properties", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground + ) + } + if (showProperties) { + HorizontalDivider( + Modifier.padding(vertical = 6.dp), + DividerDefaults.Thickness, MaterialTheme.colorScheme.outline + ) + PropertiesCard( + initialDelayText = initialDelayText, + onInitialChange = { initialDelayText = it }, + maxDelayText = maxDelayText, + onMaxDelayChange = { maxDelayText = it }, + multiplierText = multiplierText, + onMultiplierChange = { multiplierText = it }, + jitterText = jitterText, + onJitterChange = { jitterText = it }, + maxAttemptsText = maxAttemptsText, + onMaxAttemptsChange = { maxAttemptsText = it }, + overallTimeoutText = overallTimeoutText, + onOverallTimeoutChange = { overallTimeoutText = it }, + perAttemptTimeoutText = perAttemptTimeoutText, + onPerAttemptTimeoutChange = { perAttemptTimeoutText = it } + ) + } + + Spacer(Modifier.height(16.dp)) + + // Terminal Log view + TerminalLog(modifier = Modifier.weight(1f), logs = logs) } } } diff --git a/composeApp/src/iosMain/kotlin/in/androidplay/pollingengine/MainViewController.kt b/composeApp/src/iosMain/kotlin/in/androidplay/pollingengine/MainViewController.kt new file mode 100644 index 0000000..4e7bc19 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/in/androidplay/pollingengine/MainViewController.kt @@ -0,0 +1,8 @@ +package `in`.androidplay.pollingengine + +import androidx.compose.ui.window.ComposeUIViewController +import platform.UIKit.UIViewController + +// Exposed to Swift as `MainViewControllerKt.MainViewController()` +@Suppress("FunctionName") +fun MainViewController(): UIViewController = ComposeUIViewController { App() } diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/BackoffPolicies.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/BackoffPolicies.kt index 8ec51c9..a78e70e 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/BackoffPolicies.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/BackoffPolicies.kt @@ -13,8 +13,8 @@ public object BackoffPolicies { maxDelayMs = 5_000, multiplier = 1.8, jitterRatio = 0.2, - maxAttempts = 20, // also bounded by overallTimeoutMs + maxAttempts = 20, overallTimeoutMs = 20_000, - perAttemptTimeoutMs = null, + perAttemptTimeoutMs = 10000, ) } From eb115ac2f1b0e79c46c3eb58f0c2be196efd3d3a Mon Sep 17 00:00:00 2001 From: Ankush Bose Date: Fri, 5 Sep 2025 15:50:20 +0530 Subject: [PATCH 2/4] feat: Improve sample app UI and logging precision This commit enhances the sample Compose Multiplatform application and the core `PollingEngine` logging. **Key Changes:** * **Sample App UI (`App.kt`):** * The `PropertiesCard` now uses `Surface` for better elevation and visual separation, with a rounded corner shape and border. * Improved layout within `PropertiesCard` using `Arrangement.spacedBy` for more consistent spacing. * Added category labels ("Delays & Attempts", "Backoff & Timeouts") within `PropertiesCard` for better organization of input fields. * Elapsed time in outcome descriptions (`describeOutcome`) is now displayed in seconds (e.g., "elapsedSec=1.2") instead of milliseconds for better readability. * Log messages for `onComplete` now also display duration in seconds. * The `onAttempt` log message now includes both the calculated base delay (before jitter) and the actual delay (after jitter and overall timeout constraints) for the upcoming attempt, both in seconds. This provides clearer insight into how backoff and jitter affect timing. * **PollingEngine (`PollingEngine.kt`):** * Refined the timing of `onAttempt` and `metrics.recordAttempt` calls. These callbacks are now invoked just before the `delay()` call for subsequent attempts (attempt > 1), providing the *actual* sleep duration. For the first attempt, they are called immediately with a delay of 0. * Removed `suspend` keyword from `cancel(id: String)` and `cancel(handle: Handle)` as they were simple map lookups and job cancellations which are internally suspendable if needed but the functions themselves don't require suspension. * **Removed Unused Files:** * Deleted `PollingSamples.kt` as its functionality is now incorporated and expanded within the interactive `App.kt`. * Deleted `Greeting.kt` as it was a placeholder. * Deleted `Platform.kt` and its Android/iOS implementations (`Platform.android.kt`, `Platform.ios.kt`) as they were not being used in the sample app's current context. **Functionality Improvements:** * The sample app's "Properties" section is visually cleaner and better organized. * Log output in the terminal provides more precise and understandable timing information for polling attempts, including the distinction between calculated base delay and the actual delay experienced. * The `PollingEngine`'s `onAttempt` callback now more accurately reflects the delay *about to be applied* for upcoming attempts, rather than a pre-calculated one. --- .../pollingengine/Platform.android.kt | 9 -- .../in/androidplay/pollingengine/App.kt | 90 ++++++++++++++----- .../in/androidplay/pollingengine/Greeting.kt | 9 -- .../in/androidplay/pollingengine/Platform.kt | 7 -- .../pollingengine/sample/PollingSamples.kt | 56 ------------ .../androidplay/pollingengine/Platform.ios.kt | 9 -- .../pollingengine/polling/PollingEngine.kt | 30 +++++-- 7 files changed, 93 insertions(+), 117 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/sample/PollingSamples.kt diff --git a/composeApp/src/androidMain/kotlin/in/androidplay/pollingengine/Platform.android.kt b/composeApp/src/androidMain/kotlin/in/androidplay/pollingengine/Platform.android.kt index a4d793a..e69de29 100644 --- a/composeApp/src/androidMain/kotlin/in/androidplay/pollingengine/Platform.android.kt +++ b/composeApp/src/androidMain/kotlin/in/androidplay/pollingengine/Platform.android.kt @@ -1,9 +0,0 @@ -package `in`.androidplay.pollingengine - -import android.os.Build - -class AndroidPlatform : Platform { - override val name: String = "Android ${Build.VERSION.SDK_INT}" -} - -actual fun getPlatform(): Platform = AndroidPlatform() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt index 8b59e99..61c4bfe 100644 --- a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt +++ b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt @@ -49,6 +49,7 @@ import `in`.androidplay.pollingengine.polling.PollingEngine import `in`.androidplay.pollingengine.polling.PollingOutcome import kotlinx.coroutines.launch import org.jetbrains.compose.ui.tooling.preview.Preview +import kotlin.math.pow // ------- Models and helpers (moved above App to avoid any potential local-declaration parsing issues) ------- @@ -130,29 +131,36 @@ private fun PropertiesCard( overallTimeoutText: String, onOverallTimeoutChange: (String) -> Unit, perAttemptTimeoutText: String, onPerAttemptTimeoutChange: (String) -> Unit, ) { - Box( + Surface( + tonalElevation = 3.dp, + shadowElevation = 2.dp, + shape = androidx.compose.foundation.shape.RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.92f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.7f)), modifier = Modifier .fillMaxWidth() - .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)) - .border( - 1.dp, - MaterialTheme.colorScheme.outline, - androidx.compose.foundation.shape.RoundedCornerShape(12.dp) - ) - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(12.dp) + .padding(bottom = 2.dp) ) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(24.dp) ) { Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(14.dp) ) { + Text( + "Delays & Attempts", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 2.dp) + ) LabeledField("initialDelayMs", initialDelayText, onInitialChange) - LabeledField("multiplier", multiplierText, onMultiplierChange) LabeledField("maxAttempts", maxAttemptsText, onMaxAttemptsChange) LabeledField( "perAttemptTimeoutMs", @@ -162,9 +170,17 @@ private fun PropertiesCard( } Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(14.dp) ) { + Text( + "Backoff & Timeouts", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 2.dp) + ) LabeledField("maxDelayMs", maxDelayText, onMaxDelayChange) + LabeledField("multiplier", multiplierText, onMultiplierChange) LabeledField("jitterRatio", jitterText, onJitterChange) LabeledField("overallTimeoutMs", overallTimeoutText, onOverallTimeoutChange) } @@ -194,10 +210,29 @@ private fun describeResult(result: PollingResult): String = when (result) } private fun describeOutcome(outcome: PollingOutcome): String = when (outcome) { - is PollingOutcome.Success -> "Success(value=${outcome.value}, attempts=${outcome.attempts}, elapsedMs=${outcome.elapsedMs})" - is PollingOutcome.Exhausted -> "Exhausted(attempts=${outcome.attempts}, elapsedMs=${outcome.elapsedMs})" - is PollingOutcome.Timeout -> "Timeout(attempts=${outcome.attempts}, elapsedMs=${outcome.elapsedMs})" - is PollingOutcome.Cancelled -> "Cancelled(attempts=${outcome.attempts}, elapsedMs=${outcome.elapsedMs})" + is PollingOutcome.Success -> { + val secs = (outcome.elapsedMs / 100L).toFloat() / 10f + "Success(value=${outcome.value}, attempts=${outcome.attempts}, elapsedSec=${ + ((kotlin.math.round( + secs * 10f + )) / 10f) + })" + } + + is PollingOutcome.Exhausted -> { + val secs = (outcome.elapsedMs / 100L).toFloat() / 10f + "Exhausted(attempts=${outcome.attempts}, elapsedSec=${((kotlin.math.round(secs * 10f)) / 10f)})" + } + + is PollingOutcome.Timeout -> { + val secs = (outcome.elapsedMs / 100L).toFloat() / 10f + "Timeout(attempts=${outcome.attempts}, elapsedSec=${((kotlin.math.round(secs * 10f)) / 10f)})" + } + + is PollingOutcome.Cancelled -> { + val secs = (outcome.elapsedMs / 100L).toFloat() / 10f + "Cancelled(attempts=${outcome.attempts}, elapsedSec=${((kotlin.math.round(secs * 10f)) / 10f)})" + } } @Composable @@ -362,14 +397,29 @@ fun App() { isTerminalSuccess = { value -> value.isNotEmpty() }, backoff = backoff, onAttempt = { attempt, delayMs -> - appendLog("[info] Attempt #$attempt (after ${delayMs ?: 0} ms)") + // Calculate base (non-jittered) delay for this attempt + val baseDelay = (backoff.initialDelayMs * + backoff.multiplier.pow((attempt - 1).toDouble()) + ).toLong().coerceAtMost(backoff.maxDelayMs) + val baseSecs = ((baseDelay) / 100L).toFloat() / 10f + val baseSecsStr = + ((kotlin.math.round(baseSecs * 10f)) / 10f).toString() + + val actualDelay = delayMs ?: 0L + val actualSecs = (actualDelay / 100L).toFloat() / 10f + val actualSecsStr = + ((kotlin.math.round(actualSecs * 10f)) / 10f).toString() + + appendLog("[info] Attempt #$attempt (base: ${baseSecsStr}s, actual: ${actualSecsStr}s)") }, onResult = { attempt, result -> appendLog("[info] Result at #$attempt: ${describeResult(result)}") }, onComplete = { attempts, durationMs, outcome -> + val secs = (durationMs / 100L).toFloat() / 10f + val secsStr = ((kotlin.math.round(secs * 10f)) / 10f).toString() appendLog( - "[done] Completed after $attempts attempts in $durationMs ms: ${ + "[done] Completed after $attempts attempts in ${secsStr}s: ${ describeOutcome( outcome ) diff --git a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Greeting.kt b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Greeting.kt index c0695c5..e69de29 100644 --- a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Greeting.kt +++ b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Greeting.kt @@ -1,9 +0,0 @@ -package `in`.androidplay.pollingengine - -class Greeting { - private val platform = getPlatform() - - fun greet(): String { - return "Hello, ${platform.name}!" - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Platform.kt b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Platform.kt index 15d009f..e69de29 100644 --- a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Platform.kt +++ b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/Platform.kt @@ -1,7 +0,0 @@ -package `in`.androidplay.pollingengine - -interface Platform { - val name: String -} - -expect fun getPlatform(): Platform \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/sample/PollingSamples.kt b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/sample/PollingSamples.kt deleted file mode 100644 index c5f6bfc..0000000 --- a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/sample/PollingSamples.kt +++ /dev/null @@ -1,56 +0,0 @@ -package `in`.androidplay.pollingengine.sample - -import `in`.androidplay.pollingengine.models.PollingResult -import `in`.androidplay.pollingengine.polling.BackoffPolicy -import `in`.androidplay.pollingengine.polling.PollingConfig -import `in`.androidplay.pollingengine.polling.PollingEngine -import `in`.androidplay.pollingengine.polling.PollingOutcome -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -/** - * Simple sample APIs showing how to use the polling engine from Android/iOS. - * - demoPoll(): suspend function, easy to call from Kotlin/Swift (async). - * - demoPoll(callback): callback-style for legacy Objective-C callers. - */ -object PollingSamples { - - suspend fun demoPoll(): String { - var attempts = 0 - val config = PollingConfig( - fetch = { - attempts++ - when (attempts) { - in 1..3 -> PollingResult.Waiting // Simulate not-ready state initially - 4 -> PollingResult.Success("Server ready on attempt #$attempts") - else -> PollingResult.Success("Already ready: attempt #$attempts") - } - }, - isTerminalSuccess = { value -> value.isNotEmpty() }, - backoff = BackoffPolicy( - initialDelayMs = 200, - maxDelayMs = 1_000, - multiplier = 1.6, - jitterRatio = 0.2, - maxAttempts = 10, - overallTimeoutMs = 10_000, - ), - ) - - return when (val outcome = PollingEngine.pollUntil(config)) { - is PollingOutcome.Success -> outcome.value - is PollingOutcome.Exhausted -> "Exhausted after ${outcome.attempts} attempts" - is PollingOutcome.Timeout -> "Timed out after ${outcome.elapsedMs} ms" - is PollingOutcome.Cancelled -> "Cancelled after ${outcome.attempts} attempts" - } - } - - // Callback-based wrapper for Objective-C callers. - fun demoPoll(callback: (String) -> Unit) { - GlobalScope.launch(Dispatchers.Default) { - val result = demoPoll() - callback(result) - } - } -} diff --git a/composeApp/src/iosMain/kotlin/in/androidplay/pollingengine/Platform.ios.kt b/composeApp/src/iosMain/kotlin/in/androidplay/pollingengine/Platform.ios.kt index de1cec6..e69de29 100644 --- a/composeApp/src/iosMain/kotlin/in/androidplay/pollingengine/Platform.ios.kt +++ b/composeApp/src/iosMain/kotlin/in/androidplay/pollingengine/Platform.ios.kt @@ -1,9 +0,0 @@ -package `in`.androidplay.pollingengine - -import platform.UIKit.UIDevice - -class IOSPlatform: Platform { - override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion -} - -actual fun getPlatform(): Platform = IOSPlatform() \ No newline at end of file diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt index c3c3f7e..550ff0f 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt @@ -6,7 +6,6 @@ import `in`.androidplay.pollingengine.models.PollingResult.Failure import `in`.androidplay.pollingengine.models.PollingResult.Success import `in`.androidplay.pollingengine.models.PollingResult.Unknown import `in`.androidplay.pollingengine.models.PollingResult.Waiting -import kotlin.time.TimeSource import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -20,6 +19,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlin.time.TimeSource /** * Production-ready polling engine with exponential backoff and jitter. @@ -40,11 +40,13 @@ public object PollingEngine { public suspend fun listActiveIds(): List = mutex.withLock { active.keys.toList() } - public suspend fun cancel(id: String): Unit { + public suspend fun cancel(id: String) { mutex.withLock { active[id]?.cancel(CancellationException("Cancelled by user")) } } - public suspend fun cancel(handle: Handle): Unit { cancel(handle.id) } + public suspend fun cancel(handle: Handle) { + cancel(handle.id) + } public fun startPolling( config: PollingConfig, @@ -91,13 +93,19 @@ public object PollingEngine { val timeoutMs = config.backoff.perAttemptTimeoutMs if (timeoutMs != null) { withTimeout(minOf(timeoutMs, remainingOverall)) { - config.metrics?.recordAttempt(attempt, if (attempt == 1) 0 else nextDelay) - config.onAttempt(attempt, if (attempt == 1) 0 else nextDelay) + // Preface only the first attempt immediately + if (attempt == 1) { + config.metrics?.recordAttempt(attempt, 0) + config.onAttempt(attempt, 0) + } config.fetch() } } else { - config.metrics?.recordAttempt(attempt, if (attempt == 1) 0 else nextDelay) - config.onAttempt(attempt, if (attempt == 1) 0 else nextDelay) + // Preface only the first attempt immediately + if (attempt == 1) { + config.metrics?.recordAttempt(attempt, 0) + config.onAttempt(attempt, 0) + } config.fetch() } } catch (ce: CancellationException) { @@ -164,6 +172,14 @@ public object PollingEngine { val remainingBeforeSleep = config.backoff.overallTimeoutMs - elapsedBeforeSleep if (remainingBeforeSleep <= 0) break + // Provide the actual computed delay for the NEXT attempt's preface (attempt+1) + // Only announce if there is time left to sleep and another attempt could happen. + val nextAttemptIndex = attempt + 1 + if (nextAttemptIndex <= config.backoff.maxAttempts) { + config.metrics?.recordAttempt(nextAttemptIndex, sleepMs) + config.onAttempt(nextAttemptIndex, sleepMs) + } + delay(minOf(sleepMs, remainingBeforeSleep)) } From 18ada3ed63de042aa1a0b2e84d1ca8a9015533d2 Mon Sep 17 00:00:00 2001 From: Ankush Bose Date: Fri, 5 Sep 2025 16:01:21 +0530 Subject: [PATCH 3/4] feat: Add GitHub Actions workflow for publishing SDK This commit introduces a new GitHub Actions workflow (`.github/workflows/publish.yml`) to automate the publishing of the SDK. **Workflow Details:** * **Trigger:** The workflow is triggered on pushes to the `main` branch. * **Runner:** It runs on `macos-latest`. * **Steps:** 1. **Checkout:** Checks out the repository code using `actions/checkout@v4`. 2. **Set up JDK:** Sets up Java Development Kit (JDK) version 17 (Temurin distribution) using `actions/setup-java@v4`. 3. **Set up Gradle:** Configures Gradle using `gradle/gradle-build-action@v3`. 4. **Publish Android SDK:** Executes `./gradlew :pollingengine:publish` to publish the Android library to Maven. 5. **Publish iOS SDK:** Executes `./gradlew :pollingengine:publishToMavenLocal` as a placeholder for publishing the iOS SDK (intended for CocoaPods). **Functionality:** * Automates the build and deployment process for the Android SDK. * Includes a step for iOS SDK publication, currently publishing to Maven Local, which can be adapted for CocoaPods or other distribution methods. --- .github/workflows/publish.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..1941810 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,28 @@ +name: Publish SDK + +on: + push: + branches: + - main + +jobs: + publish: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Set up Gradle + uses: gradle/gradle-build-action@v3 + + - name: Publish Android SDK to Maven + run: ./gradlew :pollingengine:publish + + - name: Publish iOS SDK to CocoaPods + run: ./gradlew :pollingengine:publishToMavenLocal # or your iOS publishing command + From e7427a49bda94418c9ba46c7290175ebe7906601 Mon Sep 17 00:00:00 2001 From: Ankush Bose Date: Sat, 6 Sep 2025 13:08:28 +0530 Subject: [PATCH 4/4] feat: Enhance sample app with pause/resume and runtime backoff updates This commit introduces several enhancements to the sample application and the `PollingEngine` core: **Sample App (`App.kt`):** * **Pause/Resume Functionality:** * The "Start Polling" button now dynamically changes to "Pause" or "Resume" based on the polling state. * Users can pause an ongoing polling process and resume it later. * The countdown timer also reflects the paused state. * **Stop Button:** * Added a "Stop" button to explicitly cancel an ongoing polling operation. * **Runtime Backoff Policy Update:** * Added an "Apply Backoff" button in the properties section. * Users can modify `BackoffPolicy` parameters (initial delay, max delay, etc.) while a poll is running and apply them dynamically. * The `PollingEngine` will use the new policy for subsequent retry calculations. * **Minor UI Improvements:** * Log entries in `TerminalLog` now use `key` for better performance with `AnimatedVisibility` when items are added. * `AnimatedVisibility` is used for log entries for a smoother appearance. * The `PollingConfig` is now created using the `pollingConfig { ... }` DSL builder. **PollingEngine (`PollingEngine.kt`):** * **Pause/Resume API:** * `pause(id: String)`: Pauses the polling operation identified by `id`. * `resume(id: String)`: Resumes a paused polling operation. * Internal `Control` data class manages the state (Running/Paused) and dynamic backoff policy for each poll. * **Runtime Backoff Update API:** * `updateBackoff(id: String, newPolicy: BackoffPolicy)`: Allows changing the `BackoffPolicy` for an active poll at runtime. The engine will use the new policy for subsequent delay calculations and max attempts checks. * **Lifecycle Management:** * `cancelAll()`: Cancels all currently active polling operations. * `shutdown()`: Cancels all polls, shuts down the internal CoroutineScope, and prevents new polls from starting. This is useful for cleaning up resources when the engine is no longer needed. * **Sequential Polling (`compose`):** * `compose(vararg configs: PollingConfig)`: A new utility function to execute multiple `PollingConfig` instances sequentially. It stops early if any poll results in a non-success outcome and returns the outcome of the last executed poll (or the first non-success). * **Internal State Management:** * Introduced `PollingEngine.State` enum (`Running`, `Paused`). * The engine now uses an internal `controls` map to manage the state and dynamic backoff for each active poll. * Polling loop now checks and respects the `Paused` state, suspending execution until resumed. * Polling loop now dynamically reads the `BackoffPolicy` from the `Control` object for each iteration, allowing runtime updates. **Build & Configuration:** * **Publishing Script (`publishing.gradle.kts`):** * A new `publishing.gradle.kts` script is added to the `pollingengine` module to centralize Maven publishing and signing configuration, decluttering the main `build.gradle.kts`. * Signing is now more robustly detected and enabled based on Gradle properties or environment variables (`signing.enabled`, `SIGNING_ENABLED`). * Supports both in-memory PGP key and secret key ring file for signing. * POM metadata is configured dynamically via the Vanniktech Maven Publish plugin. * **Gradle Version Catalog (`libs.versions.toml`):** * Added `pollingengine` library version. * Added `kotlinx-coroutines` version. * Added plugins for `vanniktechMavenPublish`, `dokka`, `detekt`, `ktlint`, and `binaryCompatibilityValidator`. * **`pollingengine/build.gradle.kts`:** * Updated `group` to `io.github.bosankus`. * Version is now sourced from `libs.versions.pollingengine`. * CocoaPods metadata (`summary`, `homepage`, `license`, `authors`) is now read from project properties. * Applied `publishing.gradle.kts`. * Removed direct `maven-publish` and `signing` plugin applications and configurations as they are handled by the Vanniktech plugin and the new publishing script. * Android library variants for publishing are handled by the Vanniktech plugin. * **`composeApp/build.gradle.kts`:** * Enabled `isMinifyEnabled = true` for release builds. * Uses library `kotlinx.coroutines.core` from the version catalog. * Dependency on `:pollingengine` is added directly in the `dependencies` block (previously also in `commonMain.dependencies`). * **Root `build.gradle.kts`:** * Removed `io.github.gradle-nexus.publish-plugin` and its configuration. * Uses `binaryCompatibilityValidator` plugin alias from the version catalog. * **Docs CI Setup (`docs/ci-setup.md`):** * Marked the document as [ARCHIVED] as CI/publishing workflows were removed. * Updated references from Sonatype/OSSRH to Maven Central Portal credentials. * **Podspec (`pollingengine.podspec`):** * Updated author email. * **Settings (`settings.gradle.kts`):** * Added `mavenCentral()` to `pluginManagement.repositories`. * Added `mavenLocal()` to `dependencyResolutionManagement.repositories`. * Included `:docs` as a subproject. * **New Docs Module (`docs/build.gradle.kts`):** * Added a minimal Gradle build file for the `docs/` directory to allow Gradle commands from there without errors, and includes a helper `docsInfo` task. * **Removed Task List (`docs/tasks.md`):** * Deleted the tasks checklist file. **PollingConfig Enhancements:** * `PollingConfigBuilder.kt`: Added methods for `throwableMapper`, `logger`, and `metrics`. * `pollingConfig { ... }` DSL: A new top-level function to easily create `PollingConfig` instances using a builder lambda. * `PollingConfig.kt`: The `onComplete` callback now correctly type its `outcome` parameter as `PollingOutcome` instead of `PollingOutcome`. --- README.md | 401 +++++++++++------- build.gradle.kts | 15 +- composeApp/build.gradle.kts | 7 +- .../in/androidplay/pollingengine/App.kt | 282 +++++++----- docs/build.gradle.kts | 14 + docs/ci-setup.md | 47 +- docs/tasks.md | 174 -------- gradle.properties | 15 +- gradle/libs.versions.toml | 14 +- pollingengine/build.gradle.kts | 58 +-- pollingengine/pollingengine.podspec | 2 +- pollingengine/publishing.gradle.kts | 80 ++++ .../pollingengine/polling/PollingConfig.kt | 2 +- .../polling/PollingConfigBuilder.kt | 31 +- .../pollingengine/polling/PollingEngine.kt | 120 +++++- settings.gradle.kts | 5 +- 16 files changed, 725 insertions(+), 542 deletions(-) create mode 100644 docs/build.gradle.kts delete mode 100644 docs/tasks.md create mode 100644 pollingengine/publishing.gradle.kts diff --git a/README.md b/README.md index 454d413..ca63118 100644 --- a/README.md +++ b/README.md @@ -1,170 +1,257 @@ -This is a Kotlin Multiplatform project targeting Android, iOS. - -* [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications. - It contains several subfolders: - - [commonMain](./composeApp/src/commonMain/kotlin) is for code that’s common for all targets. - - Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. - For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app, - the [iosMain](./composeApp/src/iosMain/kotlin) folder would be the right place for such calls. - Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./composeApp/src/jvmMain/kotlin) - folder is the appropriate location. - -* [/iosApp](./iosApp/iosApp) contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform, - you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project. - -### Build and Run Android Application - -To build and run the development version of the Android app, use the run configuration from the run widget -in your IDE’s toolbar or build it directly from the terminal: -- on macOS/Linux - ```shell - ./gradlew :composeApp:assembleDebug - ``` -- on Windows - ```shell - .\gradlew.bat :composeApp:assembleDebug - ``` - -### Build and Run iOS Application - -To build and run the development version of the iOS app, use the run configuration from the run widget -in your IDE’s toolbar or open the [/iosApp](./iosApp) directory in Xcode and run it from there. - -### iOS Simulator/CoreSimulator Troubleshooting - -If you see errors like: - -- "CoreSimulator.framework was changed while the process was running. Service version (1010.15) does not match expected service version (947.17)." -- "Connection refused" - -This indicates your active Xcode's CoreSimulator service doesn't match the running Simulator/runtime. This can happen if Xcode auto-updated in the background or you have multiple Xcode versions installed. - -Quick fix (recommended): - -- Run the helper script which resets CoreSimulator and clears caches. - ```bash - chmod +x scripts/fix-ios-simulator.sh - # optionally pass an Xcode path to switch the active Xcode - ./scripts/fix-ios-simulator.sh # just reset - # sudo ./scripts/fix-ios-simulator.sh /Applications/Xcode.app - ``` -- Reopen Xcode and Simulator, then re-run the app. - -Manual steps (if you prefer to do it yourself): - -1) Ensure the right Xcode is active: - ```bash - xcode-select -p - sudo xcode-select -s /Applications/Xcode.app - ``` -2) Close Simulator and kill CoreSimulator services: - ```bash - killall -9 Simulator || true - killall -9 com.apple.CoreSimulator.CoreSimulatorService || true - launchctl remove com.apple.CoreSimulator.CoreSimulatorService || true - ``` -3) Shutdown and erase simulators (warning: removes simulator data): - ```bash - xcrun simctl shutdown all || true - xcrun simctl erase all || true - ``` -4) Clear Xcode DerivedData: - ```bash - rm -rf ~/Library/Developer/Xcode/DerivedData - ``` -5) Reopen Xcode/Simulator and try again. - -Notes: -- Ensure your Simulator runtime version matches your active Xcode (xcrun simctl list runtimes). -- Our iOS deployment target is currently set to 18.2 in the Xcode project; use an iOS 18.x simulator (e.g., 18.6). -- If the issue recurs, run the script again after Xcode updates. - ---- - -### Observability and hooks (PollingEngine) - -PollingEngine provides optional observability without forcing any logging dependency: - -- onAttempt(attempt, delayMs): called just before each fetch attempt is executed. delayMs is 0 on the first attempt; subsequent attempts include the planned sleep before the attempt. -- onResult(attempt, result): called after each attempt with the PollingResult produced by fetch. -- onComplete(attempts, durationMs, outcome): called once when polling reaches a terminal outcome (Success, Exhausted, Timeout, or Cancelled). - -Threading and timing: -- All hooks run on the dispatcher configured in PollingConfig (default: Dispatchers.Default). -- onAttempt and Metrics.recordAttempt are invoked immediately before calling fetch (or inside withTimeout if perAttemptTimeoutMs is set). -- onResult is invoked immediately after fetch completes or fails and has been mapped to a PollingResult. -- onComplete is invoked right after the terminal outcome is determined. - -Optional interfaces you can implement: -- Logger: a simple logging callback interface you can pass via PollingConfig.logger (no logging library required). -- Metrics: callbacks to record attempts, results, and completion; used by PollingEngine if provided. - -Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)… - ---- - -## Installation - -Currently, the library is consumed as a project dependency inside this repo. Once published to Maven Central, use: - -- Gradle (Kotlin DSL): - ```kotlin - repositories { mavenCentral() } - dependencies { implementation("in.androidplay:pollingengine:0.1.0") } - ``` -- Maven: - ```xml - - in.androidplay +# PollingEngine + +[![Maven Central](https://img.shields.io/maven-central/v/io.github.bosankus/pollingengine.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/io.github.bosankus/pollingengine) +![Kotlin](https://img.shields.io/badge/Kotlin-2.2.10-blue?logo=kotlin) +[![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-green.svg)](#license) +[![CI](https://img.shields.io/badge/CI-GitHub%20Actions-inactive.svg)](#setupbuild-instructions) + +A Kotlin Multiplatform library for Android and iOS that provides a production‑ready polling engine +with: + +- Exponential backoff and jitter +- Timeouts (overall and per‑attempt) +- Cancellation and control APIs +- Observability hooks (attempt/result/complete) +- Pluggable logging and metrics + +Mermaid flow diagram (GitHub renders this): + +```mermaid +flowchart TD + A[Start] --> B{Attempt fetch} + B -->|Success & meets success predicate| C[Outcome: Success] + B -->|Failure & retryable| D[Backoff delay] + B -->|Failure & not retryable| E[Outcome: Exhausted] + B -->|Timeout reached| F[Outcome: Timeout] + D --> G{More attempts left?} + G -->|Yes| B + G -->|No| E + %% External control + B -. pause/resume/cancel .-> H[Control APIs] +``` + +- Modules: + - [/pollingengine](./pollingengine) — library code + - [/composeApp](./composeApp/src) — sample shared UI (Compose Multiplatform) + - [/iosApp](./iosApp/iosApp) — iOS app entry (SwiftUI) + +## Project Overview + +PollingEngine helps you repeatedly call a function until a condition is met or limits are reached. +It is designed for long‑polling workflows like waiting for a server job to complete, checking +payment status, etc. + +Platforms: Kotlin Multiplatform (common code) with Android and iOS targets. + +Highlights: + +- Simple DSL with pollingConfig { … } +- Backoff presets (e.g., BackoffPolicies.quick20s) +- Control operations: pause(id), resume(id), cancel(handle/id), cancelAll(), shutdown() +- Domain‑level results via PollingResult and terminal PollingOutcome + +## Installation and Dependency + +Coordinates on Maven Central: + +- groupId: io.github.bosankus +- artifactId: pollingengine +- version: 0.1.0 + +Gradle Kotlin DSL (Android/shared): + +```kotlin +repositories { mavenCentral() } +dependencies { implementation("io.github.bosankus:pollingengine:0.1.0") } +``` + +Gradle Groovy DSL: + +```groovy +repositories { mavenCentral() } +dependencies { implementation "io.github.bosankus:pollingengine:0.1.0" } +``` + +Maven: + +```xml + + + io.github.bosankus pollingengine 0.1.0 - - ``` + +``` + +iOS integration options: -iOS consumption options (to be published): -- CocoaPods (planned): - ```ruby - pod 'PollingEngine', '~> 0.1' - ``` -- Swift Package Manager (binary XCFramework, planned): add the Git tag `vX.Y.Z` and use Package.swift provided in release notes. +- CocoaPods (from this repository during development): -## Quick start (Kotlin) +```ruby +# Podfile (example) +platform :ios, '14.0' +use_frameworks! +target 'YourApp' do + pod 'pollingengine', :path => '../pollingengine' +end ``` -import in.androidplay.pollingengine.models.PollingResult -import in.androidplay.pollingengine.polling.BackoffPolicies -import in.androidplay.pollingengine.polling.PollingConfigBuilder -import in.androidplay.pollingengine.polling.PollingEngine - -// Your fetcher should return PollingResult -suspend fun fetchStatus(): PollingResult = TODO() - -val config = PollingConfigBuilder() - .fetch { fetchStatus() } - .success { value -> value == "READY" } - .retry { error -> true } // customize as needed - .backoff(BackoffPolicies.quick20s) - .onAttempt { attempt, delay -> println("Attempt #$attempt (delay=$delay ms)") } - .onResult { attempt, result -> println("Result@$attempt = $result") } - .onComplete { attempts, duration, outcome -> println("Done in $attempts attempts after ${duration}ms: $outcome") } - .build() - -// Use pollUntil in a coroutine -val outcome = PollingEngine.pollUntil(config) + +Then: + +```bash +./gradlew :pollingengine:generateDummyFramework +cd iosApp && pod install +``` + +- Swift Package Manager: If you publish an XCFramework, add the package URL and version in Xcode. ( + SPM publication is not configured in this repo out‑of‑the‑box.) + +## Usage + +Basic shared usage: + +```kotlin +import `in`.androidplay.pollingengine.models.PollingResult +import `in`.androidplay.pollingengine.polling.* + +val config = pollingConfig { + fetch { /* return PollingResult */ TODO() } + success { it == "READY" } + retry(DefaultRetryPredicates.retryOnNetworkServerTimeout) + backoff(BackoffPolicies.quick20s) +} + +suspend fun run(): PollingOutcome = PollingEngine.pollUntil(config) +``` + +Android example (ViewModel + Compose): + +```kotlin +class StatusViewModel : ViewModel() { + private val _status = MutableStateFlow("Idle") + val status: StateFlow = _status + + private val config = pollingConfig { + fetch { TODO("Return PollingResult") } + success { it == "READY" } + backoff(BackoffPolicies.quick20s) + } + + fun runOnce() = viewModelScope.launch { + _status.value = PollingEngine.pollUntil(config).toString() + } +} ``` -## Semantic Versioning +iOS example (Swift calling Kotlin helper): + +```kotlin +// shared Kotlin +object IosAdapters { + fun provideStatusConfig(): PollingConfig = pollingConfig { + fetch { TODO() } + success { it == "READY" } + backoff(BackoffPolicies.quick20s) + } +} +``` + +```swift +// Swift + +import PollingEngine + +let handle = InAndroidplayPollingengineAdaptersIosAdapters().startStatusPolling { outcome in + print("Outcome: \(outcome)") +} +``` + +API Reference: + +- Generate locally with Dokka: `./gradlew :pollingengine:dokkaHtml` +- Output is in `pollingengine/build/dokka/html/index.html` + +Platform‑specific notes: + +- expect/actual: Core engine lives in commonMain. If you introduce platform APIs, add expect + declarations in common and provide actual implementations in androidMain/iosMain. +- Coroutines: library uses kotlinx.coroutines; ensure proper dispatchers on each platform. + +## Setup/Build Instructions + +Clone and build: + +```bash +git clone https://github.com/bosankus/PollingEngine.git +cd PollingEngine +./gradlew build +``` + +Run tests (all targets where applicable): + +```bash +./gradlew :pollingengine:allTests +``` + +Android app: + +```bash +./gradlew :composeApp:installDebug +``` + +iOS builds (macOS): + +- Open iosApp in Xcode and run on a simulator. +- If CoreSimulator issues arise, run `scripts/fix-ios-simulator.sh` then retry. + +## Publishing & Versioning + +Publishing to Maven Central uses com.vanniktech.maven.publish. + +- Required environment variables/Gradle properties (typically set in CI): + - OSSRH_USERNAME, OSSRH_PASSWORD + - SIGNING_KEY (Base64 GPG private key), SIGNING_PASSWORD + - GROUP: io.github.bosankus (already configured) +- Commands: + +```bash +./gradlew :pollingengine:publishToMavenLocal +./gradlew :pollingengine:publish --no-configuration-cache +``` + +- See docs/ci-setup.md and docs/tasks.md for more details and suggested CI steps. + +Versioning policy: Semantic Versioning (MAJOR.MINOR.PATCH). Public API stability is guarded by +Kotlin Binary Compatibility Validator. + +Release notes: maintain CHANGELOG.md for each version. Tag releases on Git and reference them in +release notes. + +## Contributing + +We welcome contributions! + +- Fork the repo and create a feature branch +- Follow Kotlin style and ktlint; run `./gradlew ktlintCheck detekt` +- Ensure tests pass: `./gradlew build` +- Open a Pull Request describing your changes + +Guidelines and policies: + +- [CONTRIBUTING.md](CONTRIBUTING.md) +- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) +- Binary compatibility: explicit API mode and API checks are enabled; please run + `./gradlew apiCheck` when modifying public APIs. -This project follows Semantic Versioning (MAJOR.MINOR.PATCH): -- MAJOR: incompatible API changes -- MINOR: backwards-compatible functionality -- PATCH: backwards-compatible bug fixes +## License -Public API changes are guarded using Kotlin Binary Compatibility Validator. API checks run in CI when a baseline is present. +Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE). ---- +Copyright (c) 2025 AndroidPlay -## CI and Release Setup +## Maintainers & Support -For step-by-step instructions on configuring CI and publishing the libraries (Maven Central and CocoaPods), see: -- docs/ci-setup.md +- Maintainer: @bosankus +- Issues: use [GitHub Issues](https://github.com/bosankus/PollingEngine/issues) +- Security: see [SECURITY.md](SECURITY.md) diff --git a/build.gradle.kts b/build.gradle.kts index 562b937..f945e64 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,18 +6,5 @@ plugins { alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.kotlinMultiplatform) apply false - id("io.github.gradle-nexus.publish-plugin") version "1.3.0" - id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.14.0" + alias(libs.plugins.binaryCompatibilityValidator) apply false } - -nexusPublishing { - repositories { - sonatype { - // Using the modern s01 host (most new OSSRH accounts) - nexusUrl.set(uri("https://s01.oss.sonatype.org/service/local/")) - snapshotRepositoryUrl.set(uri("https://s01.oss.sonatype.org/content/repositories/snapshots/")) - username.set((findProperty("ossrhUsername") as String?)) - password.set((findProperty("ossrhPassword") as String?)) - } - } -} \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 2f6b5f7..6ab57b0 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -31,6 +31,7 @@ kotlin { } commonMain.dependencies { implementation(project(":pollingengine")) + //implementation(libs.pollingengine) implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) @@ -39,7 +40,7 @@ kotlin { implementation(compose.components.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") + implementation(libs.kotlinx.coroutines.core) } commonTest.dependencies { implementation(libs.kotlin.test) @@ -65,7 +66,7 @@ android { } buildTypes { getByName("release") { - isMinifyEnabled = false + isMinifyEnabled = true } } compileOptions { @@ -75,6 +76,6 @@ android { } dependencies { + implementation(project(":pollingengine")) debugImplementation(compose.uiTooling) } - diff --git a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt index 61c4bfe..cfcebf5 100644 --- a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt +++ b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt @@ -44,9 +44,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import `in`.androidplay.pollingengine.models.PollingResult import `in`.androidplay.pollingengine.polling.BackoffPolicy -import `in`.androidplay.pollingengine.polling.PollingConfig import `in`.androidplay.pollingengine.polling.PollingEngine import `in`.androidplay.pollingengine.polling.PollingOutcome +import `in`.androidplay.pollingengine.polling.pollingConfig import kotlinx.coroutines.launch import org.jetbrains.compose.ui.tooling.preview.Preview import kotlin.math.pow @@ -81,8 +81,10 @@ private fun TerminalLog(modifier: Modifier = Modifier, logs: List) { verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(top = 6.dp, bottom = 24.dp) ) { - items(logs) { line -> - LogEntryCard(line) + items(items = logs, key = { it.hashCode() }) { line -> + androidx.compose.animation.AnimatedVisibility(visible = true) { + LogEntryCard(line = line) + } } } } @@ -91,7 +93,7 @@ private fun TerminalLog(modifier: Modifier = Modifier, logs: List) { // --- Simple log entry card without icons for multiplatform compatibility --- @Composable -private fun LogEntryCard(line: String) { +private fun LogEntryCard(line: String, modifier: Modifier = Modifier) { val (bgColor, textColor) = when { line.startsWith("[error]") -> Color(0x22FF5A5A) to Color(0xFFFFB4B4) line.startsWith("[done]") -> Color(0x2220E5A8) to Color(0xFFB2FFE5) @@ -103,7 +105,7 @@ private fun LogEntryCard(line: String) { shadowElevation = 1.dp, color = bgColor, shape = androidx.compose.foundation.shape.RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth() ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -306,6 +308,8 @@ fun App() { MaterialTheme(colorScheme = darkScheme, typography = techTypography) { val scope = rememberCoroutineScope() var isRunning by remember { mutableStateOf(false) } + var isPaused by remember { mutableStateOf(false) } + var handle by remember { mutableStateOf(null) } val logs = remember { mutableStateListOf() } var remainingMs by remember { mutableStateOf(0L) } var showProperties by remember { mutableStateOf(false) } @@ -340,107 +344,131 @@ fun App() { // Start button + countdown Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { GlowingButton( - enabled = !isRunning, - text = if (isRunning) "Running…" else "Start Polling", + enabled = true, + text = when { + !isRunning -> "Start Polling" + isPaused -> "Resume" + else -> "Pause" + }, onClick = { - if (isRunning) return@GlowingButton fun appendLog(msg: String) { scope.launch { logs.add(msg) } } - logs.clear() - - // Parse and validate inputs - val initialDelay = initialDelayText.toLongOrNull() - val maxDelay = maxDelayText.toLongOrNull() - val multiplier = multiplierText.toDoubleOrNull() - val jitter = jitterText.toDoubleOrNull() - val maxAttempts = maxAttemptsText.toIntOrNull() - val overallTimeout = overallTimeoutText.toLongOrNull() - val perAttemptTimeout = - perAttemptTimeoutText.trim().ifEmpty { null }?.toLongOrNull() - - if (initialDelay == null || maxDelay == null || multiplier == null || jitter == null || maxAttempts == null || overallTimeout == null || (perAttemptTimeoutText.isNotEmpty() && perAttemptTimeout == null)) { - appendLog("[error] Invalid properties. Please enter valid numbers.") - return@GlowingButton - } - - val backoff = try { - BackoffPolicy( - initialDelayMs = initialDelay, - maxDelayMs = maxDelay, - multiplier = multiplier, - jitterRatio = jitter, - maxAttempts = maxAttempts, - overallTimeoutMs = overallTimeout, - perAttemptTimeoutMs = perAttemptTimeout, - ) - } catch (t: Throwable) { - appendLog("[error] ${t.message}") - return@GlowingButton - } - - isRunning = true - remainingMs = backoff.overallTimeoutMs + if (!isRunning) { + logs.clear() + + // Parse and validate inputs + val initialDelay = initialDelayText.toLongOrNull() + val maxDelay = maxDelayText.toLongOrNull() + val multiplier = multiplierText.toDoubleOrNull() + val jitter = jitterText.toDoubleOrNull() + val maxAttempts = maxAttemptsText.toIntOrNull() + val overallTimeout = overallTimeoutText.toLongOrNull() + val perAttemptTimeout = + perAttemptTimeoutText.trim().ifEmpty { null }?.toLongOrNull() + + if (initialDelay == null || maxDelay == null || multiplier == null || jitter == null || maxAttempts == null || overallTimeout == null || (perAttemptTimeoutText.isNotEmpty() && perAttemptTimeout == null)) { + appendLog("[error] Invalid properties. Please enter valid numbers.") + return@GlowingButton + } - // Sample finish logic: succeed on the 8th attempt (to show exponential logs) - var attemptCounter = 0 + val backoff = try { + BackoffPolicy( + initialDelayMs = initialDelay, + maxDelayMs = maxDelay, + multiplier = multiplier, + jitterRatio = jitter, + maxAttempts = maxAttempts, + overallTimeoutMs = overallTimeout, + perAttemptTimeoutMs = perAttemptTimeout, + ) + } catch (t: Throwable) { + appendLog("[error] ${t.message}") + return@GlowingButton + } - val config = PollingConfig( - fetch = { - attemptCounter++ - if (attemptCounter < 8) { - PollingResult.Waiting - } else { - PollingResult.Success("Ready at attempt #$attemptCounter") + isRunning = true + isPaused = false + remainingMs = backoff.overallTimeoutMs + + // Sample finish logic: succeed on the 8th attempt (to show exponential logs) + var attemptCounter = 0 + + val config = pollingConfig { + fetch { + attemptCounter++ + if (attemptCounter < 8) { + PollingResult.Waiting + } else { + PollingResult.Success("Ready at attempt #$attemptCounter") + } + } + success { value -> value.isNotEmpty() } + backoff(backoff) + onAttempt { attempt, delayMs -> + val baseDelay = (backoff.initialDelayMs * + backoff.multiplier.pow((attempt - 1).toDouble()) + ).toLong().coerceAtMost(backoff.maxDelayMs) + val baseSecs = ((baseDelay) / 100L).toFloat() / 10f + val baseSecsStr = + ((kotlin.math.round(baseSecs * 10f)) / 10f).toString() + val actualDelay = delayMs ?: 0L + val actualSecs = (actualDelay / 100L).toFloat() / 10f + val actualSecsStr = + ((kotlin.math.round(actualSecs * 10f)) / 10f).toString() + appendLog("[info] Attempt #$attempt (base: ${baseSecsStr}s, actual: ${actualSecsStr}s)") + } + onResult { attempt, result -> + appendLog( + "[info] Result at #$attempt: ${ + describeResult( + result + ) + }" + ) + } + onComplete { attempts, durationMs, outcome -> + val secs = (durationMs / 100L).toFloat() / 10f + val secsStr = + ((kotlin.math.round(secs * 10f)) / 10f).toString() + appendLog( + "[done] Completed after $attempts attempts in ${secsStr}s: ${ + describeOutcome( + outcome + ) + }" + ) } - }, - isTerminalSuccess = { value -> value.isNotEmpty() }, - backoff = backoff, - onAttempt = { attempt, delayMs -> - // Calculate base (non-jittered) delay for this attempt - val baseDelay = (backoff.initialDelayMs * - backoff.multiplier.pow((attempt - 1).toDouble()) - ).toLong().coerceAtMost(backoff.maxDelayMs) - val baseSecs = ((baseDelay) / 100L).toFloat() / 10f - val baseSecsStr = - ((kotlin.math.round(baseSecs * 10f)) / 10f).toString() - - val actualDelay = delayMs ?: 0L - val actualSecs = (actualDelay / 100L).toFloat() / 10f - val actualSecsStr = - ((kotlin.math.round(actualSecs * 10f)) / 10f).toString() - - appendLog("[info] Attempt #$attempt (base: ${baseSecsStr}s, actual: ${actualSecsStr}s)") - }, - onResult = { attempt, result -> - appendLog("[info] Result at #$attempt: ${describeResult(result)}") - }, - onComplete = { attempts, durationMs, outcome -> - val secs = (durationMs / 100L).toFloat() / 10f - val secsStr = ((kotlin.math.round(secs * 10f)) / 10f).toString() - appendLog( - "[done] Completed after $attempts attempts in ${secsStr}s: ${ - describeOutcome( - outcome - ) - }" - ) } - ) - // Start countdown ticker - scope.launch { - while (isRunning && remainingMs > 0) { - kotlinx.coroutines.delay(100) - remainingMs = (remainingMs - 100).coerceAtLeast(0) + // Start countdown ticker respecting pause + scope.launch { + while (isRunning && remainingMs > 0) { + kotlinx.coroutines.delay(100) + if (!isPaused) remainingMs = + (remainingMs - 100).coerceAtLeast(0) + } } - } - scope.launch { - val outcome = PollingEngine.pollUntil(config) - appendLog("[done] Final Outcome: ${describeOutcome(outcome)}") - isRunning = false - remainingMs = 0 + // Start polling + handle = PollingEngine.startPolling(config) { outcome -> + appendLog("[done] Final Outcome: ${describeOutcome(outcome)}") + isRunning = false + isPaused = false + remainingMs = 0 + handle = null + } + } else { + // Toggle pause/resume + handle?.let { + if (isPaused) { + scope.launch { PollingEngine.resume(it.id) } + isPaused = false + } else { + scope.launch { PollingEngine.pause(it.id) } + isPaused = true + } + } } } ) @@ -448,10 +476,25 @@ fun App() { val secs = (remainingMs / 100L).toFloat() / 10f val secsStr = ((kotlin.math.round(secs * 10f)) / 10f).toString() Text( - text = if (isRunning) "${secsStr}s left" else "", + text = if (isRunning) "${secsStr}s left" + if (isPaused) " (paused)" else "" else "", color = MaterialTheme.colorScheme.onBackground, fontWeight = FontWeight.Medium ) + + // Stop button + GlowingButton( + enabled = isRunning, + text = "Stop", + onClick = { + handle?.let { h -> + scope.launch { PollingEngine.cancel(h) } + } + isRunning = false + isPaused = false + handle = null + remainingMs = 0 + } + ) } Spacer(Modifier.height(16.dp)) @@ -492,6 +535,53 @@ fun App() { perAttemptTimeoutText = perAttemptTimeoutText, onPerAttemptTimeoutChange = { perAttemptTimeoutText = it } ) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + GlowingButton( + enabled = isRunning && handle != null, + text = "Apply Backoff", + onClick = { + fun appendLog(msg: String) { + scope.launch { logs.add(msg) } + } + + val initialDelay = initialDelayText.toLongOrNull() + val maxDelay = maxDelayText.toLongOrNull() + val multiplier = multiplierText.toDoubleOrNull() + val jitter = jitterText.toDoubleOrNull() + val maxAttempts = maxAttemptsText.toIntOrNull() + val overallTimeout = overallTimeoutText.toLongOrNull() + val perAttemptTimeout = + perAttemptTimeoutText.trim().ifEmpty { null }?.toLongOrNull() + if (initialDelay == null || maxDelay == null || multiplier == null || jitter == null || maxAttempts == null || overallTimeout == null || (perAttemptTimeoutText.isNotEmpty() && perAttemptTimeout == null)) { + appendLog("[error] Invalid properties; cannot apply backoff.") + return@GlowingButton + } + val newPolicy = try { + BackoffPolicy( + initialDelayMs = initialDelay, + maxDelayMs = maxDelay, + multiplier = multiplier, + jitterRatio = jitter, + maxAttempts = maxAttempts, + overallTimeoutMs = overallTimeout, + perAttemptTimeoutMs = perAttemptTimeout, + ) + } catch (t: Throwable) { + appendLog("[error] ${t.message}") + return@GlowingButton + } + handle?.let { h -> + scope.launch { + PollingEngine.updateBackoff( + h.id, + newPolicy + ) + } + } + appendLog("[info] Applied new backoff policy at runtime.") + } + ) + } } Spacer(Modifier.height(16.dp)) diff --git a/docs/build.gradle.kts b/docs/build.gradle.kts new file mode 100644 index 0000000..8edb72e --- /dev/null +++ b/docs/build.gradle.kts @@ -0,0 +1,14 @@ +// Minimal Gradle module to allow running Gradle from the `docs/` directory without errors. +// This project does not participate in the build; it just makes Gradle aware that `docs/` is a subproject. + +// No plugins are applied here intentionally. + +// Provide a friendly helper task (optional) +tasks.register("docsInfo") { + group = "documentation" + description = + "Info: The docs directory is not a buildable module; use :pollingengine:dokkaHtml to generate API docs." + doLast { + println("Run ./gradlew :pollingengine:dokkaHtml to generate Dokka HTML docs.") + } +} diff --git a/docs/ci-setup.md b/docs/ci-setup.md index 8937c7f..4f2717d 100644 --- a/docs/ci-setup.md +++ b/docs/ci-setup.md @@ -1,4 +1,7 @@ -# CI and Release Setup Guide +# [ARCHIVED] CI and Release Setup Guide + +Note: CI workflows and publishing configuration have been removed from this repository as part of a +cleanup. This document is retained for reference only. Do not commit credentials or workflow files. Last updated: 2025-09-05 @@ -13,7 +16,8 @@ Context: The Gradle build for `:pollingengine` already includes publishing and s - GitHub repository admin access - Java 11 (used by the build) - A Mac runner is required for iOS/Kotlin/Native tasks (GitHub-hosted `macos-latest` works) -- Sonatype/OSSRH account with access to your `groupId` (e.g., `in.androidplay`) +- Maven Central Portal account with access to your groupId and Central Portal credentials (e.g., + `in.androidplay`) - GPG key for signing artifacts (public key published to a keyserver) - CocoaPods installed locally for validation (optional in CI, but required for `pod trunk push`) @@ -27,11 +31,15 @@ These steps help you validate before pushing tags that trigger release. - signing.keyId=YOUR_KEY_ID - signing.password=YOUR_GPG_PASSPHRASE - signing.key=-----BEGIN PGP PRIVATE KEY BLOCK-----\n...\n-----END PGP PRIVATE KEY BLOCK----- - - ossrhUsername=YOUR_SONATYPE_USERNAME - - ossrhPassword=YOUR_SONATYPE_PASSWORD + # Optional legacy path (not recommended): if you have a key ring file, you can set + # signing.secretKeyRingFile=/path/to/your_secret.gpg + # The build will load it into memory; no + `gpg` binary is required. Do NOT set signing.useGpg unless you want to use system gpg. + - mavenCentralUsername=YOUR_CENTRAL_PORTAL_USERNAME + - mavenCentralPassword=YOUR_CENTRAL_PORTAL_PASSWORD -2. Dry-run a local publish to Sonatype staging (optional): - - ./gradlew :pollingengine:publishToSonatype closeAndReleaseSonatypeStagingRepository +2. Dry-run a local publish (legacy direct): + - ./gradlew :pollingengine:publish 3. Generate docs locally: - ./gradlew :pollingengine:dokkaHtml @@ -47,8 +55,9 @@ These steps help you validate before pushing tags that trigger release. Set these secrets in your GitHub repository under Settings → Security → Secrets and variables → Actions → New repository secret. Core publishing: -- OSSRH_USERNAME: Sonatype (OSSRH) username -- OSSRH_PASSWORD: Sonatype (OSSRH) password + +- MAVEN_CENTRAL_USERNAME: Central Portal username +- MAVEN_CENTRAL_PASSWORD: Central Portal password - SIGNING_KEY_ID: Your GPG key ID (short or long ID as used by Gradle signing) - SIGNING_PASSWORD: Passphrase for the GPG private key - SIGNING_KEY: ASCII-armored GPG private key contents (single line or multiline; ensure proper YAML quoting in workflow if needed) @@ -71,16 +80,16 @@ The Gradle signing and publishing configuration will read credentials from Gradl - ORG_GRADLE_PROJECT_signing.keyId → SIGNING_KEY_ID - ORG_GRADLE_PROJECT_signing.password → SIGNING_PASSWORD - ORG_GRADLE_PROJECT_signing.key → SIGNING_KEY -- ORG_GRADLE_PROJECT_ossrhUsername → OSSRH_USERNAME -- ORG_GRADLE_PROJECT_ossrhPassword → OSSRH_PASSWORD +- ORG_GRADLE_PROJECT_mavenCentralUsername → MAVEN_CENTRAL_USERNAME +- ORG_GRADLE_PROJECT_mavenCentralPassword → MAVEN_CENTRAL_PASSWORD Workflows should map secrets to these env vars, e.g.: - env: - ORG_GRADLE_PROJECT_signing.keyId: ${{ secrets.SIGNING_KEY_ID }} - ORG_GRADLE_PROJECT_signing.password: ${{ secrets.SIGNING_PASSWORD }} - ORG_GRADLE_PROJECT_signing.key: ${{ secrets.SIGNING_KEY }} - - ORG_GRADLE_PROJECT_ossrhUsername: ${{ secrets.OSSRH_USERNAME }} - - ORG_GRADLE_PROJECT_ossrhPassword: ${{ secrets.OSSRH_PASSWORD }} + - ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + - ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} This allows Gradle to pick them up without committing anything sensitive. @@ -100,7 +109,8 @@ B) Release workflow (tag-driven): - Trigger: push tag `v*` (e.g., `v0.1.0`) - macOS runner (needed for K/N): - Set env vars from secrets (see section 4) - - Build artifacts: `./gradlew clean build :pollingengine:assembleReleaseXCFramework :pollingengine:publishToSonatype closeAndReleaseSonatypeStagingRepository :pollingengine:dokkaHtml` + - Build artifacts: + `./gradlew clean build :pollingengine:assembleReleaseXCFramework :pollingengine:publish :pollingengine:dokkaHtml` - Upload Dokka site and build artifacts as GitHub Release assets (optional) - Optionally publish gh-pages docs if you maintain a docs site @@ -121,7 +131,8 @@ C) Optional CocoaPods publish job: - Create and push a tag `vX.Y.Z`: - git tag v0.1.0 - git push origin v0.1.0 -- CI release workflow publishes to Sonatype and closes/releases the staging repo +- CI release workflow publishes to Maven Central (Central Portal) and closes/releases the staging + repository - Wait for Maven Central sync (can take up to ~2 hours) 2) CocoaPods release: @@ -142,14 +153,16 @@ C) Optional CocoaPods publish job: - ./gradlew :pollingengine:assembleReleaseXCFramework - ./gradlew :pollingengine:podspec - pod lib lint pollingengine/pollingengine.podspec --allow-warnings (optional) -- ./gradlew :pollingengine:publishToSonatype closeAndReleaseSonatypeStagingRepository (optional dry-run with real creds) +- ./gradlew :pollingengine:publishAllPublicationsToMavenCentralRepository :pollingengine: + closeAndReleaseMavenCentralStagingRepository (optional dry-run with real creds) --- ## 8) Troubleshooting - Signing errors (e.g., "No appropriate signing key"): Verify SIGNING_KEY, SIGNING_KEY_ID, and SIGNING_PASSWORD are set and correctly mapped to ORG_GRADLE_PROJECT_* env vars. -- Sonatype errors (401/403): Verify OSSRH_USERNAME/OSSRH_PASSWORD and groupId ownership. +- Central Portal errors (401/403): Verify MAVEN_CENTRAL_USERNAME/MAVEN_CENTRAL_PASSWORD and groupId + ownership. - iOS build failures on Linux runners: Use macOS runners for Kotlin/Native iOS tasks. - CocoaPods push rejection: Ensure the spec version matches a git tag, the homepage and source URLs are reachable, and that you’re registered on CocoaPods trunk. - Dokka task issues: Ensure the Dokka plugin version matches the Kotlin version; re-run `./gradlew :pollingengine:dokkaHtml` with `--info` for details. @@ -163,7 +176,7 @@ Common Gradle tasks you’ll use: - Lint and static analysis: `./gradlew detekt ktlintCheck` - API check (if configured): `./gradlew apiCheck` - Dokka docs: `./gradlew :pollingengine:dokkaHtml` -- Publish to Sonatype: `./gradlew :pollingengine:publishToSonatype closeAndReleaseSonatypeStagingRepository` +- Publish to Maven Central (legacy direct): `./gradlew :pollingengine:publish` - Generate Podspec: `./gradlew :pollingengine:podspec` - Build XCFramework: `./gradlew :pollingengine:assembleReleaseXCFramework` diff --git a/docs/tasks.md b/docs/tasks.md deleted file mode 100644 index b725e6c..0000000 --- a/docs/tasks.md +++ /dev/null @@ -1,174 +0,0 @@ -# PollingEngine Improvement Tasks Checklist - -Last updated: 2025-09-05 02:27 - -Note: The items are ordered to minimize risk: refactors first, quality gates next, then release automation. Sub‑tasks include concrete actions. - -## Initial Tasks Checklist - -1. [x] Split app and library modules (establish a publishable KMP library) - - [x] Create a new `:pollingengine` Kotlin Multiplatform library module (use `com.android.library` + `kotlin-multiplatform`). - - [x] Move core sources from `composeApp/src/commonMain/.../polling` and `.../models` into `pollingengine/src/commonMain`. - - [x] Keep `composeApp` as sample/demo app depending on `:pollingengine`. - - [x] Remove sample utilities (e.g., `PollingSamples`) from library; keep in `composeApp`. - - [x] Set proper package names and update imports in moved files. - -2. [ ] Define public API surface and semantic versioning - - [x] Review which classes/functions are public (PollingEngine, PollingConfig, BackoffPolicy, outcomes). - - [ ] Add/review `@JvmOverloads`, `@JvmName`, and `@Throws` where interop requires. - - [x] Establish semantic versioning policy (MAJOR.MINOR.PATCH) and document in README. - - [x] Introduce `api` vs `internal` visibility (and `internal` for non-API helpers). - -3. [x] Decouple external error code dependency - - [x] Remove coupling to `com.stellantis.space.core.repositories.UNKNOWN_ERROR_CODE`. - - [x] Define library-internal error codes or a sealed error model in `models`. - - [x] Provide mapping hooks so consumers can adapt to their error taxonomy. - -4. [x] Improve configuration ergonomics - - [x] Finalize/streamline `PollingConfigBuilder` DSL (inline lambdas with receiver, defaults). - - [x] Provide convenience factory methods and presets (`BackoffPolicies`). - - [x] Validate invariants at build time with descriptive messages. - -5. [x] Observability hooks and structured logging - - [x] Keep `onAttempt`, `onResult`, `onComplete`; document their threading and timings. - - [x] Provide optional logger interface (expect/actual or simple callback) without forcing a logging dependency. - - [x] Add simple metrics hooks (attempts, durations) through an interface that users can implement. - -6. [x] Concurrency and cancellation hardening - - [x] Add explicit APIs to query active polls and optionally cancel by handle/token. - - [x] Audit `CancellationException` handling paths; add tests for rogue vs real cancels. - - [x] Ensure no shared mutable state leaks across polls (e.g., `activePolls` synchronization if needed for concurrency safety across threads/platforms). - -7. [x] Performance considerations - - [x] Verify backoff growth, jitter bounds, and max delay clamping; micro-benchmark if needed. - - [x] Avoid unnecessary allocations inside polling loop; consider making `Random` source injectable for deterministic tests. - -8. [x] Code quality and style gates - - [x] Add Detekt with a sensible baseline and rules (complexity, error-prone, style). - - [x] Add ktlint (or Ktlint Gradle) and configure formatting tasks. - - [x] Add Kotlin Binary Compatibility Validator to guard public API surface. - - [x] Enable explicit API mode for the library module. - -9. [x] Documentation generation - - [x] Add Dokka to generate API docs for KMP targets. - - [x] Publish docs to GitHub Pages (or attach to releases) via CI. - - [x] Update root README with usage, configuration, and platform notes. - -10. [ ] Testing strategy and coverage - - [ ] Replace placeholder test with meaningful unit tests for: - - [x] BackoffPolicy validation (bounds, errors on invalid input). - - [x] Jitter range correctness (min/max bounds, distribution sanity). - - [ ] Polling loop: terminal success, exhausted, timeout, cancelled scenarios. - - [ ] Per-attempt timeout behavior using test dispatcher and virtual time. - - [ ] Error handling and retry predicates. - - [ ] Add common tests runnable on JVM and Native (where viable). - - [ ] Configure `kotlinx-coroutines-test` for common tests (JVM) and document limits for Native. - - [ ] Add minimal integration tests in `composeApp` sample (Android instrumented optional). - -11. [ ] Build configuration for library publishing (Android + iOS) - - [x] Set `group`, `version`, `description`, and POM metadata (name, url, licenses, developers, scm) in Gradle. - - [x] Apply `maven-publish` and `signing` plugins to `:pollingengine`. - - [x] Configure Android target as library (AAR) with publication variants. - - [x] Configure iOS targets and an `XCFramework` artifact for release builds. - - [x] Add root `io.github.gradle-nexus.publish-plugin` for Sonatype. - - [x] Externalize secrets (OSSRH user/pass, GPG keyId/password) via environment variables or `~/.gradle/gradle.properties`. - -12. [ ] Continuous Integration (CI) - - [x] Add GitHub Actions workflow to build and test on PRs (matrix: macOS for iOS, Ubuntu for JVM/Android). - - [x] Add static checks (Detekt, ktlint, API check, Dokka) to CI. - - [x] Cache Gradle and Kotlin/Native to speed up runs. - -13. [ ] Release automation (tag-driven) - - [x] Add a GitHub Actions release workflow that on tag `v*`: - - [x] Builds artifacts (AAR, XCFramework). - - [x] Publishes to Maven Central (Sonatype) after signing. - - [x] Attaches artifacts to GitHub Release (docs, XCFramework zip). - - [x] Optionally updates GitHub Pages docs. - -14. [ ] Consumer onboarding and samples - - [x] Provide Android sample (existing `composeApp`) consuming `:pollingengine` from project dependency. - - [ ] Provide Swift sample (inside `iosApp`) consuming the framework. - - [ ] Add copy-paste snippets (Gradle, Swift, CocoaPods, SPM) to README. - -15. [ ] Security and maintenance - - [x] Enable Dependabot (Gradle + GitHub Actions). - - [x] Enable code scanning (GitHub CodeQL) for Kotlin/Swift where applicable. - - [ ] Add license file and headers. - - [x] Add `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, `SECURITY.md`. - ---- - -## Release Playbook — Android (Maven Central) - -1. [x] Prepare Sonatype/OSSRH account and GPG signing - - [x] Create an OSSRH account and request `groupId` ownership (e.g., `in.androidplay`). - - [x] Generate a GPG key: `gpg --full-generate-key` and publish to a keyserver (e.g., keyserver.ubuntu.com). - - [x] Store the private key and passphrase securely (CI secrets). - -2. [x] Configure Gradle for publishing in `:pollingengine` - - [x] In `gradle.properties` (user or project), set: - - [x] `signing.keyId`, `signing.password`, `signing.key` (armored private key), - - [x] `ossrhUsername`, `ossrhPassword`, - - [x] `GROUP`, `VERSION_NAME` (or set in build.gradle.kts). - - [x] Apply plugins: `maven-publish`, `signing` and root `io.github.gradle-nexus.publish-plugin`. - - [x] Configure publications for Android AAR and metadata (`pom { name, description, url, licenses, scm, developers }`). - -3. [x] Dry run locally - - [x] `./gradlew :pollingengine:publishToSonatype closeAndReleaseSonatypeStagingRepository` (or use staged close first, then release). - - [x] Verify artifacts and POM contents in staging repository. - -4. [x] Release - - [x] Create a Git tag `vX.Y.Z` and push. - - [x] CI runs the release workflow to publish to Maven Central. - - [x] Wait for sync to Maven Central (can take up to ~2 hours). - -5. [x] Verify consumption - - [x] Consume from a sample Android project: `implementation("in.androidplay:pollingengine:X.Y.Z")`. - ---- - -## Release Playbook — iOS via CocoaPods - -1. [x] Add CocoaPods support to `:pollingengine` - - [x] Apply `kotlin("native.cocoapods")` plugin and configure: - - [x] `summary`, `homepage`, `license`, `authors`, `ios.deploymentTarget`. - - [x] `framework { baseName = "PollingEngine"; isStatic = true }`. - - [x] Run `podspec` generation via Gradle: `./gradlew :pollingengine:podspec`. - -2. [ ] Validate locally - - [ ] `pod lib lint PollingEngine.podspec --allow-warnings`. - -3. [ ] Publish - - [ ] Register for CocoaPods trunk if not already. - - [ ] `pod trunk push PollingEngine.podspec --allow-warnings`. - -4. [ ] Consume - - [ ] In `Podfile`: `pod 'PollingEngine', '~> X.Y'` and run `pod install`. - ---- - -## Release Playbook — iOS via Swift Package Manager (binary XCFramework) - -1. [ ] Build XCFramework artifact - - [ ] Configure iOS targets for `:pollingengine` and ensure `isStatic = true` as needed. - - [ ] Build: `./gradlew :pollingengine:assembleReleaseXCFramework`. - - [ ] Zip the `.xcframework` (e.g., `PollingEngine.xcframework.zip`). - -2. [ ] Compute checksum and host artifact - - [ ] Upload the zip to GitHub Releases (or a CDN with HTTPS). - - [ ] Compute checksum: `swift package compute-checksum PollingEngine.xcframework.zip`. - -3. [ ] Create/Update `Package.swift` - - [ ] Provide a Swift Package with a binary target referencing the URL and checksum of the zip. - - [ ] Tag the repo `vX.Y.Z` so SPM can resolve the package. - -4. [ ] Consume - - [ ] In Xcode, add the package URL; select version `X.Y.Z`. - ---- - -## Post‑release - -1. [ ] Update `CHANGELOG.md` with the released version and notes. -2. [ ] Update README usage snippets with the new version. -3. [ ] Announce release and collect feedback/issues. diff --git a/gradle.properties b/gradle.properties index 92cc005..1bf6e62 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,22 +4,11 @@ kotlin.daemon.jvmargs=-Xmx3072M #Gradle org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 -org.gradle.configuration-cache=true +# Configuration cache can cause issues with publish tasks; disable for reliability. Enable locally if desired. +org.gradle.configuration-cache=false org.gradle.caching=true #Android android.nonTransitiveRClass=true android.useAndroidX=true -# --- Maven Central (Sonatype) publishing configuration --- -# Set these in ~/.gradle/gradle.properties or as environment variables in CI -# ossrhUsername=your_sonatype_username -# ossrhPassword=your_sonatype_password -# signing.keyId=XXXXXXXXXXXXXXXX -# signing.password=your_gpg_key_passphrase -# signing.key=-----BEGIN PGP PRIVATE KEY BLOCK----- -# (armored private key contents) -# -----END PGP PRIVATE KEY BLOCK----- -# Optionally project-wide coordinates (also set in module build.gradle.kts) -# GROUP=in.androidplay -# VERSION_NAME=0.1.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 91ca2d8..76c7999 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.10.1" +agp = "8.12.2" android-compileSdk = "36" android-minSdk = "24" android-targetSdk = "36" @@ -12,6 +12,8 @@ androidx-testExt = "1.3.0" composeMultiplatform = "1.8.2" junit = "4.13.2" kotlin = "2.2.10" +pollingengine = "0.1.0" +kotlinx-coroutines = "1.9.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -24,10 +26,18 @@ androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "a androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +pollingengine = { module = "io.github.bosankus:pollingengine", version.ref = "pollingengine" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } \ No newline at end of file +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version = "0.34.0" } +dokka = { id = "org.jetbrains.dokka", version = "2.0.0" } +detekt = { id = "io.gitlab.arturbosch.detekt", version = "1.23.8" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "13.1.0" } +binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.18.1" } diff --git a/pollingengine/build.gradle.kts b/pollingengine/build.gradle.kts index 14e1ef7..a96d3eb 100644 --- a/pollingengine/build.gradle.kts +++ b/pollingengine/build.gradle.kts @@ -3,16 +3,16 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidLibrary) + alias(libs.plugins.dokka) + alias(libs.plugins.detekt) + alias(libs.plugins.ktlint) + alias(libs.plugins.vanniktechMavenPublish) kotlin("native.cocoapods") - id("org.jetbrains.dokka") version "1.9.20" - id("io.gitlab.arturbosch.detekt") version "1.23.6" - id("org.jlleitschuh.gradle.ktlint") version "12.1.1" - id("maven-publish") id("signing") } -group = "in.androidplay" -version = "0.1.0" +group = "io.github.bosankus" +version = libs.versions.pollingengine.get() description = "PollingEngine KMP library providing robust polling with backoff and jitter" kotlin { @@ -20,17 +20,15 @@ kotlin { androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } - publishLibraryVariants("release") } // CocoaPods configuration for iOS consumption cocoapods { - summary = "Robust polling engine with configurable backoff and jitter" - homepage = "https://github.com/androidplay/PollingEngine" ios.deploymentTarget = "14.0" - // Add extra spec attributes for license and authors - extraSpecAttributes["license"] = "{ :type => 'Apache-2.0', :file => 'LICENSE' }" - extraSpecAttributes["authors"] = "{ 'AndroidPlay' => 'opensource@androidplay.in' }" + summary = project.findProperty("pollingengine.summary") as String + homepage = project.findProperty("pollingengine.homepage") as String + extraSpecAttributes["license"] = project.findProperty("pollingengine.license") as String + extraSpecAttributes["authors"] = project.findProperty("pollingengine.authors") as String framework { baseName = "PollingEngine" @@ -50,11 +48,11 @@ kotlin { sourceSets { commonMain.dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") + implementation(libs.kotlinx.coroutines.core) } commonTest.dependencies { implementation(libs.kotlin.test) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + implementation(libs.kotlinx.coroutines.test) } } } @@ -74,7 +72,7 @@ android { } buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true } } } @@ -92,32 +90,4 @@ ktlint { android.set(true) } -publishing { - publications.withType().configureEach { - pom { - name.set("PollingEngine") - description.set(project.description) - url.set("https://github.com/androidplay/PollingEngine") - licenses { - license { - name.set("Apache-2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") - } - } - scm { - url.set("https://github.com/androidplay/PollingEngine") - } - developers { - developer { - id.set("androidplay") - name.set("AndroidPlay") - } - } - } - } -} - -signing { - // No-op locally; configured via env/gradle.properties in CI - isRequired = false -} +apply(from = "publishing.gradle.kts") diff --git a/pollingengine/pollingengine.podspec b/pollingengine/pollingengine.podspec index f9cd3a1..1b72186 100644 --- a/pollingengine/pollingengine.podspec +++ b/pollingengine/pollingengine.podspec @@ -51,5 +51,5 @@ Pod::Spec.new do |spec| } ] spec.license = { :type => 'Apache-2.0', :file => 'LICENSE' } - spec.authors = { 'AndroidPlay' => 'opensource@androidplay.in' } + spec.authors = { 'AndroidPlay' => 'ankush@androidplay.in' } end \ No newline at end of file diff --git a/pollingengine/publishing.gradle.kts b/pollingengine/publishing.gradle.kts new file mode 100644 index 0000000..036074e --- /dev/null +++ b/pollingengine/publishing.gradle.kts @@ -0,0 +1,80 @@ +import org.gradle.api.provider.Property +import org.gradle.kotlin.dsl.withGroovyBuilder +import org.gradle.plugins.signing.Sign + +// Publishing and signing configuration extracted from build.gradle.kts to declutter the module script. +fun prop(name: String): String? = + (project.findProperty(name) as String?)?.trim()?.takeIf { it.isNotEmpty() } + +val signingEnabledGate: Boolean = run { + val fromProp = prop("signing.enabled")?.toBooleanStrictOrNull() + val fromEnv = System.getenv("SIGNING_ENABLED")?.trim()?.takeIf { it.isNotEmpty() } + ?.toBooleanStrictOrNull() + (fromProp ?: fromEnv) == true +} + +val hasInMemorySigning: Boolean = listOf("signing.key", "signing.password") + .all { prop(it)?.isNotBlank() == true } + +val hasSecretKeyRingSigning: Boolean = run { + val keyRing = prop("signing.secretKeyRingFile") + val passwordOk = prop("signing.password")?.isNotBlank() == true + keyRing?.let { file(it).exists() } == true && passwordOk +} + +val shouldSignPublications: Boolean = + signingEnabledGate && (hasInMemorySigning || hasSecretKeyRingSigning) + +println("[mavenPublishing] Signing detection -> gate=$signingEnabledGate, inMemory=$hasInMemorySigning, secretKeyRing=$hasSecretKeyRingSigning, shouldSign=$shouldSignPublications") + +// Ensure any auto-wired signing tasks are skipped when no credentials are present +tasks.withType(Sign::class.java).configureEach { + enabled = shouldSignPublications + onlyIf { shouldSignPublications } +} + +// Configure Vanniktech Maven Publish extension dynamically to avoid typed references +extensions.getByName("mavenPublishing").withGroovyBuilder { + // Publish to Maven Central (S01 host is picked up from properties by the plugin if set) + "publishToMavenCentral"() + + if (shouldSignPublications) { + "signAllPublications"() + } else { + println("[mavenPublishing] No signing config detected. Skipping signing of publications.") + } + + // Define POM metadata required by Maven Central. Values are read from Gradle properties. + "pom" { + fun p(key: String) = providers.gradleProperty(key) + + // Top-level POM fields + (getProperty("name") as Property).set(p("pom.name")) + (getProperty("description") as Property).set(p("pom.description")) + (getProperty("url") as Property).set(p("pom.url")) + + // Licenses + "licenses" { + "license" { + (getProperty("name") as Property).set(p("pom.license.name")) + (getProperty("url") as Property).set(p("pom.license.url")) + } + } + + // Developers + "developers" { + "developer" { + (getProperty("id") as Property).set(p("pom.developer.id")) + (getProperty("name") as Property).set(p("pom.developer.name")) + (getProperty("url") as Property).set(p("pom.developer.url")) + } + } + + // SCM + "scm" { + (getProperty("url") as Property).set(p("pom.scm.url")) + (getProperty("connection") as Property).set(p("pom.scm.connection")) + (getProperty("developerConnection") as Property).set(p("pom.scm.developerConnection")) + } + } +} diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfig.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfig.kt index 88fdd2c..d85bc98 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfig.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfig.kt @@ -16,7 +16,7 @@ public data class PollingConfig( val dispatcher: CoroutineDispatcher = Dispatchers.Default, val onAttempt: (attempt: Int, delayMs: Long?) -> Unit = { _, _ -> }, val onResult: (attempt: Int, result: PollingResult) -> Unit = { _, _ -> }, - val onComplete: (attempts: Int, durationMs: Long, outcome: PollingOutcome) -> Unit = { _, _, _ -> }, + val onComplete: (attempts: Int, durationMs: Long, outcome: PollingOutcome) -> Unit = { _, _, _ -> }, /** Maps any thrown exception into a domain [Error] used by retry predicates and reporting. */ val throwableMapper: (Throwable) -> Error = { t -> val msg = t.message ?: (t::class.simpleName ?: "Throwable") diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfigBuilder.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfigBuilder.kt index 3e1fdc7..5f0462b 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfigBuilder.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingConfigBuilder.kt @@ -17,7 +17,14 @@ public class PollingConfigBuilder { private var dispatcher: CoroutineDispatcher = Dispatchers.Default private var onAttemptHook: (attempt: Int, delayMs: Long?) -> Unit = { _, _ -> } private var onResultHook: (attempt: Int, result: PollingResult) -> Unit = { _, _ -> } - private var onCompleteHook: (attempts: Int, durationMs: Long, outcome: PollingOutcome) -> Unit = { _, _, _ -> } + private var onCompleteHook: (attempts: Int, durationMs: Long, outcome: PollingOutcome) -> Unit = + { _, _, _ -> } + private var throwableMapper: (Throwable) -> Error = { t -> + val msg = t.message ?: (t::class.simpleName ?: "Throwable") + Error(ErrorCodes.UNKNOWN_ERROR_CODE, msg) + } + private var logger: Logger? = null + private var metrics: Metrics? = null public fun fetch(block: suspend () -> PollingResult): PollingConfigBuilder = apply { this.fetchStrategy = LambdaFetchStrategy(block) @@ -51,6 +58,18 @@ public class PollingConfigBuilder { this.dispatcher = dispatcher } + public fun throwableMapper(mapper: (Throwable) -> Error): PollingConfigBuilder = apply { + this.throwableMapper = mapper + } + + public fun logger(logger: Logger?): PollingConfigBuilder = apply { + this.logger = logger + } + + public fun metrics(metrics: Metrics?): PollingConfigBuilder = apply { + this.metrics = metrics + } + public fun onAttempt(hook: (attempt: Int, delayMs: Long?) -> Unit): PollingConfigBuilder = apply { this.onAttemptHook = hook } @@ -59,7 +78,8 @@ public class PollingConfigBuilder { this.onResultHook = hook } - public fun onComplete(hook: (attempts: Int, durationMs: Long, outcome: PollingOutcome) -> Unit): PollingConfigBuilder = apply { + public fun onComplete(hook: (attempts: Int, durationMs: Long, outcome: PollingOutcome) -> Unit): PollingConfigBuilder = + apply { this.onCompleteHook = hook } @@ -76,6 +96,13 @@ public class PollingConfigBuilder { onAttempt = onAttemptHook, onResult = onResultHook, onComplete = onCompleteHook, + throwableMapper = throwableMapper, + logger = logger, + metrics = metrics, ) } } + +/** DSL entrypoint to build a PollingConfig in a concise way. */ +public fun pollingConfig(block: PollingConfigBuilder.() -> Unit): PollingConfig = + PollingConfigBuilder().apply(block).build() diff --git a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt index 550ff0f..54e3cff 100644 --- a/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt +++ b/pollingengine/src/commonMain/kotlin/in/androidplay/pollingengine/polling/PollingEngine.kt @@ -13,6 +13,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -30,11 +32,26 @@ import kotlin.time.TimeSource */ public object PollingEngine { + public enum class State { Running, Paused } + + private data class Control( + val id: String, + val state: kotlinx.coroutines.flow.MutableStateFlow = kotlinx.coroutines.flow.MutableStateFlow( + State.Running + ), + val backoff: kotlinx.coroutines.flow.MutableStateFlow = kotlinx.coroutines.flow.MutableStateFlow( + null + ), + ) + public data class Handle(public val id: String) - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var supervisor: Job = SupervisorJob() + private var scope: CoroutineScope = CoroutineScope(supervisor + Dispatchers.Default) private val mutex = Mutex() private val active: MutableMap = mutableMapOf() + private val controls: MutableMap = mutableMapOf() + private var isShutdown: Boolean = false public fun activePollsCount(): Int = active.size @@ -44,45 +61,113 @@ public object PollingEngine { mutex.withLock { active[id]?.cancel(CancellationException("Cancelled by user")) } } + public suspend fun pause(id: String) { + mutex.withLock { controls[id]?.state?.value = State.Paused } + } + + public suspend fun resume(id: String) { + mutex.withLock { controls[id]?.state?.value = State.Running } + } + + public suspend fun updateBackoff(id: String, newPolicy: BackoffPolicy) { + mutex.withLock { controls[id]?.backoff?.value = newPolicy } + } + public suspend fun cancel(handle: Handle) { cancel(handle.id) } + /** Cancels all active polls and clears the registry. */ + public suspend fun cancelAll() { + val toCancel: List = mutex.withLock { active.values.toList() } + toCancel.forEach { it.cancel(CancellationException("Cancelled by user")) } + mutex.withLock { active.clear() } + } + + /** Shuts down the engine: cancels all polls, cancels its scope, and prevents new polls from starting. */ + public suspend fun shutdown() { + if (isShutdown) return + cancelAll() + mutex.withLock { + isShutdown = true + } + supervisor.cancel(CancellationException("PollingEngine shutdown")) + } + public fun startPolling( config: PollingConfig, onComplete: (PollingOutcome) -> Unit, ): Handle { + if (isShutdown) throw IllegalStateException("PollingEngine is shut down") val id = generateId() + val control = Control(id) val job = scope.launch(config.dispatcher) { - val outcome = pollUntil(config) + val outcome = pollUntil(config, control) try { onComplete(outcome) } finally { - mutex.withLock { active.remove(id) } + mutex.withLock { + active.remove(id) + controls.remove(id) + } + } + } + scope.launch { + mutex.withLock { + active[id] = job + controls[id] = control } } - scope.launch { mutex.withLock { active[id] = job } } return Handle(id) } + /** + * Compose multiple polling operations sequentially. Stops early on non-success outcomes. + * Returns the last outcome (success from the last config or the first non-success). + */ + public suspend fun compose(vararg configs: PollingConfig): PollingOutcome { + var lastOutcome: PollingOutcome? = null + for (cfg in configs) { + val control = Control(generateId()) + val outcome = pollUntil(cfg, control) + lastOutcome = outcome + when (outcome) { + is PollingOutcome.Success -> continue + else -> return outcome + } + } + return lastOutcome ?: error("No configs provided") + } + private fun generateId(): String { val alphabet = (('a'..'z') + ('0'..'9')) return buildString(10) { repeat(10) { append(alphabet.random()) } } } - public suspend fun pollUntil(config: PollingConfig): PollingOutcome = withContext(config.dispatcher) { + public suspend fun pollUntil(config: PollingConfig): PollingOutcome = + pollUntil(config, Control(generateId())) + + private suspend fun pollUntil( + config: PollingConfig, + control: Control + ): PollingOutcome = withContext(config.dispatcher) { val startMark = TimeSource.Monotonic.markNow() var attempt = 0 var nextDelay = config.backoff.initialDelayMs.coerceAtLeast(0L) var lastResult: PollingResult? = null try { - while (attempt < config.backoff.maxAttempts) { + while (attempt < (control.backoff.value ?: config.backoff).maxAttempts) { + // Suspend while paused + if (control.state.value == State.Paused) { + control.state.map { it == State.Running }.first { it } + } ensureActive() attempt++ + val policy = control.backoff.value ?: config.backoff val elapsedMs = startMark.elapsedNow().inWholeMilliseconds - val remainingOverall = config.backoff.overallTimeoutMs - elapsedMs + val remainingOverall = policy.overallTimeoutMs - elapsedMs if (remainingOverall <= 0) { @Suppress("UNCHECKED_CAST") return@withContext PollingOutcome.Timeout(lastResult, attempt - 1, elapsedMs) @@ -90,7 +175,7 @@ public object PollingEngine { // Execute one attempt with per-attempt timeout if configured val result: PollingResult = try { - val timeoutMs = config.backoff.perAttemptTimeoutMs + val timeoutMs = policy.perAttemptTimeoutMs if (timeoutMs != null) { withTimeout(minOf(timeoutMs, remainingOverall)) { // Preface only the first attempt immediately @@ -159,23 +244,23 @@ public object PollingEngine { } } - // Compute jittered delay around current base - val base = nextDelay.coerceAtMost(config.backoff.maxDelayMs) - val sleepMs = config.backoff.computeJitteredDelay(base).coerceAtMost(config.backoff.maxDelayMs) + // Compute jittered delay around current base (use latest policy) + val base = nextDelay.coerceAtMost(policy.maxDelayMs) + val sleepMs = policy.computeJitteredDelay(base).coerceAtMost(policy.maxDelayMs) - // Increase delay for next cycle - val multiplied = (nextDelay.toDouble() * config.backoff.multiplier) - nextDelay = multiplied.toLong().coerceAtMost(config.backoff.maxDelayMs) + // Increase delay for next cycle using current policy + val multiplied = (nextDelay.toDouble() * policy.multiplier) + nextDelay = multiplied.toLong().coerceAtMost(policy.maxDelayMs) // Respect overall timeout before sleeping val elapsedBeforeSleep = startMark.elapsedNow().inWholeMilliseconds - val remainingBeforeSleep = config.backoff.overallTimeoutMs - elapsedBeforeSleep + val remainingBeforeSleep = policy.overallTimeoutMs - elapsedBeforeSleep if (remainingBeforeSleep <= 0) break // Provide the actual computed delay for the NEXT attempt's preface (attempt+1) // Only announce if there is time left to sleep and another attempt could happen. val nextAttemptIndex = attempt + 1 - if (nextAttemptIndex <= config.backoff.maxAttempts) { + if (nextAttemptIndex <= policy.maxAttempts) { config.metrics?.recordAttempt(nextAttemptIndex, sleepMs) config.onAttempt(nextAttemptIndex, sleepMs) } @@ -184,7 +269,8 @@ public object PollingEngine { } val totalMs = startMark.elapsedNow().inWholeMilliseconds - val outcome = if (totalMs >= config.backoff.overallTimeoutMs) { + val outcome = + if (totalMs >= (control.backoff.value ?: config.backoff).overallTimeoutMs) { PollingOutcome.Timeout(lastResult, attempt, totalMs) } else { PollingOutcome.Exhausted(lastResult, attempt, totalMs) diff --git a/settings.gradle.kts b/settings.gradle.kts index df2c248..64e926c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,7 @@ pluginManagement { } } mavenCentral() + mavenCentral() gradlePluginPortal() } } @@ -23,9 +24,11 @@ dependencyResolutionManagement { includeGroupAndSubgroups("com.google") } } + mavenLocal() mavenCentral() } } include(":composeApp") -include(":pollingengine") \ No newline at end of file +include(":pollingengine") +include(":docs") \ No newline at end of file