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 + +[![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 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