├── .github ├── secrets │ ├── 8257B447.gpg.gpg │ └── local.properties.gpg ├── scripts │ └── decrypt_secret.sh └── workflows │ ├── build_publish.yml │ └── build_only.yml ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── iosApp ├── iosApp │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── Info.plist │ └── iosApp.swift ├── Podfile ├── iosApp.xcodeproj │ ├── project.xcworkspace │ │ └── contents.xcworkspacedata │ └── project.pbxproj ├── iosApp.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── Podfile.lock ├── sampleApp ├── src │ ├── androidMain │ │ ├── resources │ │ │ └── values │ │ │ │ ├── strings.xml │ │ │ │ └── themes.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── softartdev │ │ │ │ └── kronos │ │ │ │ └── sample │ │ │ │ ├── Platform.kt │ │ │ │ └── App.android.kt │ │ └── AndroidManifest.xml │ ├── commonMain │ │ └── kotlin │ │ │ └── com │ │ │ └── softartdev │ │ │ └── kronos │ │ │ └── sample │ │ │ ├── Platform.kt │ │ │ ├── Greeting.kt │ │ │ ├── App.kt │ │ │ ├── AppTheme.kt │ │ │ └── TickScreen.kt │ ├── desktopMain │ │ └── kotlin │ │ │ ├── com │ │ │ └── softartdev │ │ │ │ └── kronos │ │ │ │ └── sample │ │ │ │ ├── Platform.kt │ │ │ │ └── App.jvm.kt │ │ │ └── main.kt │ └── iosMain │ │ └── kotlin │ │ ├── com │ │ └── softartdev │ │ │ └── kronos │ │ │ └── sample │ │ │ ├── Platform.kt │ │ │ └── App.ios.kt │ │ └── main.kt ├── sampleApp.podspec └── build.gradle.kts ├── .gitignore ├── kronos ├── src │ ├── commonMain │ │ └── kotlin │ │ │ └── com │ │ │ └── softartdev │ │ │ └── kronos │ │ │ ├── ClockExt.kt │ │ │ └── NetworkClock.kt │ ├── jvmMain │ │ └── kotlin │ │ │ └── com │ │ │ └── softartdev │ │ │ └── kronos │ │ │ ├── JvmSystemClock.kt │ │ │ ├── Network.kt │ │ │ ├── JvmNetworkClock.kt │ │ │ ├── JvmClockFactory.kt │ │ │ └── JvmPreferenceSyncResponseCache.kt │ ├── commonTest │ │ └── kotlin │ │ │ └── com │ │ │ └── softartdev │ │ │ └── kronos │ │ │ └── CommonTest.kt │ ├── iosMain │ │ └── kotlin │ │ │ └── com │ │ │ └── softartdev │ │ │ └── kronos │ │ │ ├── Network.kt │ │ │ └── IosNetworkClock.kt │ ├── androidMain │ │ └── kotlin │ │ │ └── com │ │ │ └── softartdev │ │ │ └── kronos │ │ │ ├── Network.kt │ │ │ └── AndroidNetworkClock.kt │ ├── jvmTest │ │ └── kotlin │ │ │ └── com │ │ │ └── softartdev │ │ │ └── kronos │ │ │ └── PlatformTest.kt │ ├── androidUnitTest │ │ └── kotlin │ │ │ └── com │ │ │ └── softartdev │ │ │ └── kronos │ │ │ └── PlatformTest.kt │ └── iosTest │ │ └── kotlin │ │ └── com │ │ └── softartdev │ │ └── kronos │ │ └── PlatformTest.kt ├── native │ └── Kronos │ │ ├── Kronos.swift │ │ ├── NSTimer+ClosureKit.swift │ │ ├── TimeStorage.swift │ │ ├── TimeFreeze.swift │ │ ├── DNSResolver.swift │ │ ├── Data+Bytes.swift │ │ ├── Clock.swift │ │ ├── InternetAddress.swift │ │ ├── NTPProtocol.swift │ │ ├── NTPClient.swift │ │ └── NTPPacket.swift └── build.gradle.kts ├── convention-plugins ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── convention.publication.gradle.kts ├── settings.gradle.kts ├── gradle.properties ├── gradle.bat ├── README.MD └── gradlew /.github/secrets/8257B447.gpg.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/softartdev/Kronos-Multiplatform/HEAD/.github/secrets/8257B447.gpg.gpg -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/softartdev/Kronos-Multiplatform/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/secrets/local.properties.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/softartdev/Kronos-Multiplatform/HEAD/.github/secrets/local.properties.gpg -------------------------------------------------------------------------------- /iosApp/Podfile: -------------------------------------------------------------------------------- 1 | target 'iosApp' do 2 | use_frameworks! 3 | platform :ios, '14.1' 4 | pod 'sampleApp', :path => '../sampleApp' 5 | end 6 | -------------------------------------------------------------------------------- /iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /sampleApp/src/androidMain/resources/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Kronos Multiplatform 4 | -------------------------------------------------------------------------------- /sampleApp/src/commonMain/kotlin/com/softartdev/kronos/sample/Platform.kt: -------------------------------------------------------------------------------- 1 | package com.softartdev.kronos.sample 2 | 3 | interface Platform { 4 | val name: String 5 | } 6 | 7 | expect fun getPlatform(): Platform -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /8257B447.gpg 5 | /.idea 6 | .DS_Store 7 | build 8 | */build 9 | captures 10 | .externalNativeBuild 11 | .cxx 12 | xcuserdata/ 13 | Pods/ 14 | *.jks 15 | *yarn.lock 16 | /.kotlin 17 | -------------------------------------------------------------------------------- /kronos/src/commonMain/kotlin/com/softartdev/kronos/ClockExt.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package com.softartdev.kronos 4 | 5 | import kotlin.time.Clock 6 | import kotlin.time.ExperimentalTime 7 | 8 | expect val Clock.Companion.Network: NetworkClock -------------------------------------------------------------------------------- /sampleApp/src/commonMain/kotlin/com/softartdev/kronos/sample/Greeting.kt: -------------------------------------------------------------------------------- 1 | package com.softartdev.kronos.sample 2 | 3 | class Greeting { 4 | private val platform: Platform = getPlatform() 5 | 6 | fun greet(): String { 7 | return "Hello, ${platform.name}!" 8 | } 9 | } -------------------------------------------------------------------------------- /sampleApp/src/desktopMain/kotlin/com/softartdev/kronos/sample/Platform.kt: -------------------------------------------------------------------------------- 1 | package com.softartdev.kronos.sample 2 | 3 | class JvmPlatform : Platform { 4 | override val name: String = "JVM ${System.getProperty("java.version")}" 5 | } 6 | 7 | actual fun getPlatform(): Platform = JvmPlatform() -------------------------------------------------------------------------------- /iosApp/iosApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CADisableMinimumFrameDurationOnPhone 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /sampleApp/src/androidMain/kotlin/com/softartdev/kronos/sample/Platform.kt: -------------------------------------------------------------------------------- 1 | package com.softartdev.kronos.sample 2 | 3 | class AndroidPlatform : Platform { 4 | override val name: String = "Android ${android.os.Build.VERSION.SDK_INT}" 5 | } 6 | 7 | actual fun getPlatform(): Platform = AndroidPlatform() -------------------------------------------------------------------------------- /iosApp/iosApp.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 27 19:10:37 GET 2025 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-all.zip 5 | networkTimeout=10000 6 | validateDistributionUrl=true 7 | zipStoreBase=GRADLE_USER_HOME 8 | zipStorePath=wrapper/dists 9 | -------------------------------------------------------------------------------- /sampleApp/src/androidMain/resources/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | -------------------------------------------------------------------------------- /sampleApp/src/iosMain/kotlin/com/softartdev/kronos/sample/Platform.kt: -------------------------------------------------------------------------------- 1 | package com.softartdev.kronos.sample 2 | 3 | import platform.UIKit.UIDevice 4 | 5 | class IOSPlatform: Platform { 6 | override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion 7 | } 8 | 9 | actual fun getPlatform(): Platform = IOSPlatform() -------------------------------------------------------------------------------- /iosApp/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - sampleApp (1.0.0) 3 | 4 | DEPENDENCIES: 5 | - sampleApp (from `../sampleApp`) 6 | 7 | EXTERNAL SOURCES: 8 | sampleApp: 9 | :path: "../sampleApp" 10 | 11 | SPEC CHECKSUMS: 12 | sampleApp: 74ffcffb09857e525c843f48fcd5511e65e94800 13 | 14 | PODFILE CHECKSUM: 31aecb1c889393f7cb5f9aac6f63f896eb049a8e 15 | 16 | COCOAPODS: 1.12.1 17 | -------------------------------------------------------------------------------- /kronos/src/jvmMain/kotlin/com/softartdev/kronos/JvmSystemClock.kt: -------------------------------------------------------------------------------- 1 | package com.softartdev.kronos 2 | 3 | import com.lyft.kronos.Clock 4 | 5 | class JvmSystemClock : Clock { 6 | override fun getCurrentTimeMs(): Long = System.currentTimeMillis() 7 | 8 | // TODO: use SystemClock.elapsedRealtime() 9 | override fun getElapsedTimeMs(): Long = System.currentTimeMillis() 10 | } -------------------------------------------------------------------------------- /convention-plugins/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` // Is needed to turn our build logic written in Kotlin into the Gradle Plugin 3 | } 4 | 5 | repositories { 6 | gradlePluginPortal() // To use 'maven-publish' and 'signing' plugins in our own plugin 7 | } 8 | 9 | dependencies { 10 | implementation("io.codearte.gradle.nexus:gradle-nexus-staging-plugin:0.30.0") 11 | } -------------------------------------------------------------------------------- /kronos/src/commonTest/kotlin/com/softartdev/kronos/CommonTest.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package com.softartdev.kronos 4 | 5 | import kotlin.test.Test 6 | import kotlin.test.assertNull 7 | import kotlin.time.Clock 8 | import kotlin.time.ExperimentalTime 9 | 10 | class CommonTest { 11 | 12 | @Test 13 | fun getCurrentNtpTimeMsTest() = assertNull(actual = Clock.Network.getCurrentNtpTimeMs()) 14 | } -------------------------------------------------------------------------------- /sampleApp/src/commonMain/kotlin/com/softartdev/kronos/sample/App.kt: -------------------------------------------------------------------------------- 1 | package com.softartdev.kronos.sample 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | @Composable 6 | internal fun App() = AppTheme { 7 | TickScreen() 8 | } 9 | 10 | internal expect fun openUrl(url: String?) 11 | 12 | internal expect fun clickSync() 13 | 14 | internal expect fun clickBlockingSync() 15 | 16 | internal expect suspend fun clickAwaitSync() -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "Kronos Multiplatform" 2 | includeBuild("convention-plugins") 3 | include(":kronos") 4 | include(":sampleApp") 5 | 6 | pluginManagement { 7 | repositories { 8 | google() 9 | gradlePluginPortal() 10 | mavenCentral() 11 | } 12 | } 13 | 14 | dependencyResolutionManagement { 15 | repositories { 16 | google() 17 | mavenCentral() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/scripts/decrypt_secret.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Decrypt the file 4 | # --batch to prevent interactive command --yes to assume "yes" for questions 5 | 6 | gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" \ 7 | --output ./8257B447.gpg ./.github/secrets/8257B447.gpg.gpg 8 | 9 | gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" \ 10 | --output ./local.properties ./.github/secrets/local.properties.gpg 11 | -------------------------------------------------------------------------------- /kronos/src/jvmMain/kotlin/com/softartdev/kronos/Network.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package com.softartdev.kronos 4 | 5 | import kotlin.time.Clock 6 | import kotlin.time.ExperimentalTime 7 | 8 | actual val Clock.Companion.Network: NetworkClock 9 | get() = JvmNetworkClock 10 | 11 | fun NetworkClock.sync() = JvmNetworkClock.sync() 12 | fun NetworkClock.blockingSync() = JvmNetworkClock.blockingSync() 13 | suspend fun NetworkClock.awaitSyncSync(): Boolean = JvmNetworkClock.awaitSync() -------------------------------------------------------------------------------- /sampleApp/src/iosMain/kotlin/main.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.ui.window.ComposeUIViewController 2 | import com.softartdev.kronos.IosNetworkClock 3 | import com.softartdev.kronos.sample.App 4 | import io.github.aakira.napier.DebugAntilog 5 | import io.github.aakira.napier.Napier 6 | import platform.UIKit.UIViewController 7 | 8 | fun appInit() { 9 | Napier.base(DebugAntilog()) 10 | IosNetworkClock.sync() 11 | } 12 | 13 | fun MainViewController(): UIViewController { 14 | return ComposeUIViewController { App() } 15 | } 16 | -------------------------------------------------------------------------------- /kronos/src/iosMain/kotlin/com/softartdev/kronos/Network.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package com.softartdev.kronos 4 | 5 | import platform.Foundation.NSDate 6 | import kotlin.time.Clock 7 | import kotlin.time.ExperimentalTime 8 | 9 | actual val Clock.Companion.Network: NetworkClock 10 | get() = IosNetworkClock 11 | 12 | fun NetworkClock.sync() = IosNetworkClock.sync() 13 | fun NetworkClock.blockingSync() = IosNetworkClock.blockingSync() 14 | suspend fun NetworkClock.awaitSync(): NSDate? = IosNetworkClock.awaitSync() -------------------------------------------------------------------------------- /kronos/src/androidMain/kotlin/com/softartdev/kronos/Network.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package com.softartdev.kronos 4 | 5 | import android.content.Context 6 | import kotlin.time.Clock 7 | import kotlin.time.ExperimentalTime 8 | 9 | actual val Clock.Companion.Network: NetworkClock 10 | get() = AndroidNetworkClock 11 | 12 | fun NetworkClock.sync(context: Context) = AndroidNetworkClock.sync(context) 13 | fun NetworkClock.blockingSync(context: Context) = AndroidNetworkClock.blockingSync(context) 14 | suspend fun NetworkClock.awaitSync(ctx: Context): Boolean = AndroidNetworkClock.awaitSync(ctx) -------------------------------------------------------------------------------- /kronos/src/jvmTest/kotlin/com/softartdev/kronos/PlatformTest.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package com.softartdev.kronos 4 | 5 | import org.junit.Ignore 6 | import kotlin.test.Test 7 | import kotlin.test.assertNotNull 8 | import kotlin.test.assertNull 9 | import kotlin.test.assertTrue 10 | import kotlin.time.Clock 11 | import kotlin.time.ExperimentalTime 12 | 13 | class PlatformTest { 14 | 15 | @Ignore // TODO: fix on CI 16 | @Test 17 | fun getCurrentNtpTimeMsTest() { 18 | assertNull(Clock.Network.getCurrentNtpTimeMs()) 19 | assertTrue(Clock.Network.blockingSync()) 20 | assertNotNull(Clock.Network.getCurrentNtpTimeMs()) 21 | } 22 | } -------------------------------------------------------------------------------- /iosApp/iosApp/iosApp.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import ComposeApp 4 | 5 | @main 6 | struct iosApp: App { 7 | 8 | init() { 9 | MainKt.appInit() 10 | } 11 | 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | 19 | struct ContentView: View { 20 | var body: some View { 21 | ComposeView().ignoresSafeArea(.keyboard) 22 | } 23 | } 24 | 25 | struct ComposeView: UIViewControllerRepresentable { 26 | func makeUIViewController(context: Context) -> UIViewController { 27 | MainKt.MainViewController() 28 | } 29 | 30 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} 31 | } 32 | -------------------------------------------------------------------------------- /kronos/src/androidUnitTest/kotlin/com/softartdev/kronos/PlatformTest.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package com.softartdev.kronos 4 | 5 | import android.content.Context 6 | import androidx.test.platform.app.InstrumentationRegistry 7 | import kotlin.test.* 8 | import kotlin.time.Clock 9 | import kotlin.time.ExperimentalTime 10 | 11 | class PlatformTest { 12 | 13 | @Ignore // TODO: fix 14 | @Test 15 | fun getCurrentNtpTimeMsTest() { 16 | assertNull(Clock.Network.getCurrentNtpTimeMs()) 17 | val appContext: Context = InstrumentationRegistry.getInstrumentation().targetContext 18 | assertTrue(Clock.Network.blockingSync(context = appContext)) 19 | assertNotNull(Clock.Network.getCurrentNtpTimeMs()) 20 | } 21 | } -------------------------------------------------------------------------------- /sampleApp/src/iosMain/kotlin/com/softartdev/kronos/sample/App.ios.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package com.softartdev.kronos.sample 4 | 5 | import com.softartdev.kronos.* 6 | import platform.Foundation.NSURL 7 | import platform.UIKit.UIApplication 8 | import kotlin.time.Clock 9 | import kotlin.time.ExperimentalTime 10 | 11 | internal actual fun openUrl(url: String?) { 12 | val nsUrl = url?.let { NSURL.URLWithString(it) } ?: return 13 | UIApplication.sharedApplication.openURL(nsUrl) 14 | } 15 | 16 | internal actual fun clickSync() = Clock.Network.sync() 17 | 18 | internal actual fun clickBlockingSync() { 19 | Clock.Network.blockingSync() 20 | } 21 | 22 | internal actual suspend fun clickAwaitSync() { 23 | Clock.Network.awaitSync() 24 | } -------------------------------------------------------------------------------- /kronos/src/commonMain/kotlin/com/softartdev/kronos/NetworkClock.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package com.softartdev.kronos 4 | 5 | import kotlin.time.Clock 6 | import kotlin.time.ExperimentalTime 7 | import kotlin.time.Instant 8 | 9 | interface NetworkClock : Clock { 10 | /** 11 | * @return the current time in milliseconds, or null if no ntp sync has occurred. 12 | */ 13 | fun getCurrentNtpTimeMs(): Long? 14 | 15 | /** 16 | * Returns the [Instant] corresponding to the current time, according to this clock. 17 | */ 18 | override fun now(): Instant { 19 | val currentNtpTimeMs = getCurrentNtpTimeMs() 20 | requireNotNull(currentNtpTimeMs) { "No ntp sync has occurred" } 21 | return Instant.fromEpochMilliseconds(currentNtpTimeMs) 22 | } 23 | } -------------------------------------------------------------------------------- /sampleApp/src/desktopMain/kotlin/main.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | import androidx.compose.ui.unit.dp 4 | import androidx.compose.ui.window.Window 5 | import androidx.compose.ui.window.application 6 | import androidx.compose.ui.window.rememberWindowState 7 | import com.softartdev.kronos.Network 8 | import com.softartdev.kronos.sample.App 9 | import com.softartdev.kronos.sync 10 | import io.github.aakira.napier.DebugAntilog 11 | import io.github.aakira.napier.Napier 12 | import kotlin.time.Clock 13 | import kotlin.time.ExperimentalTime 14 | 15 | fun main() { 16 | Napier.base(DebugAntilog()) 17 | 18 | Clock.Network.sync() 19 | 20 | application { 21 | Window( 22 | title = "Kronos Multiplatform", 23 | state = rememberWindowState(width = 600.dp, height = 400.dp), 24 | onCloseRequest = ::exitApplication, 25 | ) { App() } 26 | } 27 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | #Gradle 2 | org.gradle.jvmargs=-Xmx16g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx16g" -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g 3 | org.gradle.parallel=true 4 | org.gradle.daemon=true 5 | org.gradle.configureondemand=true 6 | org.gradle.caching=true 7 | 8 | #Kotlin 9 | kotlin.code.style=official 10 | kotlin.experimental.tryK2=true 11 | 12 | #MPP 13 | kotlin.mpp.enableCInteropCommonization=true 14 | kotlin.mpp.androidSourceSetLayoutVersion=2 15 | kotlin.mpp.applyDefaultHierarchyTemplate=false 16 | 17 | #Compose 18 | org.jetbrains.compose.experimental.uikit.enabled=true 19 | kotlin.native.cacheKind=none 20 | 21 | #Android 22 | android.useAndroidX=true 23 | android.nonTransitiveRClass=true 24 | android.nonFinalResIds=true 25 | android.buildFeatures.buildConfig=true 26 | 27 | #gradle-nexus-staging-plugin 28 | gnsp.disableApplyOnlyOnRootProjectEnforcement=true -------------------------------------------------------------------------------- /sampleApp/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /sampleApp/src/desktopMain/kotlin/com/softartdev/kronos/sample/App.jvm.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package com.softartdev.kronos.sample 4 | 5 | import androidx.compose.desktop.ui.tooling.preview.Preview 6 | import androidx.compose.runtime.Composable 7 | import com.softartdev.kronos.JvmNetworkClock 8 | import com.softartdev.kronos.Network 9 | import com.softartdev.kronos.sync 10 | import java.awt.Desktop 11 | import java.net.URI 12 | import kotlin.time.Clock 13 | import kotlin.time.ExperimentalTime 14 | 15 | internal actual fun openUrl(url: String?) { 16 | val uri = url?.let { URI.create(it) } ?: return 17 | Desktop.getDesktop().browse(uri) 18 | } 19 | 20 | internal actual fun clickSync() = Clock.Network.sync() 21 | 22 | internal actual fun clickBlockingSync() { 23 | JvmNetworkClock.blockingSync() 24 | } 25 | 26 | internal actual suspend fun clickAwaitSync() { 27 | JvmNetworkClock.awaitSync() 28 | } 29 | 30 | @Preview 31 | @Composable 32 | fun AppPreview() = App() -------------------------------------------------------------------------------- /.github/workflows/build_publish.yml: -------------------------------------------------------------------------------- 1 | name: Build & Publish CI/CD 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build_unit_test_publish_job: 10 | name: Build -> Unit-test -> Publish 11 | runs-on: macos-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up JDK 17 15 | uses: actions/setup-java@v4 16 | with: 17 | distribution: 'oracle' 18 | java-version: 17 19 | - name: Setup Gradle 20 | uses: gradle/actions/setup-gradle@v4 21 | with: 22 | cache-read-only: false 23 | cache-overwrite-existing: true 24 | - name: Decrypt large secret 25 | run: ./.github/scripts/decrypt_secret.sh 26 | env: 27 | LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} 28 | - name: Build 29 | run: ./gradlew build 30 | - name: Publish 31 | run: ./gradlew publishAllPublicationsToSonatypeRepository --max-workers 1 closeAndReleaseRepository 32 | -------------------------------------------------------------------------------- /kronos/src/jvmMain/kotlin/com/softartdev/kronos/JvmNetworkClock.kt: -------------------------------------------------------------------------------- 1 | package com.softartdev.kronos 2 | 3 | import com.lyft.kronos.KronosClock 4 | import kotlin.coroutines.resume 5 | import kotlin.coroutines.resumeWithException 6 | import kotlin.coroutines.suspendCoroutine 7 | 8 | object JvmNetworkClock : NetworkClock { 9 | private val lazyProp: Lazy = lazy(initializer = JvmClockFactory::createKronosClock) 10 | private val kronosClock: KronosClock by lazyProp 11 | 12 | fun sync() = kronosClock.syncInBackground() 13 | 14 | fun blockingSync(): Boolean = kronosClock.sync() 15 | 16 | suspend fun awaitSync(): Boolean = suspendCoroutine { continuation -> 17 | try { 18 | val result = kronosClock.sync() 19 | continuation.resume(result) 20 | } catch (e: Exception) { 21 | continuation.resumeWithException(e) 22 | } 23 | } 24 | 25 | override fun getCurrentNtpTimeMs(): Long? = when { 26 | lazyProp.isInitialized() -> kronosClock.getCurrentTimeMs() 27 | else -> null 28 | } 29 | } -------------------------------------------------------------------------------- /.github/workflows/build_only.yml: -------------------------------------------------------------------------------- 1 | name: Kotlin Multiplatform CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | tags-ignore: 8 | - '**' 9 | pull_request: 10 | 11 | jobs: 12 | build_job: 13 | name: Build (Unit-tests & Android-Lint) 14 | runs-on: macos-latest 15 | continue-on-error: true 16 | steps: 17 | - name: Check out 18 | uses: actions/checkout@v4 19 | - name: Gradle Wrapper Validation 20 | uses: gradle/wrapper-validation-action@v1 21 | - name: Set up JDK 17 22 | uses: actions/setup-java@v4 23 | with: 24 | distribution: 'oracle' 25 | java-version: 17 26 | - name: Setup Gradle 27 | uses: gradle/actions/setup-gradle@v4 28 | with: 29 | cache-read-only: false 30 | cache-overwrite-existing: true 31 | - name: Decrypt large secret 32 | run: ./.github/scripts/decrypt_secret.sh 33 | env: 34 | LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} 35 | - name: Build 36 | run: ./gradlew build 37 | -------------------------------------------------------------------------------- /kronos/src/iosMain/kotlin/com/softartdev/kronos/IosNetworkClock.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalForeignApi::class) 2 | 3 | package com.softartdev.kronos 4 | 5 | import kotlinx.cinterop.ExperimentalForeignApi 6 | import platform.Foundation.NSDate 7 | import platform.Foundation.NSNumber 8 | import platform.Foundation.timeIntervalSince1970 9 | import kotlin.coroutines.Continuation 10 | import kotlin.coroutines.resume 11 | import kotlin.coroutines.resumeWithException 12 | import kotlin.coroutines.suspendCoroutine 13 | 14 | object IosNetworkClock : NetworkClock { 15 | 16 | fun sync() = Kronos.sync() 17 | 18 | fun blockingSync() = Kronos.blockingSync() 19 | 20 | suspend fun awaitSync(): NSDate? = suspendCoroutine { continuation: Continuation -> 21 | try { 22 | Kronos.syncWithCallback { nsDate: NSDate?, _: NSNumber? -> continuation.resume(nsDate) } 23 | } catch (t: Throwable) { 24 | continuation.resumeWithException(t) 25 | } 26 | } 27 | 28 | override fun getCurrentNtpTimeMs(): Long? { 29 | val nsDate: NSDate = Kronos.now() ?: return null 30 | return nsDate.timeIntervalSince1970.toLong() * 1000 31 | } 32 | } -------------------------------------------------------------------------------- /kronos/src/jvmMain/kotlin/com/softartdev/kronos/JvmClockFactory.kt: -------------------------------------------------------------------------------- 1 | package com.softartdev.kronos 2 | 3 | import com.lyft.kronos.* 4 | 5 | object JvmClockFactory { 6 | 7 | @JvmStatic 8 | fun createDeviceClock(): Clock = JvmSystemClock() 9 | 10 | @JvmStatic 11 | @JvmOverloads 12 | fun createKronosClock( 13 | syncListener: SyncListener? = null, 14 | ntpHosts: List = DefaultParam.NTP_HOSTS, 15 | requestTimeoutMs: Long = DefaultParam.TIMEOUT_MS, 16 | minWaitTimeBetweenSyncMs: Long = DefaultParam.MIN_WAIT_TIME_BETWEEN_SYNC_MS, 17 | cacheExpirationMs: Long = DefaultParam.CACHE_EXPIRATION_MS, 18 | maxNtpResponseTimeMs: Long = DefaultParam.MAX_NTP_RESPONSE_TIME_MS 19 | ): KronosClock = ClockFactory.createKronosClock( 20 | localClock = createDeviceClock(), 21 | syncResponseCache = JvmPreferenceSyncResponseCache, 22 | syncListener = syncListener, 23 | ntpHosts = ntpHosts, 24 | requestTimeoutMs = requestTimeoutMs, 25 | minWaitTimeBetweenSyncMs = minWaitTimeBetweenSyncMs, 26 | cacheExpirationMs = cacheExpirationMs, 27 | maxNtpResponseTimeMs = maxNtpResponseTimeMs 28 | ) 29 | } -------------------------------------------------------------------------------- /sampleApp/src/commonMain/kotlin/com/softartdev/kronos/sample/AppTheme.kt: -------------------------------------------------------------------------------- 1 | package com.softartdev.kronos.sample 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.Surface 6 | import androidx.compose.material.darkColors 7 | import androidx.compose.material.lightColors 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.graphics.Color 10 | 11 | private val Orange = Color(0xfff8873c) 12 | private val Purple = Color(0xff6b70fc) 13 | private val LightColors = lightColors( 14 | primary = Orange, 15 | primaryVariant = Orange, 16 | onPrimary = Color.White, 17 | secondary = Purple, 18 | onSecondary = Color.White 19 | ) 20 | private val DarkColors = darkColors( 21 | primary = Orange, 22 | primaryVariant = Orange, 23 | onPrimary = Color.White, 24 | secondary = Purple, 25 | onSecondary = Color.White 26 | ) 27 | 28 | @Composable 29 | internal fun AppTheme( 30 | content: @Composable () -> Unit 31 | ) { 32 | MaterialTheme( 33 | colors = if (isSystemInDarkTheme()) DarkColors else LightColors, 34 | content = { 35 | Surface(content = content) 36 | } 37 | ) 38 | } -------------------------------------------------------------------------------- /kronos/src/iosTest/kotlin/com/softartdev/kronos/PlatformTest.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package com.softartdev.kronos 4 | 5 | import kotlinx.coroutines.TimeoutCancellationException 6 | import kotlinx.coroutines.test.runTest 7 | import kotlinx.coroutines.withTimeout 8 | import kotlin.test.* 9 | import kotlin.time.Clock 10 | import kotlin.time.ExperimentalTime 11 | 12 | class PlatformTest { 13 | 14 | @Ignore // TODO: fix 15 | @Test 16 | fun getCurrentNtpTimeMsTest() = runTest { 17 | assertNull(Clock.Network.getCurrentNtpTimeMs()) 18 | assertFailsWith { 19 | withTimeout(timeMillis = 20_000) { 20 | Clock.Network.awaitSync() 21 | } 22 | } 23 | assertNotNull(Clock.Network.getCurrentNtpTimeMs()) 24 | } 25 | 26 | @Ignore 27 | @Test 28 | fun awaitSyncTest() = runTest { 29 | val currentNtpTimeMs = Clock.Network.getCurrentNtpTimeMs() 30 | println("⏺️ before sync: $currentNtpTimeMs") 31 | assertNull(currentNtpTimeMs) 32 | 33 | Clock.Network.awaitSync() 34 | 35 | val instance = Clock.Network.now() 36 | println("⏺️ after sync: $instance") 37 | assertNotNull(instance) 38 | } 39 | } -------------------------------------------------------------------------------- /kronos/src/jvmMain/kotlin/com/softartdev/kronos/JvmPreferenceSyncResponseCache.kt: -------------------------------------------------------------------------------- 1 | package com.softartdev.kronos 2 | 3 | import com.lyft.kronos.SyncResponseCache 4 | import com.lyft.kronos.internal.Constants.TIME_UNAVAILABLE 5 | import java.util.prefs.Preferences 6 | 7 | object JvmPreferenceSyncResponseCache : SyncResponseCache { 8 | private const val KEY_CURRENT_TIME = "com.lyft.kronos.cached_current_time" 9 | private const val KEY_ELAPSED_TIME = "com.lyft.kronos.cached_elapsed_time" 10 | private const val KEY_OFFSET = "com.lyft.kronos.cached_offset" 11 | 12 | private val preferences: Preferences = Preferences.userNodeForPackage(JvmPreferenceSyncResponseCache::class.java) 13 | 14 | override var currentTime: Long 15 | get() = preferences.getLong(KEY_CURRENT_TIME, TIME_UNAVAILABLE) 16 | set(value) = preferences.putLong(KEY_CURRENT_TIME, value) 17 | override var elapsedTime: Long 18 | get() = preferences.getLong(KEY_ELAPSED_TIME, TIME_UNAVAILABLE) 19 | set(value) = preferences.putLong(KEY_ELAPSED_TIME, value) 20 | override var currentOffset: Long 21 | get() = preferences.getLong(KEY_OFFSET, TIME_UNAVAILABLE) 22 | set(value) = preferences.putLong(KEY_OFFSET, value) 23 | 24 | override fun clear() = preferences.clear() 25 | } -------------------------------------------------------------------------------- /kronos/src/androidMain/kotlin/com/softartdev/kronos/AndroidNetworkClock.kt: -------------------------------------------------------------------------------- 1 | package com.softartdev.kronos 2 | 3 | import android.content.Context 4 | import com.lyft.kronos.AndroidClockFactory 5 | import com.lyft.kronos.KronosClock 6 | import kotlin.coroutines.resume 7 | import kotlin.coroutines.resumeWithException 8 | import kotlin.coroutines.suspendCoroutine 9 | 10 | object AndroidNetworkClock : NetworkClock { 11 | lateinit var kronosClock: KronosClock 12 | 13 | fun sync(applicationContext: Context) { 14 | initClock(applicationContext) 15 | kronosClock.syncInBackground() 16 | } 17 | 18 | fun blockingSync(applicationContext: Context): Boolean { 19 | initClock(applicationContext) 20 | return kronosClock.sync() 21 | } 22 | 23 | suspend fun awaitSync(applicationContext: Context): Boolean = suspendCoroutine { continuation -> 24 | initClock(applicationContext) 25 | try { 26 | val result = kronosClock.sync() 27 | continuation.resume(result) 28 | } catch (e: Exception) { 29 | continuation.resumeWithException(e) 30 | } 31 | } 32 | 33 | private fun initClock(applicationContext: Context) { 34 | if (::kronosClock.isInitialized) return 35 | kronosClock = AndroidClockFactory.createKronosClock(applicationContext) 36 | } 37 | 38 | override fun getCurrentNtpTimeMs(): Long? = when { 39 | ::kronosClock.isInitialized -> kronosClock.getCurrentTimeMs() 40 | else -> null 41 | } 42 | } -------------------------------------------------------------------------------- /sampleApp/src/androidMain/kotlin/com/softartdev/kronos/sample/App.android.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package com.softartdev.kronos.sample 4 | 5 | import android.app.Application 6 | import android.content.Intent 7 | import android.net.Uri 8 | import android.os.Bundle 9 | import androidx.activity.ComponentActivity 10 | import androidx.activity.compose.setContent 11 | import androidx.activity.enableEdgeToEdge 12 | import com.softartdev.kronos.* 13 | import io.github.aakira.napier.DebugAntilog 14 | import io.github.aakira.napier.Napier 15 | import kotlin.time.Clock 16 | import kotlin.time.ExperimentalTime 17 | 18 | class AndroidApp : Application() { 19 | companion object { 20 | lateinit var INSTANCE: AndroidApp 21 | } 22 | 23 | override fun onCreate() { 24 | super.onCreate() 25 | INSTANCE = this 26 | Napier.base(DebugAntilog()) 27 | Clock.Network.sync(applicationContext) 28 | } 29 | } 30 | 31 | class AppActivity : ComponentActivity() { 32 | override fun onCreate(savedInstanceState: Bundle?) { 33 | super.onCreate(savedInstanceState) 34 | enableEdgeToEdge() 35 | setContent { App() } 36 | } 37 | } 38 | 39 | internal actual fun openUrl(url: String?) { 40 | val uri: Uri = url?.let(Uri::parse) ?: return 41 | val intent: Intent = Intent().apply { 42 | action = Intent.ACTION_VIEW 43 | data = uri 44 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 45 | } 46 | AndroidApp.INSTANCE.startActivity(intent) 47 | } 48 | 49 | internal actual fun clickSync() = Clock.Network.sync(context = AndroidApp.INSTANCE) 50 | 51 | internal actual fun clickBlockingSync() { 52 | Clock.Network.blockingSync(context = AndroidApp.INSTANCE) 53 | } 54 | 55 | internal actual suspend fun clickAwaitSync() { 56 | Clock.Network.awaitSync(ctx = AndroidApp.INSTANCE) 57 | } -------------------------------------------------------------------------------- /kronos/native/Kronos/Kronos.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @objc public class Kronos : NSObject { 4 | 5 | @objc(sync) public class func sync() { 6 | let firstCallback: ((Date, TimeInterval) -> Void)? = { (date, offset) in 7 | print("➡️ Kronos: first sync: \(date) offset: \(offset)") 8 | } 9 | let completionCallback: ((Date?, TimeInterval?) -> Void)? = { (date, offset) in 10 | print("⬅️ Kronos: completion sync: \(date) offset: \(offset)") 11 | } 12 | Clock.sync(first: firstCallback, completion: completionCallback) 13 | } 14 | 15 | @objc(now) public class func now() -> Date? { 16 | return Clock.now 17 | } 18 | 19 | @objc(blockingSync) public class func blockingSync() { 20 | let semaphore = DispatchSemaphore(value: 0) 21 | let firstCallback: ((Date, TimeInterval) -> Void)? = { (date, offset) in 22 | print("➡️ Kronos: first sync: \(date) offset: \(offset)") 23 | } 24 | let completionCallback: ((Date?, TimeInterval?) -> Void)? = { (date, offset) in 25 | print("⬅️ Kronos: completion sync: \(date) offset: \(offset)") 26 | semaphore.signal() 27 | } 28 | Clock.sync(first: firstCallback, completion: completionCallback) 29 | semaphore.wait() 30 | } 31 | 32 | @objc public class func sync(callback: @escaping (Date?, NSNumber?) -> Void) { 33 | let firstCallback: ((Date, TimeInterval) -> Void)? = { (date, offset) in 34 | print("➡️ Kronos: first sync: \(date) offset: \(offset)") 35 | } 36 | let completionCallback: ((Date?, TimeInterval?) -> Void)? = { (date, offset) in 37 | print("⬅️ Kronos: completion sync: \(date) offset: \(offset)") 38 | let intervalNumber = offset.map(NSNumber.init(value:)) as NSNumber? 39 | callback(date, intervalNumber) 40 | } 41 | Clock.sync(first: firstCallback, completion: completionCallback) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /kronos/native/Kronos/NSTimer+ClosureKit.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | typealias CKTimerHandler = (Timer) -> Void 4 | 5 | /// Simple closure implementation on NSTimer scheduling. 6 | /// 7 | /// Example: 8 | /// 9 | /// ```swift 10 | /// BlockTimer.scheduledTimer(withTimeInterval: 1.0) { timer in 11 | /// print("Did something after 1s!") 12 | /// } 13 | /// ``` 14 | final class BlockTimer: NSObject { 15 | 16 | /// Creates and returns a block-based NSTimer object and schedules it on the current run loop. 17 | /// 18 | /// - parameter interval: The number of seconds between firings of the timer. 19 | /// - parameter repeated: If true, the timer will repeatedly reschedule itself until invalidated. If 20 | /// false, the timer will be invalidated after it fires. 21 | /// - parameter handler: The closure that the NSTimer fires. 22 | /// 23 | /// - returns: A new NSTimer object, configured according to the specified parameters. 24 | class func scheduledTimer(withTimeInterval interval: TimeInterval, repeated: Bool = false, 25 | handler: @escaping CKTimerHandler) -> Timer 26 | { 27 | return Timer.scheduledTimer(timeInterval: interval, target: self, 28 | selector: #selector(BlockTimer.invokeFrom(timer:)), 29 | userInfo: TimerClosureWrapper(handler: handler, repeats: repeated), repeats: repeated) 30 | } 31 | 32 | // MARK: Private methods 33 | 34 | @objc 35 | class private func invokeFrom(timer: Timer) { 36 | if let closureWrapper = timer.userInfo as? TimerClosureWrapper { 37 | closureWrapper.handler(timer) 38 | } 39 | } 40 | } 41 | 42 | // MARK: - Private classes 43 | 44 | private final class TimerClosureWrapper { 45 | fileprivate var handler: CKTimerHandler 46 | private var repeats: Bool 47 | 48 | init(handler: @escaping CKTimerHandler, repeats: Bool) { 49 | self.handler = handler 50 | self.repeats = repeats 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | kronos = "0.0.2" 3 | compileSdk = "36" 4 | targetSdk = "36" 5 | minSdk = "21" 6 | lyft-kronos = "0.0.1-alpha11" 7 | kotlin = "2.2.0" 8 | jdk = "17" 9 | agp = "8.11.0" 10 | compose = "1.8.2" 11 | androidx-appcompat = "1.7.1" 12 | androidx-activityCompose = "1.10.1" 13 | androidx-test = "1.6.1" 14 | compose-uitooling = "1.8.3" 15 | napier = "2.7.1" 16 | kotlinx-coroutines = "1.10.2" 17 | swift-klib = "0.6.4" 18 | 19 | [libraries] 20 | kronos = { module = "io.github.softartdev:kronos", version.ref = "kronos" } 21 | lyft-kronos-android = { module = "com.lyft.kronos:kronos-android", version.ref = "lyft-kronos" } 22 | lyft-kronos-java = { module = "com.lyft.kronos:kronos-java", version.ref = "lyft-kronos" } 23 | androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } 24 | androidx-activityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } 25 | androidx-test = { module = "androidx.test:core", version.ref = "androidx-test" } 26 | compose-uitooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose-uitooling" } 27 | napier = { module = "io.github.aakira:napier", version.ref = "napier" } 28 | kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } 29 | kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } 30 | kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } 31 | 32 | [plugins] 33 | multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 34 | cocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" } 35 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 36 | compose = { id = "org.jetbrains.compose", version.ref = "compose" } 37 | android-application = { id = "com.android.application", version.ref = "agp" } 38 | android-library = { id = "com.android.library", version.ref = "agp" } 39 | swift-klib = { id = "io.github.ttypic.swiftklib", version.ref = "swift-klib" } 40 | -------------------------------------------------------------------------------- /kronos/native/Kronos/TimeStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Defines where the user defaults are stored 4 | public enum TimeStoragePolicy { 5 | /// Uses `UserDefaults.Standard` 6 | case standard 7 | /// Attempts to use the specified App Group ID (which is the String) to access shared storage. 8 | case appGroup(String) 9 | 10 | /// Creates an instance 11 | /// 12 | /// - parameter appGroupID: The App Group ID that maps to a shared container for `UserDefaults`. If this 13 | /// is nil, the resulting instance will be `.standard` 14 | public init(appGroupID: String?) { 15 | if let appGroupID = appGroupID { 16 | self = .appGroup(appGroupID) 17 | } else { 18 | self = .standard 19 | } 20 | } 21 | } 22 | 23 | /// Handles saving and retrieving instances of `TimeFreeze` for quick retrieval 24 | public struct TimeStorage { 25 | private var userDefaults: UserDefaults 26 | private let kDefaultsKey = "KronosStableTime" 27 | 28 | /// The most recent stored `TimeFreeze`. Getting retrieves from the UserDefaults defined by the storage 29 | /// policy. Setting sets the value in UserDefaults 30 | var stableTime: TimeFreeze? { 31 | get { 32 | guard let stored = self.userDefaults.value(forKey: kDefaultsKey) as? [String: TimeInterval], 33 | let previousStableTime = TimeFreeze(from: stored) else 34 | { 35 | return nil 36 | } 37 | 38 | return previousStableTime 39 | } 40 | 41 | set { 42 | guard let newFreeze = newValue else { 43 | return 44 | } 45 | 46 | self.userDefaults.set(newFreeze.toDictionary(), forKey: kDefaultsKey) 47 | } 48 | } 49 | 50 | /// Creates an instance 51 | /// 52 | /// - parameter storagePolicy: Defines the storage location of `UserDefaults` 53 | public init(storagePolicy: TimeStoragePolicy) { 54 | switch storagePolicy { 55 | case .standard: 56 | self.userDefaults = .standard 57 | case .appGroup(let groupName): 58 | let sharedDefaults = UserDefaults(suiteName: groupName) 59 | assert(sharedDefaults != nil, "Could not create UserDefaults for group: '\(groupName)'") 60 | self.userDefaults = sharedDefaults ?? .standard 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /sampleApp/sampleApp.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = 'sampleApp' 3 | spec.version = '1.0.0' 4 | spec.homepage = 'empty' 5 | spec.source = { :http=> ''} 6 | spec.authors = '' 7 | spec.license = '' 8 | spec.summary = 'Compose application framework' 9 | spec.vendored_frameworks = 'build/cocoapods/framework/ComposeApp.framework' 10 | spec.libraries = 'c++' 11 | spec.ios.deployment_target = '14.1' 12 | 13 | 14 | if !Dir.exist?('build/cocoapods/framework/ComposeApp.framework') || Dir.empty?('build/cocoapods/framework/ComposeApp.framework') 15 | raise " 16 | 17 | Kotlin framework 'ComposeApp' doesn't exist yet, so a proper Xcode project can't be generated. 18 | 'pod install' should be executed after running ':generateDummyFramework' Gradle task: 19 | 20 | ./gradlew :sampleApp:generateDummyFramework 21 | 22 | Alternatively, proper pod installation is performed during Gradle sync in the IDE (if Podfile location is set)" 23 | end 24 | 25 | spec.xcconfig = { 26 | 'ENABLE_USER_SCRIPT_SANDBOXING' => 'NO', 27 | } 28 | 29 | spec.pod_target_xcconfig = { 30 | 'KOTLIN_PROJECT_PATH' => ':sampleApp', 31 | 'PRODUCT_MODULE_NAME' => 'ComposeApp', 32 | } 33 | 34 | spec.script_phases = [ 35 | { 36 | :name => 'Build sampleApp', 37 | :execution_position => :before_compile, 38 | :shell_path => '/bin/sh', 39 | :script => <<-SCRIPT 40 | if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then 41 | echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\"" 42 | exit 0 43 | fi 44 | set -ev 45 | REPO_ROOT="$PODS_TARGET_SRCROOT" 46 | "$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \ 47 | -Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \ 48 | -Pkotlin.native.cocoapods.archs="$ARCHS" \ 49 | -Pkotlin.native.cocoapods.configuration="$CONFIGURATION" 50 | SCRIPT 51 | } 52 | ] 53 | spec.resources = ['build/compose/cocoapods/compose-resources'] 54 | end -------------------------------------------------------------------------------- /gradle.bat: -------------------------------------------------------------------------------- 1 | 2 | @rem 3 | @rem Copyright 2015 the original author or authors. 4 | @rem 5 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 6 | @rem you may not use this file except in compliance with the License. 7 | @rem You may obtain a copy of the License at 8 | @rem 9 | @rem https://www.apache.org/licenses/LICENSE-2.0 10 | @rem 11 | @rem Unless required by applicable law or agreed to in writing, software 12 | @rem distributed under the License is distributed on an "AS IS" BASIS, 13 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | @rem See the License for the specific language governing permissions and 15 | @rem limitations under the License. 16 | @rem 17 | 18 | @if "%DEBUG%" == "" @echo off 19 | @rem ########################################################################## 20 | @rem 21 | @rem Gradle startup script for Windows 22 | @rem 23 | @rem ########################################################################## 24 | 25 | @rem Set local scope for the variables with windows NT shell 26 | if "%OS%"=="Windows_NT" setlocal 27 | 28 | set DIRNAME=%~dp0 29 | if "%DIRNAME%" == "" set DIRNAME=. 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if "%ERRORLEVEL%" == "0" goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /kronos/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.multiplatform) 3 | alias(libs.plugins.android.library) 4 | alias(libs.plugins.swift.klib) 5 | id("convention.publication") 6 | } 7 | group = "io.github.softartdev" 8 | version = libs.versions.kronos.get() 9 | 10 | kotlin { 11 | jvmToolchain(libs.versions.jdk.get().toInt()) 12 | jvm() 13 | androidTarget { 14 | publishLibraryVariants("release", "debug") 15 | } 16 | listOf( 17 | iosX64(), 18 | iosArm64(), 19 | iosSimulatorArm64(), 20 | ).forEach { 21 | it.compilations { 22 | val main by getting { 23 | cinterops { 24 | create("KronosMultiplatform") 25 | } 26 | } 27 | } 28 | } 29 | sourceSets { 30 | val commonMain by getting { 31 | dependencies { 32 | } 33 | } 34 | val commonTest by getting { 35 | dependencies { 36 | implementation(kotlin("test")) 37 | implementation(libs.kotlinx.coroutines.test) 38 | } 39 | } 40 | val jvmMain by getting { 41 | dependencies { 42 | api(libs.lyft.kronos.java) 43 | } 44 | } 45 | val jvmTest by getting 46 | val androidMain by getting { 47 | dependencies { 48 | api(libs.lyft.kronos.android) 49 | } 50 | } 51 | val androidUnitTest by getting { 52 | dependencies { 53 | implementation(libs.androidx.test) 54 | } 55 | } 56 | val iosX64Main by getting 57 | val iosArm64Main by getting 58 | val iosSimulatorArm64Main by getting 59 | val iosMain by creating { 60 | dependsOn(commonMain) 61 | iosX64Main.dependsOn(this) 62 | iosArm64Main.dependsOn(this) 63 | iosSimulatorArm64Main.dependsOn(this) 64 | } 65 | val iosX64Test by getting 66 | val iosArm64Test by getting 67 | val iosSimulatorArm64Test by getting 68 | val iosTest by creating { 69 | dependsOn(commonTest) 70 | iosX64Test.dependsOn(this) 71 | iosArm64Test.dependsOn(this) 72 | iosSimulatorArm64Test.dependsOn(this) 73 | } 74 | } 75 | } 76 | 77 | android { 78 | namespace = "com.softartdev.kronos" 79 | compileSdk = libs.versions.compileSdk.get().toInt() 80 | defaultConfig { 81 | minSdk = libs.versions.minSdk.get().toInt() 82 | } 83 | compileOptions { 84 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jdk.get().toInt()) 85 | targetCompatibility = JavaVersion.toVersion(libs.versions.jdk.get().toInt()) 86 | } 87 | } 88 | 89 | swiftklib { 90 | create("KronosMultiplatform") { 91 | path = file("native/Kronos") 92 | packageName("com.softartdev.kronos") 93 | } 94 | } 95 | 96 | tasks.withType().configureEach { 97 | dependsOn(tasks.withType()) 98 | } -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # Kronos Multiplatform Library 2 | 3 | ![Maven Central](https://img.shields.io/maven-central/v/io.github.softartdev/kronos) 4 | [![Build & Publish CI/CD](https://github.com/softartdev/Kronos-Multiplatform/actions/workflows/build_publish.yml/badge.svg)](https://github.com/softartdev/Kronos-Multiplatform/actions/workflows/build_publish.yml) 5 | 6 | Kotlin Multiplatform library for network time synchronization. It extends the [`kotlin.time`](https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.time/) API and supports the following platforms: 7 | - Android 8 | - iOS 9 | - Desktop JVM (MacOS, Linux, Windows) 10 | ## Usage 11 | ### `kotlin.time` Extension 12 | The library [extends the main `Clock` interface](https://github.com/softartdev/Kronos-Multiplatform/blob/main/kronos/src/commonMain/kotlin/com/softartdev/kronos/ClockExt.kt) from the standard library. You can use the [`Clock.Network`](https://github.com/softartdev/Kronos-Multiplatform/blob/main/kronos/src/commonMain/kotlin/com/softartdev/kronos/NetworkClock.kt) class to retrieve the current network time, similar to using the built-in [`Clock.System`](https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/src/kotlin/time/Clock.kt#L60) instance. 13 | ```kotlin 14 | val networkTime: Instant = Clock.Network.now() // 2025-06-30T13:42:43.712Z 15 | val systemTime: Instant = Clock.System.now() // 2025-06-30T13:42:43.566455Z 16 | val diff: Duration = networkTime - systemTime // 145.545ms 17 | ``` 18 | ### Synchronization 19 | When running the application, it's necessary to synchronize the time with the network using the platform-specific code: 20 | - Android: 21 | ```kotlin 22 | class App : Application() { 23 | override fun onCreate() { 24 | super.onCreate() 25 | Clock.Network.sync(applicationContext) 26 | } 27 | } 28 | ``` 29 | - iOS: 30 | ```swift 31 | @main 32 | struct iosApp: App { 33 | init() { 34 | Clock.Network.sync() 35 | } 36 | var body: some Scene { ... } 37 | } 38 | ``` 39 | - Desktop JVM: 40 | ```kotlin 41 | fun main() { 42 | Clock.Network.sync() 43 | ... 44 | } 45 | ``` 46 | ### Installation 47 | The latest release is available on [Maven Central](https://repo1.maven.org/maven2/io/github/softartdev/kronos/). 48 | 1. Add the Maven Central repository if it is not already included: 49 | ```kotlin 50 | repositories { 51 | mavenCentral() 52 | } 53 | ``` 54 | 2. In multiplatform projects, add the following dependency to the `commonMain` source set dependencies: 55 | ```kotlin 56 | commonMain { 57 | dependencies { 58 | implementation("io.github.softartdev:kronos:$latestVersion") 59 | } 60 | } 61 | ``` 62 | ## Implementation 63 | The main common interface is implemented using the following: 64 | - [lyft/Kronos](https://github.com/lyft/Kronos-Android) for Java & Android 65 | - [MobileNativeFoundation/Kronos](https://github.com/MobileNativeFoundation/Kronos) for iOS 66 | 67 | The project is built and tested using the following: 68 | - [Swift Klib Gradle Plugin](https://github.com/ttypic/swift-klib-plugin) for including Swift source files in KMM shared module 69 | - [Compose Multiplatform, by JetBrains](https://github.com/JetBrains/compose-jb) for UI samples 70 | -------------------------------------------------------------------------------- /kronos/native/Kronos/TimeFreeze.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private let kUptimeKey = "Uptime" 4 | private let kTimestampKey = "Timestamp" 5 | private let kOffsetKey = "Offset" 6 | 7 | struct TimeFreeze { 8 | private let uptime: TimeInterval 9 | private let timestamp: TimeInterval 10 | private let offset: TimeInterval 11 | 12 | /// The stable timestamp adjusted by the most accurate offset known so far. 13 | var adjustedTimestamp: TimeInterval { 14 | return self.offset + self.stableTimestamp 15 | } 16 | 17 | /// The stable timestamp (calculated based on the uptime); note that this doesn't have sub-seconds 18 | /// precision. See `systemUptime()` for more information. 19 | var stableTimestamp: TimeInterval { 20 | return (TimeFreeze.systemUptime() - self.uptime) + self.timestamp 21 | } 22 | 23 | /// Time interval between now and the time the NTP response represented by this TimeFreeze was received. 24 | var timeSinceLastNtpSync: TimeInterval { 25 | return TimeFreeze.systemUptime() - uptime 26 | } 27 | 28 | init(offset: TimeInterval) { 29 | self.offset = offset 30 | self.timestamp = currentTime() 31 | self.uptime = TimeFreeze.systemUptime() 32 | } 33 | 34 | init?(from dictionary: [String: TimeInterval]) { 35 | guard let uptime = dictionary[kUptimeKey], let timestamp = dictionary[kTimestampKey], 36 | let offset = dictionary[kOffsetKey] else 37 | { 38 | return nil 39 | } 40 | 41 | let currentUptime = TimeFreeze.systemUptime() 42 | let currentTimestamp = currentTime() 43 | let currentBoot = currentUptime - currentTimestamp 44 | let previousBoot = uptime - timestamp 45 | if rint(currentBoot) - rint(previousBoot) != 0 { 46 | return nil 47 | } 48 | 49 | self.uptime = uptime 50 | self.timestamp = timestamp 51 | self.offset = offset 52 | } 53 | 54 | /// Convert this TimeFreeze to a dictionary representation. 55 | /// 56 | /// - returns: A dictionary representation. 57 | func toDictionary() -> [String: TimeInterval] { 58 | return [ 59 | kUptimeKey: self.uptime, 60 | kTimestampKey: self.timestamp, 61 | kOffsetKey: self.offset, 62 | ] 63 | } 64 | 65 | /// Returns a high-resolution measurement of system uptime, that continues ticking through device sleep 66 | /// *and* user- or system-generated clock adjustments. This allows for stable differences to be calculated 67 | /// between timestamps. 68 | /// 69 | /// Note: Due to an issue in BSD/darwin, sub-second precision will be lost; 70 | /// see: https://github.com/darwin-on-arm/xnu/blob/master/osfmk/kern/clock.c#L522. 71 | /// 72 | /// - returns: An Int measurement of system uptime in microseconds. 73 | static func systemUptime() -> TimeInterval { 74 | var mib = [CTL_KERN, KERN_BOOTTIME] 75 | var size = MemoryLayout.stride 76 | var bootTime = timeval() 77 | 78 | let bootTimeError = sysctl(&mib, u_int(mib.count), &bootTime, &size, nil, 0) != 0 79 | assert(!bootTimeError, "system clock error: kernel boot time unavailable") 80 | 81 | let now = currentTime() 82 | let uptime = Double(bootTime.tv_sec) + Double(bootTime.tv_usec) / 1_000_000 83 | assert(now >= uptime, "inconsistent clock state: system time precedes boot time") 84 | 85 | return now - uptime 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /kronos/native/Kronos/DNSResolver.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private let kCopyNoOperation = unsafeBitCast(0, to: CFAllocatorCopyDescriptionCallBack.self) 4 | private let kDefaultTimeout = 8.0 5 | 6 | final class DNSResolver { 7 | private var completion: (([InternetAddress]) -> Void)? 8 | private var timer: Timer? 9 | 10 | private init() {} 11 | 12 | /// Performs DNS lookups and calls the given completion with the answers that are returned from the name 13 | /// server(s) that were queried. 14 | /// 15 | /// - parameter host: The host to be looked up. 16 | /// - parameter timeout: The connection timeout. 17 | /// - parameter completion: A completion block that will be called both on failure and success with a list 18 | /// of IPs. 19 | static func resolve(host: String, timeout: TimeInterval = kDefaultTimeout, 20 | completion: @escaping ([InternetAddress]) -> Void) 21 | { 22 | let callback: CFHostClientCallBack = { host, _, _, info in 23 | guard let info = info else { 24 | return 25 | } 26 | let retainedSelf = Unmanaged.fromOpaque(info) 27 | let resolver = retainedSelf.takeUnretainedValue() 28 | resolver.timer?.invalidate() 29 | resolver.timer = nil 30 | 31 | var resolved: DarwinBoolean = false 32 | guard let addresses = CFHostGetAddressing(host, &resolved), resolved.boolValue else { 33 | resolver.completion?([]) 34 | retainedSelf.release() 35 | return 36 | } 37 | 38 | let IPs = (addresses.takeUnretainedValue() as NSArray) 39 | .compactMap { $0 as? NSData } 40 | .compactMap(InternetAddress.init) 41 | 42 | resolver.completion?(IPs) 43 | retainedSelf.release() 44 | } 45 | 46 | let resolver = DNSResolver() 47 | resolver.completion = completion 48 | 49 | let retainedClosure = Unmanaged.passRetained(resolver).toOpaque() 50 | var clientContext = CFHostClientContext(version: 0, info: UnsafeMutableRawPointer(retainedClosure), 51 | retain: nil, release: nil, copyDescription: kCopyNoOperation) 52 | 53 | let hostReference = CFHostCreateWithName(kCFAllocatorDefault, host as CFString).takeUnretainedValue() 54 | resolver.timer = Timer.scheduledTimer(timeInterval: timeout, target: resolver, 55 | selector: #selector(DNSResolver.onTimeout), 56 | userInfo: hostReference, repeats: false) 57 | 58 | CFHostSetClient(hostReference, callback, &clientContext) 59 | CFHostScheduleWithRunLoop(hostReference, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) 60 | CFHostStartInfoResolution(hostReference, .addresses, nil) 61 | } 62 | 63 | @objc 64 | private func onTimeout() { 65 | defer { 66 | self.completion?([]) 67 | 68 | // Manually release the previously retained self. 69 | Unmanaged.passUnretained(self).release() 70 | } 71 | 72 | guard let userInfo = self.timer?.userInfo else { 73 | return 74 | } 75 | 76 | let hostReference = unsafeBitCast(userInfo as AnyObject, to: CFHost.self) 77 | CFHostCancelInfoResolution(hostReference, .addresses) 78 | CFHostUnscheduleFromRunLoop(hostReference, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) 79 | CFHostSetClient(hostReference, nil, nil) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /convention-plugins/src/main/kotlin/convention.publication.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.gradle.api.publish.maven.MavenPublication 2 | import org.gradle.api.tasks.bundling.Jar 3 | import org.gradle.kotlin.dsl.`maven-publish` 4 | import org.gradle.kotlin.dsl.signing 5 | import java.util.* 6 | 7 | plugins { 8 | `maven-publish` 9 | signing 10 | id("io.codearte.nexus-staging") 11 | } 12 | // Stub secrets to let the project sync and build without the publication values set up 13 | ext["signing.keyId"] = null 14 | ext["signing.password"] = null 15 | ext["signing.secretKeyRingFile"] = null 16 | ext["ossrhUsername"] = null 17 | ext["ossrhPassword"] = null 18 | 19 | // Grabbing secrets from local.properties file or from environment variables, which could be used on CI 20 | val secretPropsFile = project.rootProject.file("local.properties") 21 | if (secretPropsFile.exists()) { 22 | secretPropsFile.reader().use { 23 | Properties().apply { 24 | load(it) 25 | } 26 | }.onEach { (name, value) -> 27 | ext[name.toString()] = value 28 | } 29 | } else { 30 | ext["signing.keyId"] = System.getenv("SIGNING_KEY_ID") 31 | ext["signing.password"] = System.getenv("SIGNING_PASSWORD") 32 | ext["signing.secretKeyRingFile"] = System.getenv("SIGNING_SECRET_KEY_RING_FILE") 33 | ext["ossrhUsername"] = System.getenv("OSSRH_USERNAME") 34 | ext["ossrhPassword"] = System.getenv("OSSRH_PASSWORD") 35 | } 36 | ext["signing.secretKeyRingFile"] = "../${ext["signing.secretKeyRingFile"]}" // path from module 37 | val javadocJar by tasks.registering(Jar::class) { 38 | archiveClassifier.set("javadoc") 39 | } 40 | fun getExtraString(name: String) = ext[name]?.toString() 41 | 42 | publishing { 43 | // Configure maven central repository 44 | repositories { 45 | maven { 46 | name = "sonatype" 47 | setUrl("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") 48 | credentials { 49 | username = getExtraString("ossrhUsername") 50 | password = getExtraString("ossrhPassword") 51 | } 52 | } 53 | } 54 | // Configure all publications 55 | publications.withType { 56 | // Stub javadoc.jar artifact 57 | artifact(javadocJar.get()) 58 | // Provide artifacts information requited by Maven Central 59 | pom { 60 | name.set("Kronos Multiplatform Library") 61 | description.set("Kotlin Multiplatform SNTP library") 62 | url.set("https://github.com/softartdev/Kronos-Multiplatform") 63 | 64 | licenses { 65 | license { 66 | name.set("MIT") 67 | url.set("https://opensource.org/licenses/MIT") 68 | } 69 | } 70 | developers { 71 | developer { 72 | id.set("softartdev") 73 | name.set("Artur Babichev") 74 | email.set("artik222012@gmail.com") 75 | } 76 | } 77 | scm { 78 | url.set("https://github.com/softartdev/Kronos-Multiplatform") 79 | } 80 | } 81 | } 82 | } 83 | 84 | // Signing artifacts. Signing.* extra properties values will be used 85 | signing { 86 | sign(publishing.publications) 87 | } 88 | 89 | nexusStaging { 90 | serverUrl = "https://ossrh-staging-api.central.sonatype.com/service/local/" 91 | packageGroup = "io.github.softartdev" 92 | stagingProfileId = getExtraString("sonatypeStagingProfileId") 93 | username = getExtraString("ossrhUsername") 94 | password = getExtraString("ossrhPassword") 95 | } -------------------------------------------------------------------------------- /kronos/native/Kronos/Data+Bytes.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Data { 4 | 5 | /// Creates an Data instance based on a hex string (example: "ffff" would be ). 6 | /// 7 | /// - parameter hex: The hex string without any spaces; should only have [0-9A-Fa-f]. 8 | init?(hex: String) { 9 | if hex.count % 2 != 0 { 10 | return nil 11 | } 12 | 13 | let hexArray = Array(hex) 14 | var bytes: [UInt8] = [] 15 | 16 | for index in stride(from: 0, to: hexArray.count, by: 2) { 17 | guard let byte = UInt8("\(hexArray[index])\(hexArray[index + 1])", radix: 16) else { 18 | return nil 19 | } 20 | 21 | bytes.append(byte) 22 | } 23 | 24 | self.init(bytes: bytes, count: bytes.count) 25 | } 26 | 27 | /// Gets one byte from the given index. 28 | /// 29 | /// - parameter index: The index of the byte to be retrieved. Note that this should never be >= length. 30 | /// 31 | /// - returns: The byte located at position `index`. 32 | func getByte(at index: Int) -> Int8 { 33 | let data: Int8 = self.subdata(in: index ..< (index + 1)).withUnsafeBytes { rawPointer in 34 | rawPointer.bindMemory(to: Int8.self).baseAddress!.pointee 35 | } 36 | 37 | return data 38 | } 39 | 40 | /// Gets an unsigned int (32 bits => 4 bytes) from the given index. 41 | /// 42 | /// - parameter index: The index of the uint to be retrieved. Note that this should never be >= length - 43 | /// 3. 44 | /// 45 | /// - returns: The unsigned int located at position `index`. 46 | func getUnsignedInteger(at index: Int, bigEndian: Bool = true) -> UInt32 { 47 | let data: UInt32 = self.subdata(in: index ..< (index + 4)).withUnsafeBytes { rawPointer in 48 | rawPointer.bindMemory(to: UInt32.self).baseAddress!.pointee 49 | } 50 | 51 | return bigEndian ? data.bigEndian : data.littleEndian 52 | } 53 | 54 | /// Gets an unsigned long integer (64 bits => 8 bytes) from the given index. 55 | /// 56 | /// - parameter index: The index of the ulong to be retrieved. Note that this should never be >= length - 57 | /// 7. 58 | /// 59 | /// - returns: The unsigned long integer located at position `index`. 60 | func getUnsignedLong(at index: Int, bigEndian: Bool = true) -> UInt64 { 61 | let data: UInt64 = self.subdata(in: index ..< (index + 8)).withUnsafeBytes { rawPointer in 62 | rawPointer.bindMemory(to: UInt64.self).baseAddress!.pointee 63 | } 64 | 65 | return bigEndian ? data.bigEndian : data.littleEndian 66 | } 67 | 68 | /// Appends the given byte (8 bits) into the receiver Data. 69 | /// 70 | /// - parameter data: The byte to be appended. 71 | mutating func append(byte data: Int8) { 72 | var data = data 73 | self.append(Data(bytes: &data, count: MemoryLayout.size)) 74 | } 75 | 76 | /// Appends the given unsigned integer (32 bits; 4 bytes) into the receiver Data. 77 | /// 78 | /// - parameter data: The unsigned integer to be appended. 79 | mutating func append(unsignedInteger data: UInt32, bigEndian: Bool = true) { 80 | var data = bigEndian ? data.bigEndian : data.littleEndian 81 | self.append(Data(bytes: &data, count: MemoryLayout.size)) 82 | } 83 | 84 | /// Appends the given unsigned long (64 bits; 8 bytes) into the receiver Data. 85 | /// 86 | /// - parameter data: The unsigned long to be appended. 87 | mutating func append(unsignedLong data: UInt64, bigEndian: Bool = true) { 88 | var data = bigEndian ? data.bigEndian : data.littleEndian 89 | self.append(Data(bytes: &data, count: MemoryLayout.size)) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /sampleApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | import org.jetbrains.compose.ExperimentalComposeLibrary 4 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat 5 | 6 | plugins { 7 | alias(libs.plugins.multiplatform) 8 | alias(libs.plugins.compose.compiler) 9 | alias(libs.plugins.compose) 10 | alias(libs.plugins.cocoapods) 11 | alias(libs.plugins.android.application) 12 | } 13 | group = "com.softartdev.kronos.sample" 14 | 15 | kotlin { 16 | jvmToolchain(libs.versions.jdk.get().toInt()) 17 | jvm("desktop") 18 | androidTarget() 19 | iosX64() 20 | iosArm64() 21 | iosSimulatorArm64() 22 | 23 | cocoapods { 24 | summary = "Compose application framework" 25 | homepage = "empty" 26 | version = "1.0.0" 27 | ios.deploymentTarget = "14.1" 28 | podfile = project.file("../iosApp/Podfile") 29 | framework { 30 | baseName = "ComposeApp" 31 | isStatic = true 32 | } 33 | } 34 | 35 | sourceSets { 36 | val commonMain by getting { 37 | dependencies { 38 | implementation(project(":kronos")) 39 | // implementation(libs.kronos) 40 | implementation(compose.runtime) 41 | implementation(compose.foundation) 42 | implementation(compose.material) 43 | @OptIn(ExperimentalComposeLibrary::class) 44 | implementation(compose.components.resources) 45 | implementation(libs.napier) 46 | implementation(libs.kotlinx.coroutines.core) 47 | } 48 | } 49 | val androidMain by getting { 50 | dependencies { 51 | implementation(libs.androidx.appcompat) 52 | implementation(libs.androidx.activityCompose) 53 | implementation(libs.compose.uitooling) 54 | implementation(libs.kotlinx.coroutines.android) 55 | } 56 | } 57 | val androidUnitTest by getting 58 | val desktopMain by getting { 59 | dependencies { 60 | implementation(compose.desktop.common) 61 | implementation(compose.desktop.currentOs) 62 | implementation(compose.preview) 63 | } 64 | } 65 | val iosX64Main by getting 66 | val iosArm64Main by getting 67 | val iosSimulatorArm64Main by getting 68 | val iosMain by creating { 69 | dependsOn(commonMain) 70 | iosX64Main.dependsOn(this) 71 | iosArm64Main.dependsOn(this) 72 | iosSimulatorArm64Main.dependsOn(this) 73 | dependencies { 74 | } 75 | } 76 | } 77 | } 78 | 79 | android { 80 | namespace = "com.softartdev.kronos.sample" 81 | compileSdk = libs.versions.compileSdk.get().toInt() 82 | 83 | defaultConfig { 84 | minSdk = libs.versions.minSdk.get().toInt() 85 | targetSdk = libs.versions.targetSdk.get().toInt() 86 | 87 | applicationId = "com.softartdev.kronos.sample" 88 | versionCode = 1 89 | versionName = "1.0.0" 90 | } 91 | sourceSets["main"].apply { 92 | manifest.srcFile("src/androidMain/AndroidManifest.xml") 93 | res.srcDirs("src/androidMain/resources") 94 | resources.srcDirs("src/commonMain/resources") 95 | } 96 | kotlin { 97 | jvmToolchain(libs.versions.jdk.get().toInt()) 98 | } 99 | compileOptions { 100 | sourceCompatibility = JavaVersion.toVersion(libs.versions.jdk.get().toInt()) 101 | targetCompatibility = JavaVersion.toVersion(libs.versions.jdk.get().toInt()) 102 | } 103 | packaging { 104 | resources.excludes.add("META-INF/**") 105 | } 106 | } 107 | 108 | compose.desktop { 109 | application { 110 | mainClass = "MainKt" 111 | 112 | nativeDistributions { 113 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) 114 | packageName = "com.softartdev.kronos.sample" 115 | packageVersion = "1.0.0" 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /kronos/native/Kronos/Clock.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Struct that has time + related metadata 4 | public typealias AnnotatedTime = ( 5 | 6 | /// Time that is being annotated 7 | date: Date, 8 | 9 | /// Amount of time that has passed since the last NTP sync; in other words, the NTP response age. 10 | timeSinceLastNtpSync: TimeInterval 11 | ) 12 | 13 | /// High level implementation for clock synchronization using NTP. All returned dates use the most accurate 14 | /// synchronization and it's not affected by clock changes. The NTP synchronization implementation has sub- 15 | /// second accuracy but given that Darwin doesn't support microseconds on bootTime, dates don't have sub- 16 | /// second accuracy. 17 | /// 18 | /// Example usage: 19 | /// 20 | /// ```swift 21 | /// Clock.sync { date, offset in 22 | /// print(date) 23 | /// } 24 | /// // (... later on ...) 25 | /// print(Clock.now) 26 | /// ``` 27 | public struct Clock { 28 | private static var stableTime: TimeFreeze? { 29 | didSet { 30 | self.storage.stableTime = self.stableTime 31 | } 32 | } 33 | 34 | /// Determines where the most current stable time is stored. Use TimeStoragePolicy.appGroup to share 35 | /// between your app and an extension. 36 | public static var storage = TimeStorage(storagePolicy: .standard) 37 | 38 | /// The most accurate timestamp that we have so far (nil if no synchronization was done yet) 39 | public static var timestamp: TimeInterval? { 40 | return self.stableTime?.adjustedTimestamp 41 | } 42 | 43 | /// The most accurate date that we have so far (nil if no synchronization was done yet) 44 | public static var now: Date? { 45 | return self.annotatedNow?.date 46 | } 47 | 48 | /// Same as `now` except with analytic metadata about the time 49 | public static var annotatedNow: AnnotatedTime? { 50 | guard let stableTime = self.stableTime else { 51 | return nil 52 | } 53 | 54 | return AnnotatedTime(date: Date(timeIntervalSince1970: stableTime.adjustedTimestamp), 55 | timeSinceLastNtpSync: stableTime.timeSinceLastNtpSync) 56 | } 57 | 58 | /// Syncs the clock using NTP. Note that the full synchronization could take a few seconds. The given 59 | /// closure will be called with the first valid NTP response which accuracy should be good enough for the 60 | /// initial clock adjustment but it might not be the most accurate representation. After calling the 61 | /// closure this method will continue syncing with multiple servers and multiple passes. 62 | /// 63 | /// - parameter pool: NTP pool that will be resolved into multiple NTP servers that will be used for 64 | /// the synchronization. 65 | /// - parameter samples: The number of samples to be acquired from each server (default 4). 66 | /// - parameter completion: A closure that will be called after _all_ the NTP calls are finished. 67 | /// - parameter first: A closure that will be called after the first valid date is calculated. 68 | public static func sync(from pool: String = "time.apple.com", samples: Int = 4, 69 | first: ((Date, TimeInterval) -> Void)? = nil, 70 | completion: ((Date?, TimeInterval?) -> Void)? = nil) 71 | { 72 | self.loadFromDefaults() 73 | 74 | NTPClient().query(pool: pool, numberOfSamples: samples) { offset, done, total in 75 | if let offset = offset { 76 | self.stableTime = TimeFreeze(offset: offset) 77 | 78 | if done == 1, let now = self.now { 79 | first?(now, offset) 80 | } 81 | } 82 | 83 | if done == total { 84 | completion?(self.now, offset) 85 | } 86 | } 87 | } 88 | 89 | /// Resets all state of the monotonic clock. Note that you won't be able to access `now` until you `sync` 90 | /// again. 91 | public static func reset() { 92 | self.stableTime = nil 93 | } 94 | 95 | private static func loadFromDefaults() { 96 | guard let previousStableTime = self.storage.stableTime else { 97 | self.stableTime = nil 98 | return 99 | } 100 | self.stableTime = previousStableTime 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /kronos/native/Kronos/InternetAddress.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// This enum represents an internet address that can either be IPv4 or IPv6. 4 | /// 5 | /// - IPv6: An Internet Address of type IPv6 (e.g.: '::1'). 6 | /// - IPv4: An Internet Address of type IPv4 (e.g.: '127.0.0.1'). 7 | enum InternetAddress: Hashable { 8 | case ipv6(sockaddr_in6) 9 | case ipv4(sockaddr_in) 10 | 11 | /// Human readable host represetnation (e.g. '192.168.1.1' or 'ab:ab:ab:ab:ab:ab:ab:ab'). 12 | var host: String? { 13 | switch self { 14 | case .ipv6(var address): 15 | var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) 16 | inet_ntop(AF_INET6, &address.sin6_addr, &buffer, socklen_t(INET6_ADDRSTRLEN)) 17 | return String(cString: buffer) 18 | 19 | case .ipv4(var address): 20 | var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) 21 | inet_ntop(AF_INET, &address.sin_addr, &buffer, socklen_t(INET_ADDRSTRLEN)) 22 | return String(cString: buffer) 23 | } 24 | } 25 | 26 | /// The protocol family that should be used on the socket creation for this address. 27 | var family: Int32 { 28 | switch self { 29 | case .ipv4: 30 | return PF_INET 31 | 32 | case .ipv6: 33 | return PF_INET6 34 | } 35 | } 36 | 37 | func hash(into hasher: inout Hasher) { 38 | hasher.combine(self.host) 39 | } 40 | 41 | init?(dataWithSockAddress data: NSData) { 42 | let storage = sockaddr_storage.from(unsafeDataWithSockAddress: data) 43 | switch Int32(storage.ss_family) { 44 | case AF_INET: 45 | self = storage.withUnsafeAddress { InternetAddress.ipv4($0.pointee) } 46 | 47 | case AF_INET6: 48 | self = storage.withUnsafeAddress { InternetAddress.ipv6($0.pointee) } 49 | 50 | default: 51 | return nil 52 | } 53 | } 54 | 55 | /// Returns the address struct (either sockaddr_in or sockaddr_in6) represented as an CFData. 56 | /// 57 | /// - parameter port: The port number to associate on the address struct. 58 | /// 59 | /// - returns: An address struct wrapped into a CFData type. 60 | func addressData(withPort port: Int) -> CFData { 61 | switch self { 62 | case .ipv6(var address): 63 | address.sin6_port = in_port_t(port).bigEndian 64 | return Data(bytes: &address, count: MemoryLayout.size) as CFData 65 | 66 | case .ipv4(var address): 67 | address.sin_port = in_port_t(port).bigEndian 68 | return Data(bytes: &address, count: MemoryLayout.size) as CFData 69 | } 70 | } 71 | } 72 | 73 | /// Compare InternetAddress(es) by making sure the host representation are equal. 74 | func == (lhs: InternetAddress, rhs: InternetAddress) -> Bool { 75 | return lhs.host == rhs.host 76 | } 77 | 78 | // MARK: - sockaddr_storage helpers 79 | 80 | extension sockaddr_storage { 81 | /// Creates a new storage value from a data type that contains the memory layout of a sockaddr_t. This 82 | /// is used to create sockaddr_storage(s) from some of the CF C functions such as `CFHostGetAddressing`. 83 | /// 84 | /// !!! WARNING: This method is unsafe and assumes the memory layout is of `sockaddr_t`. !!! 85 | /// 86 | /// - parameter data: The data to be interpreted as sockaddr 87 | /// - returns: The newly created sockaddr_storage value 88 | fileprivate static func from(unsafeDataWithSockAddress data: NSData) -> sockaddr_storage { 89 | var storage = sockaddr_storage() 90 | data.getBytes(&storage, length: data.length) 91 | return storage 92 | } 93 | 94 | /// Calls a closure with traditional BSD Sockets address parameters. 95 | /// 96 | /// - parameter body: A closure to call with `self` referenced appropriately for calling 97 | /// BSD Sockets APIs that take an address. 98 | /// 99 | /// - throws: Any error thrown by `body`. 100 | /// 101 | /// - returns: Any result returned by `body`. 102 | fileprivate func withUnsafeAddress(_ body: (_ address: UnsafePointer) -> T) -> T { 103 | var storage = self 104 | return withUnsafePointer(to: &storage) { 105 | $0.withMemoryRebound(to: U.self, capacity: 1) { address in body(address) } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /kronos/native/Kronos/NTPProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Exception raised when the received PDU is invalid. 4 | enum NTPParsingError: Error { 5 | case invalidNTPPDU(String) 6 | } 7 | 8 | /// The leap indicator warning of an impending leap second to be inserted or deleted in the last minute of the 9 | /// current month. 10 | enum LeapIndicator: Int8 { 11 | case noWarning, sixtyOneSeconds, fiftyNineSeconds, alarm 12 | 13 | /// Human readable value of the leap warning. 14 | var description: String { 15 | switch self { 16 | case .noWarning: 17 | return "No warning" 18 | case .sixtyOneSeconds: 19 | return "Last minute of the day has 61 seconds" 20 | case .fiftyNineSeconds: 21 | return "Last minute of the day has 59 seconds" 22 | case .alarm: 23 | return "Unknown (clock unsynchronized)" 24 | } 25 | } 26 | } 27 | 28 | /// The connection mode. 29 | enum Mode: Int8 { 30 | case reserved, symmetricActive, symmetricPassive, client, server, broadcast, reservedNTP, unknown 31 | } 32 | 33 | /// Mode representing the stratum level of the clock. 34 | enum Stratum: Int8 { 35 | case unspecified, primary, secondary, invalid 36 | 37 | init(value: Int8) { 38 | switch value { 39 | case 0: 40 | self = .unspecified 41 | 42 | case 1: 43 | self = .primary 44 | 45 | case 0 ..< 15: 46 | self = .secondary 47 | 48 | default: 49 | self = .invalid 50 | } 51 | } 52 | } 53 | 54 | /// Server or reference clock. This value is generated based on the server stratum. 55 | /// 56 | /// - ReferenceClock: Contains the sourceID and the description for the reference clock (stratum 1). 57 | /// - Debug(id): Contains the kiss code for debug purposes (stratum 0). 58 | /// - ReferenceIdentifier(id): The reference identifier of the server (stratum > 1). 59 | enum ClockSource { 60 | case referenceClock(id: UInt32, description: String) 61 | case debug(id: UInt32) 62 | case referenceIdentifier(id: UInt32) 63 | 64 | init(stratum: Stratum, sourceID: UInt32) { 65 | switch stratum { 66 | case .unspecified: 67 | self = .debug(id: sourceID) 68 | 69 | case .primary: 70 | let (id, description) = ClockSource.description(fromID: sourceID) 71 | self = .referenceClock(id: id, description: description) 72 | 73 | case .secondary, .invalid: 74 | self = .referenceIdentifier(id: sourceID) 75 | } 76 | } 77 | 78 | /// The id for the reference clock (IANA, stratum 1), debug (stratum 0) or referenceIdentifier 79 | var ID: UInt32 { 80 | switch self { 81 | case .referenceClock(let id, _): 82 | return id 83 | 84 | case .debug(let id): 85 | return id 86 | 87 | case .referenceIdentifier(let id): 88 | return id 89 | } 90 | } 91 | 92 | private static func description(fromID sourceID: UInt32) -> (UInt32, String) { 93 | let sourceMap: [UInt32: String] = [ 94 | 0x47505300: "Global Position System", 95 | 0x47414c00: "Galileo Positioning System", 96 | 0x50505300: "Generic pulse-per-second", 97 | 0x49524947: "Inter-Range Instrumentation Group", 98 | 0x57575642: "LF Radio WWVB Ft. Collins, CO 60 kHz", 99 | 0x44434600: "LF Radio DCF77 Mainflingen, DE 77.5 kHz", 100 | 0x48424700: "LF Radio HBG Prangins, HB 75 kHz", 101 | 0x4d534600: "LF Radio MSF Anthorn, UK 60 kHz", 102 | 0x4a4a5900: "LF Radio JJY Fukushima, JP 40 kHz, Saga, JP 60 kHz", 103 | 0x4c4f5243: "MF Radio LORAN C station, 100 kHz", 104 | 0x54444600: "MF Radio Allouis, FR 162 kHz", 105 | 0x43485500: "HF Radio CHU Ottawa, Ontario", 106 | 0x57575600: "HF Radio WWV Ft. Collins, CO", 107 | 0x57575648: "HF Radio WWVH Kauai, HI", 108 | 0x4e495354: "NIST telephone modem", 109 | 0x41435453: "ACTS telephone modem", 110 | 0x55534e4f: "USNO telephone modem", 111 | 0x50544200: "European telephone modem", 112 | 0x4c4f434c: "Uncalibrated local clock", 113 | 0x4345534d: "Calibrated Cesium clock", 114 | 0x5242444d: "Calibrated Rubidium clock", 115 | 0x4f4d4547: "OMEGA radio navigation system", 116 | 0x44434e00: "DCN routing protocol", 117 | 0x54535000: "TSP time protocol", 118 | 0x44545300: "Digital Time Service", 119 | 0x41544f4d: "Atomic clock (calibrated)", 120 | 0x564c4600: "VLF radio (OMEGA,, etc.)", 121 | 0x31505053: "External 1 PPS input", 122 | 0x46524545: "(Internal clock)", 123 | 0x494e4954: "(Initialization)", 124 | ] 125 | 126 | return (sourceID, sourceMap[sourceID] ?? "NULL") 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /sampleApp/src/commonMain/kotlin/com/softartdev/kronos/sample/TickScreen.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package com.softartdev.kronos.sample 4 | 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.calculateStartPadding 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.safeDrawingPadding 11 | import androidx.compose.material.Button 12 | import androidx.compose.material.Checkbox 13 | import androidx.compose.material.CircularProgressIndicator 14 | import androidx.compose.material.MaterialTheme.typography 15 | import androidx.compose.material.Scaffold 16 | import androidx.compose.material.Switch 17 | import androidx.compose.material.Text 18 | import androidx.compose.material.TopAppBar 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.LaunchedEffect 21 | import androidx.compose.runtime.MutableState 22 | import androidx.compose.runtime.derivedStateOf 23 | import androidx.compose.runtime.getValue 24 | import androidx.compose.runtime.mutableStateOf 25 | import androidx.compose.runtime.remember 26 | import androidx.compose.runtime.rememberCoroutineScope 27 | import androidx.compose.runtime.setValue 28 | import androidx.compose.ui.Alignment 29 | import androidx.compose.ui.Modifier 30 | import androidx.compose.ui.platform.LocalLayoutDirection 31 | import androidx.compose.ui.unit.dp 32 | import com.softartdev.kronos.Network 33 | import io.github.aakira.napier.Napier 34 | import kotlinx.coroutines.delay 35 | import kotlinx.coroutines.launch 36 | import kotlin.time.Clock 37 | import kotlin.time.Duration 38 | import kotlin.time.Duration.Companion.seconds 39 | import kotlin.time.ExperimentalTime 40 | import kotlin.time.Instant 41 | 42 | @Composable 43 | fun TickScreen() = Scaffold( 44 | modifier = Modifier.safeDrawingPadding(), 45 | topBar = { TopAppBar(title = { Text(text = Greeting().greet()) }) }, 46 | content = { paddingValues -> 47 | Column( 48 | modifier = Modifier.padding( 49 | vertical = paddingValues.calculateTopPadding() 50 | .coerceAtLeast(minimumValue = 8.dp), 51 | horizontal = paddingValues.calculateStartPadding(LocalLayoutDirection.current) 52 | .coerceAtLeast(minimumValue = 8.dp), 53 | ), 54 | horizontalAlignment = Alignment.Start, 55 | verticalArrangement = Arrangement.spacedBy(16.dp) 56 | ) { 57 | var networkTime: Instant? by remember { mutableStateOf(value = clockNetworkNowOrNull()) } 58 | Text(text = "Network time:", style = typography.subtitle2) 59 | Text(text = networkTime.toString(), style = typography.body1) 60 | var systemTime: Instant by remember { mutableStateOf(Clock.System.now()) } 61 | Text(text = "System time:", style = typography.subtitle2) 62 | Text(text = systemTime.toString(), style = typography.body1) 63 | var diff: Duration? by remember { 64 | mutableStateOf(value = networkTime?.let { it - systemTime }) 65 | } 66 | Text(text = "Diff:", style = typography.subtitle2) 67 | Text(text = diff.toString(), style = typography.body1) 68 | var ticking by remember { mutableStateOf(false) } 69 | val synced by remember { derivedStateOf { networkTime != null } } 70 | Row( 71 | verticalAlignment = Alignment.CenterVertically, 72 | horizontalArrangement = Arrangement.spacedBy(8.dp) 73 | ) { 74 | Text(text = "Ticking: ", style = typography.subtitle1) 75 | Switch(checked = ticking, onCheckedChange = { ticking = it }) 76 | Text(text = "Synced: ", style = typography.subtitle1) 77 | Checkbox(checked = synced, onCheckedChange = null, enabled = false) 78 | } 79 | fun tick() { 80 | systemTime = Clock.System.now() 81 | networkTime = clockNetworkNowOrNull() 82 | diff = networkTime?.let { it - systemTime } 83 | Napier.d(tag = "⏳", message = "Ticking Diff: $diff") 84 | } 85 | if (ticking) { 86 | LaunchedEffect(key1 = "tick") { 87 | while (ticking) { 88 | tick() 89 | delay(1.seconds) 90 | } 91 | } 92 | } 93 | val loadingState: MutableState = remember { mutableStateOf(false) } 94 | val coroutineScope = rememberCoroutineScope() 95 | if (loadingState.value) { 96 | CircularProgressIndicator() 97 | } else { 98 | Button(onClick = { 99 | coroutineScope.launch { 100 | loadingState.value = true 101 | clickAwaitSync() 102 | tick() 103 | loadingState.value = false 104 | } 105 | }) { 106 | Text(text = "Sync network time") 107 | } 108 | } 109 | Button(onClick = { openUrl("https://github.com/softartdev/Kronos-Multiplatform") }) { 110 | Text("Open github") 111 | } 112 | } 113 | }) 114 | 115 | private fun clockNetworkNowOrNull(): Instant? = try { 116 | Clock.Network.now() 117 | } catch (e: Exception) { 118 | Napier.e(tag = "❌", throwable = e, message = "Network time error") 119 | null 120 | } 121 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | 2 | #!/usr/bin/env sh 3 | 4 | # 5 | # Copyright 2015 the original author or authors. 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # https://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | # 19 | 20 | ############################################################################## 21 | ## 22 | ## Gradle start up script for UN*X 23 | ## 24 | ############################################################################## 25 | 26 | # Attempt to set APP_HOME 27 | # Resolve links: ${'$'}0 may be a link 28 | PRG="$0" 29 | # Need this for relative symlinks. 30 | while [ -h "$PRG" ] ; do 31 | ls=`ls -ld "$PRG"` 32 | link=`expr "$ls" : '.*-> \(.*\)${'$'}'` 33 | if expr "$link" : '/.*' > /dev/null; then 34 | PRG="$link" 35 | else 36 | PRG=`dirname "$PRG"`"/$link" 37 | fi 38 | done 39 | SAVED="`pwd`" 40 | cd "`dirname \"$PRG\"`/" >/dev/null 41 | APP_HOME="`pwd -P`" 42 | cd "$SAVED" >/dev/null 43 | 44 | APP_NAME="Gradle" 45 | APP_BASE_NAME=`basename "$0"` 46 | 47 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 48 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 49 | 50 | # Use the maximum available, or set MAX_FD != -1 to use that value. 51 | MAX_FD="maximum" 52 | 53 | warn () { 54 | echo "${'$'}*" 55 | } 56 | 57 | die () { 58 | echo 59 | echo "${'$'}*" 60 | echo 61 | exit 1 62 | } 63 | 64 | # OS specific support (must be 'true' or 'false'). 65 | cygwin=false 66 | msys=false 67 | darwin=false 68 | nonstop=false 69 | case "`uname`" in 70 | CYGWIN* ) 71 | cygwin=true 72 | ;; 73 | Darwin* ) 74 | darwin=true 75 | ;; 76 | MSYS* | MINGW* ) 77 | msys=true 78 | ;; 79 | NONSTOP* ) 80 | nonstop=true 81 | ;; 82 | esac 83 | 84 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 85 | 86 | 87 | # Determine the Java command to use to start the JVM. 88 | if [ -n "$JAVA_HOME" ] ; then 89 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 90 | # IBM's JDK on AIX uses strange locations for the executables 91 | JAVACMD="$JAVA_HOME/jre/sh/java" 92 | else 93 | JAVACMD="$JAVA_HOME/bin/java" 94 | fi 95 | if [ ! -x "$JAVACMD" ] ; then 96 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 97 | 98 | Please set the JAVA_HOME variable in your environment to match the 99 | location of your Java installation." 100 | fi 101 | else 102 | JAVACMD="java" 103 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 104 | 105 | Please set the JAVA_HOME variable in your environment to match the 106 | location of your Java installation." 107 | fi 108 | 109 | # Increase the maximum file descriptors if we can. 110 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 111 | MAX_FD_LIMIT=`ulimit -H -n` 112 | if [ $? -eq 0 ] ; then 113 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 114 | MAX_FD="$MAX_FD_LIMIT" 115 | fi 116 | ulimit -n $MAX_FD 117 | if [ $? -ne 0 ] ; then 118 | warn "Could not set maximum file descriptor limit: $MAX_FD" 119 | fi 120 | else 121 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 122 | fi 123 | fi 124 | 125 | # For Darwin, add options to specify how the application appears in the dock 126 | if $darwin; then 127 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 128 | fi 129 | 130 | # For Cygwin or MSYS, switch paths to Windows format before running java 131 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 132 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 133 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 134 | 135 | JAVACMD=`cygpath --unix "$JAVACMD"` 136 | 137 | # We build the pattern for arguments to be converted via cygpath 138 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 139 | SEP="" 140 | for dir in $ROOTDIRSRAW ; do 141 | ROOTDIRS="$ROOTDIRS$SEP$dir" 142 | SEP="|" 143 | done 144 | OURCYGPATTERN="(^($ROOTDIRS))" 145 | # Add a user-defined pattern to the cygpath arguments 146 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 147 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 148 | fi 149 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 150 | i=0 151 | for arg in "$@" ; do 152 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 153 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 154 | 155 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 156 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 157 | else 158 | eval `echo args$i`="\"$arg\"" 159 | fi 160 | i=`expr $i + 1` 161 | done 162 | case $i in 163 | 0) set -- ;; 164 | 1) set -- "$args0" ;; 165 | 2) set -- "$args0" "$args1" ;; 166 | 3) set -- "$args0" "$args1" "$args2" ;; 167 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 168 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 169 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 170 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 171 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 172 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 173 | esac 174 | fi 175 | 176 | # Escape application args 177 | save () { 178 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 179 | echo " " 180 | } 181 | APP_ARGS=`save "$@"` 182 | 183 | # Collect all arguments for the java command, following the shell quoting and substitution rules 184 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 185 | 186 | exec "$JAVACMD" "$@" 187 | -------------------------------------------------------------------------------- /kronos/native/Kronos/NTPClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private let kDefaultTimeout = 6.0 4 | private let kDefaultSamples = 4 5 | private let kMaximumNTPServers = 5 6 | private let kMaximumResultDispersion = 10.0 7 | 8 | private typealias ObjCCompletionType = @convention(block) (Data?, TimeInterval) -> Void 9 | 10 | /// Exception raised while sending / receiving NTP packets. 11 | enum NTPNetworkError: Error { 12 | case noValidNTPPacketFound 13 | } 14 | 15 | /// NTP client session. 16 | final class NTPClient { 17 | 18 | /// Query the all ips that resolve from the given pool. 19 | /// 20 | /// - parameter pool: NTP pool that will be resolved into multiple NTP servers. 21 | /// - parameter port: Server NTP port (default 123). 22 | /// - parameter version: NTP version to use (default 3). 23 | /// - parameter numberOfSamples: The number of samples to be acquired from each server (default 4). 24 | /// - parameter maximumServers: The maximum number of servers to be queried (default 5). 25 | /// - parameter timeout: The individual timeout for each of the NTP operations. 26 | /// - parameter completion: A closure that will be response PDU on success or nil on error. 27 | func query(pool: String = "time.apple.com", version: Int8 = 3, port: Int = 123, 28 | numberOfSamples: Int = kDefaultSamples, maximumServers: Int = kMaximumNTPServers, 29 | timeout: CFTimeInterval = kDefaultTimeout, 30 | progress: @escaping (TimeInterval?, Int, Int) -> Void) 31 | { 32 | var servers: [InternetAddress: [NTPPacket]] = [:] 33 | var completed: Int = 0 34 | 35 | let queryIPAndStoreResult = { (address: InternetAddress, totalQueries: Int) -> Void in 36 | self.query(ip: address, port: port, version: version, timeout: timeout, 37 | numberOfSamples: numberOfSamples) 38 | { packet in 39 | defer { 40 | completed += 1 41 | 42 | let responses = Array(servers.values) 43 | progress(try? self.offset(from: responses), completed, totalQueries) 44 | } 45 | 46 | guard let PDU = packet else { 47 | return 48 | } 49 | 50 | if servers[address] == nil { 51 | servers[address] = [] 52 | } 53 | 54 | servers[address]?.append(PDU) 55 | } 56 | } 57 | 58 | DNSResolver.resolve(host: pool) { addresses in 59 | if addresses.count == 0 { 60 | return progress(nil, 0, 0) 61 | } 62 | 63 | let totalServers = min(addresses.count, maximumServers) 64 | for address in addresses[0 ..< totalServers] { 65 | queryIPAndStoreResult(address, totalServers * numberOfSamples) 66 | } 67 | } 68 | } 69 | 70 | /// Query the given NTP server for the time exchange. 71 | /// 72 | /// - parameter ip: Server socket address. 73 | /// - parameter port: Server NTP port (default 123). 74 | /// - parameter version: NTP version to use (default 3). 75 | /// - parameter timeout: Timeout on socket operations. 76 | /// - parameter numberOfSamples: The number of samples to be acquired from the server (default 4). 77 | /// - parameter completion: A closure that will be response PDU on success or nil on error. 78 | func query(ip: InternetAddress, port: Int = 123, version: Int8 = 3, 79 | timeout: CFTimeInterval = kDefaultTimeout, numberOfSamples: Int = kDefaultSamples, 80 | completion: @escaping (NTPPacket?) -> Void) 81 | { 82 | var timer: Timer? 83 | let bridgeCallback: ObjCCompletionType = { data, destinationTime in 84 | defer { 85 | // If we still have samples left; we'll keep querying the same server 86 | if numberOfSamples > 1 { 87 | self.query(ip: ip, port: port, version: version, timeout: timeout, 88 | numberOfSamples: numberOfSamples - 1, completion: completion) 89 | } 90 | } 91 | 92 | timer?.invalidate() 93 | guard 94 | let data = data, let PDU = try? NTPPacket(data: data, destinationTime: destinationTime), 95 | PDU.isValidResponse() else 96 | { 97 | completion(nil) 98 | return 99 | } 100 | 101 | completion(PDU) 102 | } 103 | 104 | let callback = unsafeBitCast(bridgeCallback, to: AnyObject.self) 105 | let retainedCallback = Unmanaged.passRetained(callback) 106 | let sourceAndSocket = self.sendAsyncUDPQuery( 107 | to: ip, port: port, timeout: timeout, 108 | completion: UnsafeMutableRawPointer(retainedCallback.toOpaque()) 109 | ) 110 | 111 | timer = BlockTimer.scheduledTimer(withTimeInterval: timeout, repeated: true) { _ in 112 | bridgeCallback(nil, TimeInterval.infinity) 113 | retainedCallback.release() 114 | 115 | if let (source, socket) = sourceAndSocket { 116 | CFSocketInvalidate(socket) 117 | CFRunLoopRemoveSource(CFRunLoopGetMain(), source, CFRunLoopMode.commonModes) 118 | } 119 | } 120 | } 121 | 122 | // MARK: - Private helpers (NTP Calculation) 123 | 124 | private func offset(from responses: [[NTPPacket]]) throws -> TimeInterval { 125 | let now = currentTime() 126 | var bestResponses: [NTPPacket] = [] 127 | for serverResponses in responses { 128 | let filtered = serverResponses 129 | .filter { abs($0.originTime - now) < kMaximumResultDispersion } 130 | .min { $0.delay < $1.delay } 131 | 132 | if let filtered = filtered { 133 | bestResponses.append(filtered) 134 | } 135 | } 136 | 137 | if bestResponses.count == 0 { 138 | throw NTPNetworkError.noValidNTPPacketFound 139 | } 140 | 141 | bestResponses.sort { $0.offset < $1.offset } 142 | return bestResponses[bestResponses.count / 2].offset 143 | } 144 | 145 | // MARK: - Private helpers (CFSocket) 146 | 147 | private func sendAsyncUDPQuery(to ip: InternetAddress, port: Int, timeout: TimeInterval, 148 | completion: UnsafeMutableRawPointer) -> (CFRunLoopSource, CFSocket)? 149 | { 150 | signal(SIGPIPE, SIG_IGN) 151 | 152 | let callback: CFSocketCallBack = { socket, callbackType, _, data, info in 153 | if callbackType == .writeCallBack { 154 | var packet = NTPPacket() 155 | let PDU = packet.prepareToSend() as CFData 156 | CFSocketSendData(socket, nil, PDU, kDefaultTimeout) 157 | return 158 | } 159 | 160 | guard let info = info else { 161 | return 162 | } 163 | 164 | CFSocketInvalidate(socket) 165 | 166 | let destinationTime = currentTime() 167 | let retainedClosure = Unmanaged.fromOpaque(info) 168 | let completion = unsafeBitCast(retainedClosure.takeUnretainedValue(), to: ObjCCompletionType.self) 169 | 170 | let data = unsafeBitCast(data, to: CFData.self) as Data? 171 | completion(data, destinationTime) 172 | retainedClosure.release() 173 | } 174 | 175 | let types = CFSocketCallBackType.dataCallBack.rawValue | CFSocketCallBackType.writeCallBack.rawValue 176 | var context = CFSocketContext(version: 0, info: completion, retain: nil, release: nil, 177 | copyDescription: nil) 178 | guard let socket = CFSocketCreate(nil, ip.family, SOCK_DGRAM, IPPROTO_UDP, types, callback, &context), 179 | CFSocketIsValid(socket) else 180 | { 181 | return nil 182 | } 183 | 184 | let runLoopSource = CFSocketCreateRunLoopSource(kCFAllocatorDefault, socket, 0) 185 | CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, CFRunLoopMode.commonModes) 186 | CFSocketConnectToAddress(socket, ip.addressData(withPort: port), timeout) 187 | return (runLoopSource!, socket) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /kronos/native/Kronos/NTPPacket.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Delta between system and NTP time 4 | private let kEpochDelta = 2208988800.0 5 | private let kEpochRolloverDelta = pow(2.0, 32.0) - kEpochDelta 6 | 7 | /// This is the maximum that we'll tolerate for the client's time vs self.delay 8 | private let kMaximumDelayDifference = 0.1 9 | private let kMaximumDispersion = 100.0 10 | 11 | /// Returns the current time in decimal EPOCH timestamp format. 12 | /// 13 | /// - returns: The current time in EPOCH timestamp format. 14 | func currentTime() -> TimeInterval { 15 | var current = timeval() 16 | let systemTimeError = gettimeofday(¤t, nil) != 0 17 | assert(!systemTimeError, "system clock error: system time unavailable") 18 | 19 | return Double(current.tv_sec) + Double(current.tv_usec) / 1_000_000 20 | } 21 | 22 | struct NTPPacket { 23 | 24 | /// The leap indicator warning of an impending leap second to be inserted or deleted in the last 25 | /// minute of the current month. 26 | let leap: LeapIndicator 27 | 28 | /// Version Number (VN): This is a three-bit integer indicating the NTP version number, currently 3. 29 | let version: Int8 30 | 31 | /// The current connection mode. 32 | let mode: Mode 33 | 34 | /// Mode representing the stratum level of the local clock. 35 | let stratum: Stratum 36 | 37 | /// Indicates the maximum interval between successive messages, in seconds to the nearest power of two. 38 | /// The values that normally appear in this field range from 6 to 10, inclusive. 39 | let poll: Int8 40 | 41 | /// The precision of the local clock, in seconds to the nearest power of two. The values that normally 42 | /// appear in this field range from -6 for mains-frequency clocks to -18 for microsecond clocks found 43 | /// in some workstations. 44 | let precision: Int8 45 | 46 | /// The total roundtrip delay to the primary reference source, in seconds with fraction point between 47 | /// bits 15 and 16. Note that this variable can take on both positive and negative values, depending on 48 | /// the relative time and frequency errors. The values that normally appear in this field range from 49 | /// negative values of a few milliseconds to positive values of several hundred milliseconds. 50 | let rootDelay: TimeInterval 51 | 52 | /// Total dispersion to the reference clock, in EPOCH. 53 | let rootDispersion: TimeInterval 54 | 55 | /// Server or reference clock. This value is generated based on a reference identifier maintained by IANA. 56 | let clockSource: ClockSource 57 | 58 | /// Time when the system clock was last set or corrected, in EPOCH timestamp format. 59 | let referenceTime: TimeInterval 60 | 61 | /// Time at the client when the request departed for the server, in EPOCH timestamp format. 62 | let originTime: TimeInterval 63 | 64 | /// Time at the server when the request arrived from the client, in EPOCH timestamp format. 65 | let receiveTime: TimeInterval 66 | 67 | /// Time at the server when the response left for the client, in EPOCH timestamp format. 68 | var transmitTime: TimeInterval = 0.0 69 | 70 | /// Time at the client when the response arrived, in EPOCH timestamp format. 71 | let destinationTime: TimeInterval 72 | 73 | /// NTP protocol package representation. 74 | /// 75 | /// - parameter transmitTime: Packet transmission timestamp. 76 | /// - parameter version: NTP protocol version. 77 | /// - parameter mode: Packet mode (client, server). 78 | init(version: Int8 = 3, mode: Mode = .client) { 79 | self.version = version 80 | self.leap = .noWarning 81 | self.mode = mode 82 | self.stratum = .unspecified 83 | self.poll = 4 84 | self.precision = -6 85 | self.rootDelay = 1 86 | self.rootDispersion = 1 87 | self.clockSource = .referenceIdentifier(id: 0) 88 | self.referenceTime = -kEpochDelta 89 | self.originTime = -kEpochDelta 90 | self.receiveTime = -kEpochDelta 91 | self.destinationTime = -1 92 | } 93 | 94 | /// Creates a NTP package based on a network PDU. 95 | /// 96 | /// - parameter data: The PDU received from the NTP call. 97 | /// - parameter destinationTime: The time where the package arrived (client time) in EPOCH format. 98 | /// - throws: NTPParsingError in case of an invalid response. 99 | init(data: Data, destinationTime: TimeInterval) throws { 100 | if data.count < 48 { 101 | throw NTPParsingError.invalidNTPPDU("Invalid PDU length: \(data.count)") 102 | } 103 | 104 | self.leap = LeapIndicator(rawValue: (data.getByte(at: 0) >> 6) & 0b11) ?? .noWarning 105 | self.version = data.getByte(at: 0) >> 3 & 0b111 106 | self.mode = Mode(rawValue: data.getByte(at: 0) & 0b111) ?? .unknown 107 | self.stratum = Stratum(value: data.getByte(at: 1)) 108 | self.poll = data.getByte(at: 2) 109 | self.precision = data.getByte(at: 3) 110 | self.rootDelay = NTPPacket.intervalFromNTPFormat(data.getUnsignedInteger(at: 4)) 111 | self.rootDispersion = NTPPacket.intervalFromNTPFormat(data.getUnsignedInteger(at: 8)) 112 | self.clockSource = ClockSource(stratum: self.stratum, sourceID: data.getUnsignedInteger(at: 12)) 113 | self.referenceTime = NTPPacket.dateFromNTPFormat(data.getUnsignedLong(at: 16)) 114 | self.originTime = NTPPacket.dateFromNTPFormat(data.getUnsignedLong(at: 24)) 115 | self.receiveTime = NTPPacket.dateFromNTPFormat(data.getUnsignedLong(at: 32)) 116 | self.transmitTime = NTPPacket.dateFromNTPFormat(data.getUnsignedLong(at: 40)) 117 | self.destinationTime = destinationTime 118 | } 119 | 120 | /// Convert this NTPPacket to a buffer that can be sent over a socket. 121 | /// 122 | /// - returns: A bytes buffer representing this packet. 123 | mutating func prepareToSend(transmitTime: TimeInterval? = nil) -> Data { 124 | var data = Data() 125 | data.append(byte: self.leap.rawValue << 6 | self.version << 3 | self.mode.rawValue) 126 | data.append(byte: self.stratum.rawValue) 127 | data.append(byte: self.poll) 128 | data.append(byte: self.precision) 129 | data.append(unsignedInteger: self.intervalToNTPFormat(self.rootDelay)) 130 | data.append(unsignedInteger: self.intervalToNTPFormat(self.rootDispersion)) 131 | data.append(unsignedInteger: self.clockSource.ID) 132 | data.append(unsignedLong: self.dateToNTPFormat(self.referenceTime)) 133 | data.append(unsignedLong: self.dateToNTPFormat(self.originTime)) 134 | data.append(unsignedLong: self.dateToNTPFormat(self.receiveTime)) 135 | 136 | self.transmitTime = transmitTime ?? currentTime() 137 | data.append(unsignedLong: self.dateToNTPFormat(self.transmitTime)) 138 | return data 139 | } 140 | 141 | /// Checks properties to make sure that the received PDU is a valid response that we can use. 142 | /// 143 | /// - returns: A boolean indicating if the response is valid for the given version. 144 | func isValidResponse() -> Bool { 145 | return (self.mode == .server || self.mode == .symmetricPassive) && self.leap != .alarm 146 | && self.stratum != .invalid && self.stratum != .unspecified 147 | && self.rootDispersion < kMaximumDispersion 148 | && abs(currentTime() - self.originTime - self.delay) < kMaximumDelayDifference 149 | } 150 | 151 | // MARK: - Private helpers 152 | 153 | private func dateToNTPFormat(_ time: TimeInterval) -> UInt64 { 154 | let integer = UInt64(time + kEpochDelta) & 0xffffffff 155 | let decimal = modf(time).1 * 4294967296.0 // 2 ^ 32 156 | return integer << 32 | UInt64(decimal) 157 | } 158 | 159 | private func intervalToNTPFormat(_ time: TimeInterval) -> UInt32 { 160 | let integer = UInt16(time) 161 | let decimal = modf(time).1 * 65536 // 2 ^ 16 162 | return UInt32(integer) << 16 | UInt32(decimal) 163 | } 164 | 165 | private static func dateFromNTPFormat(_ time: UInt64) -> TimeInterval { 166 | let needsRollOver = time & 0x8000000000000000 == 0 167 | let delta = needsRollOver ? kEpochRolloverDelta : -kEpochDelta 168 | let integer = Double(time >> 32) 169 | let decimal = Double(time & 0xffffffff) / 4294967296.0 170 | return integer + delta + decimal 171 | } 172 | 173 | private static func intervalFromNTPFormat(_ time: UInt32) -> TimeInterval { 174 | let integer = Double(time >> 16) 175 | let decimal = Double(time & 0xffff) / 65536 176 | return integer + decimal 177 | } 178 | } 179 | 180 | /// From RFC 2030 (with a correction to the delay math): 181 | /// 182 | /// Timestamp Name ID When Generated 183 | /// ------------------------------------------------------------ 184 | /// Originate Timestamp T1 time request sent by client 185 | /// Receive Timestamp T2 time request received by server 186 | /// Transmit Timestamp T3 time reply sent by server 187 | /// Destination Timestamp T4 time reply received by client 188 | /// 189 | /// The roundtrip delay d and local clock offset t are defined as 190 | /// 191 | /// d = (T4 - T1) - (T3 - T2) t = ((T2 - T1) + (T3 - T4)) / 2. 192 | extension NTPPacket { 193 | 194 | /// Clocks offset in seconds. 195 | var offset: TimeInterval { 196 | return ((self.receiveTime - self.originTime) + (self.transmitTime - self.destinationTime)) / 2.0 197 | } 198 | 199 | /// Round-trip delay in seconds 200 | var delay: TimeInterval { 201 | return (self.destinationTime - self.originTime) - (self.transmitTime - self.receiveTime) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | A93A953B29CC810C00F8E227 /* iosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A93A953A29CC810C00F8E227 /* iosApp.swift */; }; 11 | A93A953F29CC810D00F8E227 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A93A953E29CC810D00F8E227 /* Assets.xcassets */; }; 12 | A93A954229CC810D00F8E227 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A93A954129CC810D00F8E227 /* Preview Assets.xcassets */; }; 13 | D51586C6B134BDB93BE1DFFB /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EE080622DF072CBDC3D9C873 /* Pods_iosApp.framework */; }; 14 | /* End PBXBuildFile section */ 15 | 16 | /* Begin PBXFileReference section */ 17 | 05E67C72B2BDBC81379103CB /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; }; 18 | 73FFDDCA9C728FEE3DFEF2F4 /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; }; 19 | 751C49B92CF2A16F00D81888 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 20 | A93A953729CC810C00F8E227 /* Kronos Multiplatform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Kronos Multiplatform.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 21 | A93A953A29CC810C00F8E227 /* iosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iosApp.swift; sourceTree = ""; }; 22 | A93A953E29CC810D00F8E227 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 23 | A93A954129CC810D00F8E227 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 24 | EE080622DF072CBDC3D9C873 /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 25 | /* End PBXFileReference section */ 26 | 27 | /* Begin PBXFrameworksBuildPhase section */ 28 | A93A953429CC810C00F8E227 /* Frameworks */ = { 29 | isa = PBXFrameworksBuildPhase; 30 | buildActionMask = 2147483647; 31 | files = ( 32 | D51586C6B134BDB93BE1DFFB /* Pods_iosApp.framework in Frameworks */, 33 | ); 34 | runOnlyForDeploymentPostprocessing = 0; 35 | }; 36 | /* End PBXFrameworksBuildPhase section */ 37 | 38 | /* Begin PBXGroup section */ 39 | 979913F6AE5D271756D4649D /* Pods */ = { 40 | isa = PBXGroup; 41 | children = ( 42 | 73FFDDCA9C728FEE3DFEF2F4 /* Pods-iosApp.debug.xcconfig */, 43 | 05E67C72B2BDBC81379103CB /* Pods-iosApp.release.xcconfig */, 44 | ); 45 | path = Pods; 46 | sourceTree = ""; 47 | }; 48 | A93A952E29CC810C00F8E227 = { 49 | isa = PBXGroup; 50 | children = ( 51 | A93A953929CC810C00F8E227 /* iosApp */, 52 | A93A953829CC810C00F8E227 /* Products */, 53 | 979913F6AE5D271756D4649D /* Pods */, 54 | C4127409AE3703430489E7BC /* Frameworks */, 55 | ); 56 | sourceTree = ""; 57 | }; 58 | A93A953829CC810C00F8E227 /* Products */ = { 59 | isa = PBXGroup; 60 | children = ( 61 | A93A953729CC810C00F8E227 /* Kronos Multiplatform.app */, 62 | ); 63 | name = Products; 64 | sourceTree = ""; 65 | }; 66 | A93A953929CC810C00F8E227 /* iosApp */ = { 67 | isa = PBXGroup; 68 | children = ( 69 | 751C49B92CF2A16F00D81888 /* Info.plist */, 70 | A93A953A29CC810C00F8E227 /* iosApp.swift */, 71 | A93A953E29CC810D00F8E227 /* Assets.xcassets */, 72 | A93A954029CC810D00F8E227 /* Preview Content */, 73 | ); 74 | path = iosApp; 75 | sourceTree = ""; 76 | }; 77 | A93A954029CC810D00F8E227 /* Preview Content */ = { 78 | isa = PBXGroup; 79 | children = ( 80 | A93A954129CC810D00F8E227 /* Preview Assets.xcassets */, 81 | ); 82 | path = "Preview Content"; 83 | sourceTree = ""; 84 | }; 85 | C4127409AE3703430489E7BC /* Frameworks */ = { 86 | isa = PBXGroup; 87 | children = ( 88 | EE080622DF072CBDC3D9C873 /* Pods_iosApp.framework */, 89 | ); 90 | name = Frameworks; 91 | sourceTree = ""; 92 | }; 93 | /* End PBXGroup section */ 94 | 95 | /* Begin PBXNativeTarget section */ 96 | A93A953629CC810C00F8E227 /* iosApp */ = { 97 | isa = PBXNativeTarget; 98 | buildConfigurationList = A93A954529CC810D00F8E227 /* Build configuration list for PBXNativeTarget "iosApp" */; 99 | buildPhases = ( 100 | D3665D361753A60B1A6EEEB7 /* [CP] Check Pods Manifest.lock */, 101 | A93A953329CC810C00F8E227 /* Sources */, 102 | A93A953429CC810C00F8E227 /* Frameworks */, 103 | A93A953529CC810C00F8E227 /* Resources */, 104 | FC29032C0717E1CCFA494848 /* [CP] Copy Pods Resources */, 105 | ); 106 | buildRules = ( 107 | ); 108 | dependencies = ( 109 | ); 110 | name = iosApp; 111 | productName = iosApp; 112 | productReference = A93A953729CC810C00F8E227 /* Kronos Multiplatform.app */; 113 | productType = "com.apple.product-type.application"; 114 | }; 115 | /* End PBXNativeTarget section */ 116 | 117 | /* Begin PBXProject section */ 118 | A93A952F29CC810C00F8E227 /* Project object */ = { 119 | isa = PBXProject; 120 | attributes = { 121 | BuildIndependentTargetsInParallel = 1; 122 | LastSwiftUpdateCheck = 1420; 123 | LastUpgradeCheck = 1420; 124 | TargetAttributes = { 125 | A93A953629CC810C00F8E227 = { 126 | CreatedOnToolsVersion = 14.2; 127 | }; 128 | }; 129 | }; 130 | buildConfigurationList = A93A953229CC810C00F8E227 /* Build configuration list for PBXProject "iosApp" */; 131 | compatibilityVersion = "Xcode 14.0"; 132 | developmentRegion = en; 133 | hasScannedForEncodings = 0; 134 | knownRegions = ( 135 | en, 136 | Base, 137 | ); 138 | mainGroup = A93A952E29CC810C00F8E227; 139 | productRefGroup = A93A953829CC810C00F8E227 /* Products */; 140 | projectDirPath = ""; 141 | projectRoot = ""; 142 | targets = ( 143 | A93A953629CC810C00F8E227 /* iosApp */, 144 | ); 145 | }; 146 | /* End PBXProject section */ 147 | 148 | /* Begin PBXResourcesBuildPhase section */ 149 | A93A953529CC810C00F8E227 /* Resources */ = { 150 | isa = PBXResourcesBuildPhase; 151 | buildActionMask = 2147483647; 152 | files = ( 153 | A93A954229CC810D00F8E227 /* Preview Assets.xcassets in Resources */, 154 | A93A953F29CC810D00F8E227 /* Assets.xcassets in Resources */, 155 | ); 156 | runOnlyForDeploymentPostprocessing = 0; 157 | }; 158 | /* End PBXResourcesBuildPhase section */ 159 | 160 | /* Begin PBXShellScriptBuildPhase section */ 161 | D3665D361753A60B1A6EEEB7 /* [CP] Check Pods Manifest.lock */ = { 162 | isa = PBXShellScriptBuildPhase; 163 | buildActionMask = 2147483647; 164 | files = ( 165 | ); 166 | inputFileListPaths = ( 167 | ); 168 | inputPaths = ( 169 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 170 | "${PODS_ROOT}/Manifest.lock", 171 | ); 172 | name = "[CP] Check Pods Manifest.lock"; 173 | outputFileListPaths = ( 174 | ); 175 | outputPaths = ( 176 | "$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt", 177 | ); 178 | runOnlyForDeploymentPostprocessing = 0; 179 | shellPath = /bin/sh; 180 | shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 181 | showEnvVarsInLog = 0; 182 | }; 183 | FC29032C0717E1CCFA494848 /* [CP] Copy Pods Resources */ = { 184 | isa = PBXShellScriptBuildPhase; 185 | buildActionMask = 2147483647; 186 | files = ( 187 | ); 188 | inputFileListPaths = ( 189 | "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-input-files.xcfilelist", 190 | ); 191 | name = "[CP] Copy Pods Resources"; 192 | outputFileListPaths = ( 193 | "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-output-files.xcfilelist", 194 | ); 195 | runOnlyForDeploymentPostprocessing = 0; 196 | shellPath = /bin/sh; 197 | shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n"; 198 | showEnvVarsInLog = 0; 199 | }; 200 | /* End PBXShellScriptBuildPhase section */ 201 | 202 | /* Begin PBXSourcesBuildPhase section */ 203 | A93A953329CC810C00F8E227 /* Sources */ = { 204 | isa = PBXSourcesBuildPhase; 205 | buildActionMask = 2147483647; 206 | files = ( 207 | A93A953B29CC810C00F8E227 /* iosApp.swift in Sources */, 208 | ); 209 | runOnlyForDeploymentPostprocessing = 0; 210 | }; 211 | /* End PBXSourcesBuildPhase section */ 212 | 213 | /* Begin XCBuildConfiguration section */ 214 | A93A954329CC810D00F8E227 /* Debug */ = { 215 | isa = XCBuildConfiguration; 216 | buildSettings = { 217 | ALWAYS_SEARCH_USER_PATHS = NO; 218 | CLANG_ANALYZER_NONNULL = YES; 219 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 220 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 221 | CLANG_ENABLE_MODULES = YES; 222 | CLANG_ENABLE_OBJC_ARC = YES; 223 | CLANG_ENABLE_OBJC_WEAK = YES; 224 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 225 | CLANG_WARN_BOOL_CONVERSION = YES; 226 | CLANG_WARN_COMMA = YES; 227 | CLANG_WARN_CONSTANT_CONVERSION = YES; 228 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 229 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 230 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 231 | CLANG_WARN_EMPTY_BODY = YES; 232 | CLANG_WARN_ENUM_CONVERSION = YES; 233 | CLANG_WARN_INFINITE_RECURSION = YES; 234 | CLANG_WARN_INT_CONVERSION = YES; 235 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 236 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 237 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 238 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 239 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 240 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 241 | CLANG_WARN_STRICT_PROTOTYPES = YES; 242 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 243 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 244 | CLANG_WARN_UNREACHABLE_CODE = YES; 245 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 246 | COPY_PHASE_STRIP = NO; 247 | DEBUG_INFORMATION_FORMAT = dwarf; 248 | ENABLE_STRICT_OBJC_MSGSEND = YES; 249 | ENABLE_TESTABILITY = YES; 250 | GCC_C_LANGUAGE_STANDARD = gnu11; 251 | GCC_DYNAMIC_NO_PIC = NO; 252 | GCC_NO_COMMON_BLOCKS = YES; 253 | GCC_OPTIMIZATION_LEVEL = 0; 254 | GCC_PREPROCESSOR_DEFINITIONS = ( 255 | "DEBUG=1", 256 | "$(inherited)", 257 | ); 258 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 259 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 260 | GCC_WARN_UNDECLARED_SELECTOR = YES; 261 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 262 | GCC_WARN_UNUSED_FUNCTION = YES; 263 | GCC_WARN_UNUSED_VARIABLE = YES; 264 | IPHONEOS_DEPLOYMENT_TARGET = 16.2; 265 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 266 | MTL_FAST_MATH = YES; 267 | ONLY_ACTIVE_ARCH = YES; 268 | SDKROOT = iphoneos; 269 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 270 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 271 | }; 272 | name = Debug; 273 | }; 274 | A93A954429CC810D00F8E227 /* Release */ = { 275 | isa = XCBuildConfiguration; 276 | buildSettings = { 277 | ALWAYS_SEARCH_USER_PATHS = NO; 278 | CLANG_ANALYZER_NONNULL = YES; 279 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 280 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 281 | CLANG_ENABLE_MODULES = YES; 282 | CLANG_ENABLE_OBJC_ARC = YES; 283 | CLANG_ENABLE_OBJC_WEAK = YES; 284 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 285 | CLANG_WARN_BOOL_CONVERSION = YES; 286 | CLANG_WARN_COMMA = YES; 287 | CLANG_WARN_CONSTANT_CONVERSION = YES; 288 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 289 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 290 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 291 | CLANG_WARN_EMPTY_BODY = YES; 292 | CLANG_WARN_ENUM_CONVERSION = YES; 293 | CLANG_WARN_INFINITE_RECURSION = YES; 294 | CLANG_WARN_INT_CONVERSION = YES; 295 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 296 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 297 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 298 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 299 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 300 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 301 | CLANG_WARN_STRICT_PROTOTYPES = YES; 302 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 303 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 304 | CLANG_WARN_UNREACHABLE_CODE = YES; 305 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 306 | COPY_PHASE_STRIP = NO; 307 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 308 | ENABLE_NS_ASSERTIONS = NO; 309 | ENABLE_STRICT_OBJC_MSGSEND = YES; 310 | GCC_C_LANGUAGE_STANDARD = gnu11; 311 | GCC_NO_COMMON_BLOCKS = YES; 312 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 313 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 314 | GCC_WARN_UNDECLARED_SELECTOR = YES; 315 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 316 | GCC_WARN_UNUSED_FUNCTION = YES; 317 | GCC_WARN_UNUSED_VARIABLE = YES; 318 | IPHONEOS_DEPLOYMENT_TARGET = 16.2; 319 | MTL_ENABLE_DEBUG_INFO = NO; 320 | MTL_FAST_MATH = YES; 321 | SDKROOT = iphoneos; 322 | SWIFT_COMPILATION_MODE = wholemodule; 323 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 324 | VALIDATE_PRODUCT = YES; 325 | }; 326 | name = Release; 327 | }; 328 | A93A954629CC810D00F8E227 /* Debug */ = { 329 | isa = XCBuildConfiguration; 330 | baseConfigurationReference = 73FFDDCA9C728FEE3DFEF2F4 /* Pods-iosApp.debug.xcconfig */; 331 | buildSettings = { 332 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 333 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 334 | CODE_SIGN_STYLE = Automatic; 335 | CURRENT_PROJECT_VERSION = 1; 336 | DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; 337 | ENABLE_PREVIEWS = YES; 338 | GENERATE_INFOPLIST_FILE = YES; 339 | INFOPLIST_FILE = iosApp/Info.plist; 340 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 341 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 342 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 343 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 344 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 345 | LD_RUNPATH_SEARCH_PATHS = ( 346 | "$(inherited)", 347 | "@executable_path/Frameworks", 348 | ); 349 | MARKETING_VERSION = 1.0; 350 | PRODUCT_BUNDLE_IDENTIFIER = com.softartdev.kronos.iosApp; 351 | PRODUCT_NAME = "Kronos Multiplatform"; 352 | SWIFT_EMIT_LOC_STRINGS = YES; 353 | SWIFT_VERSION = 5.0; 354 | TARGETED_DEVICE_FAMILY = "1,2"; 355 | }; 356 | name = Debug; 357 | }; 358 | A93A954729CC810D00F8E227 /* Release */ = { 359 | isa = XCBuildConfiguration; 360 | baseConfigurationReference = 05E67C72B2BDBC81379103CB /* Pods-iosApp.release.xcconfig */; 361 | buildSettings = { 362 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 363 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 364 | CODE_SIGN_STYLE = Automatic; 365 | CURRENT_PROJECT_VERSION = 1; 366 | DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; 367 | ENABLE_PREVIEWS = YES; 368 | GENERATE_INFOPLIST_FILE = YES; 369 | INFOPLIST_FILE = iosApp/Info.plist; 370 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 371 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 372 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 373 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 374 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 375 | LD_RUNPATH_SEARCH_PATHS = ( 376 | "$(inherited)", 377 | "@executable_path/Frameworks", 378 | ); 379 | MARKETING_VERSION = 1.0; 380 | PRODUCT_BUNDLE_IDENTIFIER = com.softartdev.kronos.iosApp; 381 | PRODUCT_NAME = "Kronos Multiplatform"; 382 | SWIFT_EMIT_LOC_STRINGS = YES; 383 | SWIFT_VERSION = 5.0; 384 | TARGETED_DEVICE_FAMILY = "1,2"; 385 | }; 386 | name = Release; 387 | }; 388 | /* End XCBuildConfiguration section */ 389 | 390 | /* Begin XCConfigurationList section */ 391 | A93A953229CC810C00F8E227 /* Build configuration list for PBXProject "iosApp" */ = { 392 | isa = XCConfigurationList; 393 | buildConfigurations = ( 394 | A93A954329CC810D00F8E227 /* Debug */, 395 | A93A954429CC810D00F8E227 /* Release */, 396 | ); 397 | defaultConfigurationIsVisible = 0; 398 | defaultConfigurationName = Release; 399 | }; 400 | A93A954529CC810D00F8E227 /* Build configuration list for PBXNativeTarget "iosApp" */ = { 401 | isa = XCConfigurationList; 402 | buildConfigurations = ( 403 | A93A954629CC810D00F8E227 /* Debug */, 404 | A93A954729CC810D00F8E227 /* Release */, 405 | ); 406 | defaultConfigurationIsVisible = 0; 407 | defaultConfigurationName = Release; 408 | }; 409 | /* End XCConfigurationList section */ 410 | }; 411 | rootObject = A93A952F29CC810C00F8E227 /* Project object */; 412 | } 413 | --------------------------------------------------------------------------------