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
+
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
+
+[](https://central.sonatype.com/artifact/io.github.bosankus/pollingengine)
+
+[](#license)
+[](#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 9f61b22..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 "2.0.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/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/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 7fdbced..cfcebf5 100644
--- a/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt
+++ b/composeApp/src/commonMain/kotlin/in/androidplay/pollingengine/App.kt
@@ -1,74 +1,593 @@
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.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
+
+// ------- 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()
+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(items = logs, key = { it.hashCode() }) { line ->
+ androidx.compose.animation.AnimatedVisibility(visible = true) {
+ LogEntryCard(line = line)
+ }
+ }
+ }
+ }
+ }
+}
+
+// --- Simple log entry card without icons for multiplatform compatibility ---
+@Composable
+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)
+ 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,
+) {
+ 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()
+ .padding(bottom = 2.dp)
+ ) {
Column(
- modifier = Modifier
- .background(MaterialTheme.colorScheme.primaryContainer)
- .safeContentPadding()
- .fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.padding(18.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
) {
- Button(onClick = { showContent = !showContent }) {
- Text("Toggle Greeting")
- }
- AnimatedVisibility(showContent) {
- val greeting = remember { Greeting().greet() }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(14.dp)
) {
- Image(painterResource(Res.drawable.compose_multiplatform), null)
- Text("Compose: $greeting")
+ 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("maxAttempts", maxAttemptsText, onMaxAttemptsChange)
+ LabeledField(
+ "perAttemptTimeoutMs",
+ perAttemptTimeoutText,
+ onPerAttemptTimeoutChange
+ )
+ }
+ Column(
+ modifier = Modifier.weight(1f),
+ 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)
}
}
- Button(onClick = {
- if (!isRunning) {
- isRunning = true
- demoResult = null
- scope.launch {
- demoResult = demoPoll()
- isRunning = false
+ }
+ }
+}
+
+@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 -> {
+ 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
+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) }
+ 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) }
+
+ // 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 = true,
+ text = when {
+ !isRunning -> "Start Polling"
+ isPaused -> "Resume"
+ else -> "Pause"
+ },
+ onClick = {
+ fun appendLog(msg: String) {
+ scope.launch { logs.add(msg) }
+ }
+ 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
+ }
+
+ 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
+ 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
+ )
+ }"
+ )
+ }
+ }
+
+ // Start countdown ticker respecting pause
+ scope.launch {
+ while (isRunning && remainingMs > 0) {
+ kotlinx.coroutines.delay(100)
+ if (!isPaused) remainingMs =
+ (remainingMs - 100).coerceAtLeast(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
+ }
+ }
+ }
+ }
+ )
+ 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" + 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))
+
+ // 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 }
+ )
+ 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.")
+ }
+ )
}
}
- }) {
- 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)
+
+ Spacer(Modifier.height(16.dp))
+
+ // Terminal Log view
+ TerminalLog(modifier = Modifier.weight(1f), logs = logs)
}
}
}
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/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/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/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 110c978..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 "2.0.0"
- id("io.gitlab.arturbosch.detekt") version "1.23.8"
- id("org.jlleitschuh.gradle.ktlint") version "13.1.0"
- 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.10.2")
+ implementation(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
- implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
+ 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/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,
)
}
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 c3c3f7e..54e3cff 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
@@ -14,12 +13,15 @@ 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
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.
@@ -30,57 +32,142 @@ import kotlinx.coroutines.withTimeout
*/
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
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 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)
@@ -88,16 +175,22 @@ 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)) {
- 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) {
@@ -151,24 +244,33 @@ 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 <= policy.maxAttempts) {
+ config.metrics?.recordAttempt(nextAttemptIndex, sleepMs)
+ config.onAttempt(nextAttemptIndex, sleepMs)
+ }
+
delay(minOf(sleepMs, remainingBeforeSleep))
}
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