├── .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 | 
4 | [](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 |
--------------------------------------------------------------------------------