diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..b4d7ef13
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,10 @@
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+charset = utf-8
+trim_trailing_whitespace = true
+max_line_length = 100
diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md
new file mode 100644
index 00000000..ef5931e5
--- /dev/null
+++ b/.github/CODE_OF_CONDUCT.md
@@ -0,0 +1,6 @@
+Be reasonable and respectful. This repository, discussions within and artifacts derived from it are
+freely available, but it is still expected that you act within reasonable boundaries, whether you
+are a source contributor or not.
+
+The owners reserve the right to impose temporary and/or permanent restrictions against particular
+users on this repository as well as any others under the same ownership if deemed necessary.
diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
new file mode 100644
index 00000000..d27a92d2
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -0,0 +1,18 @@
+name: Bug Report
+description: Report a bug in the project
+body:
+ - type: markdown
+ attributes:
+ value: |
+ **Please include:**
+
+ * Clear reproduction steps.
+ * Frequency with which the steps above reproduce the bug (if not 100%).
+ * Expected result(s) of the reproduction steps, as well as the actual ones.
+
+ Detailed reports are more likely to be addressed faster.
+ - type: textarea
+ attributes:
+ label: Description
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..3ba13e0c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1 @@
+blank_issues_enabled: false
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 00000000..48f0420a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,8 @@
+name: Feature request
+description: Request new functionality
+body:
+ - type: textarea
+ attributes:
+ label: Description
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml
new file mode 100644
index 00000000..40419c52
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/question.yml
@@ -0,0 +1,8 @@
+name: Question
+description: If you have any doubts, just ask
+body:
+ - type: textarea
+ attributes:
+ label: Description
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/vulnerability.yml b/.github/ISSUE_TEMPLATE/vulnerability.yml
new file mode 100644
index 00000000..3f1db6fc
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/vulnerability.yml
@@ -0,0 +1,13 @@
+name: Security vulnerability
+description: Report a vulnerability on any versions of the tool
+body:
+ - type: textarea
+ attributes:
+ label: Description
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: What versions is this vulnerability observed on?
+ validations:
+ required: true
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 00000000..0d404604
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,2 @@
+No pre-defined structure is enforced, but descriptive PRs are likely to be
+attended earlier.
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
new file mode 100644
index 00000000..f88683ab
--- /dev/null
+++ b/.github/SECURITY.md
@@ -0,0 +1,4 @@
+Report vulnerabilities as vulnerability-type issues.
+
+Find vulnerabilities as [issues with the `vulnerability` label](
+https://github.com/tidal-music/network-time/issues?q=is%3Aissue+is%3Aopen+label%3Avulnerability).
diff --git a/.github/actions/runGradleTask/action.yml b/.github/actions/runGradleTask/action.yml
new file mode 100644
index 00000000..61cf7188
--- /dev/null
+++ b/.github/actions/runGradleTask/action.yml
@@ -0,0 +1,20 @@
+name: Setup Gradle and run a task on all modules that have it
+description: Includes setting up JDK
+inputs:
+ task:
+ description: The task to run
+ required: true
+ preTaskString:
+ description: A String to pass to the Gradle invocation before the task. This can be used for example to pass properties
+ required: false
+ default: ""
+runs:
+ using: composite
+ steps:
+ - uses: actions/setup-java@v3.9.0
+ with:
+ distribution: temurin
+ java-version: 17
+ cache: gradle
+ - run: ./gradlew ${{ inputs.preTaskString }} ${{ inputs.task }}
+ shell: bash
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 00000000..7c2033f6
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,25 @@
+name: Build
+on:
+ push:
+ branches:
+ - '**'
+jobs:
+ build:
+ runs-on: macos-14-xlarge
+ strategy:
+ fail-fast: false
+ matrix:
+ xcodeversion: [ 14.3.1, 15.1, 15.3 ]
+ steps:
+ - uses: actions/checkout@v4.1.6
+ - uses: maxim-lobanov/setup-xcode@v1.6.0
+ with:
+ xcode-version: ${{ matrix.xcodeversion }}
+ - uses: ./.github/actions/runGradleTask
+ with:
+ task: build
+ barrier-build:
+ runs-on: ubuntu-22.04
+ needs: [ build ]
+ steps:
+ - run: exit 0
diff --git a/.github/workflows/dependencyReport.yml b/.github/workflows/dependencyReport.yml
new file mode 100644
index 00000000..cd5de2a2
--- /dev/null
+++ b/.github/workflows/dependencyReport.yml
@@ -0,0 +1,37 @@
+name: Submit dependencies
+on:
+ push:
+ branches:
+ - main
+permissions:
+ contents: write
+jobs:
+ library:
+ runs-on: macos-14-xlarge
+ steps:
+ - uses: actions/checkout@v4.1.6
+ - uses: actions/setup-java@v3.9.0
+ with:
+ distribution: temurin
+ java-version: 17
+ cache: gradle
+ - run: .scripts/dependency_report_generate.sh -m library -c allSourceSetsCompileDependenciesMetadata > dependencies_library_raw
+ - run: .scripts/github/dependency_report_as_github_json.sh -i dependencies_library_raw -n library -s $(TZ=UTC date +"%Y-%m-%dT%H:%M:%SZ") -l library/build.gradle.kts > dependencies_library.github.json
+ - run: |
+ echo RESPONSE_CODE=$(curl -L \
+ -X POST \
+ -H "Accept: application/vnd.github+json" \
+ -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ https://api.github.com/repos/$GITHUB_REPOSITORY/dependency-graph/snapshots \
+ --data-binary "@dependencies_library.github.json" \
+ -o /dev/null \
+ -w '%{http_code}') >> $GITHUB_ENV
+ - run: |
+ if [[ "$RESPONSE_CODE" == "201" ]];
+ then
+ exit 0
+ else
+ echo "Dependency submission failed with HTTP code $RESPONSE_CODE"
+ exit 1
+ fi
diff --git a/.github/workflows/manual_publishing_tag_warning.yml b/.github/workflows/manual_publishing_tag_warning.yml
new file mode 100644
index 00000000..622c57e0
--- /dev/null
+++ b/.github/workflows/manual_publishing_tag_warning.yml
@@ -0,0 +1,12 @@
+name: Manual publishing tag warning
+on:
+ push:
+ tags:
+ - '[1-9]+.[0-9]+.[0-9]+'
+ - '![1-9]+.[0-9]+.[0-9]+-dispatch'
+jobs:
+ print-warning:
+ runs-on: ubuntu-22.04
+ steps:
+ - run: echo "Releases must be dispatched using .scripts/dispatch_release.sh"
+ - run: exit 1
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 00000000..7f600b44
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,29 @@
+name: Publish
+on: workflow_dispatch
+env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+concurrency:
+ group: ${{ github.ref_name }}
+jobs:
+ create-github-release:
+ runs-on: ubuntu-22.04
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4.1.6
+ with:
+ ref: ${{ github.ref_name }}
+ - run: .scripts/github/release.sh
+ publish-maven-publications:
+ runs-on: macos-14-xlarge
+ permissions:
+ packages: write
+ steps:
+ - uses: actions/checkout@v4.1.6
+ with:
+ ref: ${{ github.ref_name }}
+ - uses: ./.github/actions/runGradleTask
+ with:
+ preTaskString: -Pversion=$GITHUB_REF_NAME
+ # TODO Change this to publishToMavenCentral
+ task: :library:publishAllPublicationsToGithubPackagesRepository --continue --no-configuration-cache
diff --git a/.github/workflows/publish_process_dispatch.yml b/.github/workflows/publish_process_dispatch.yml
new file mode 100644
index 00000000..fa31b171
--- /dev/null
+++ b/.github/workflows/publish_process_dispatch.yml
@@ -0,0 +1,35 @@
+name: Publish
+on:
+ push:
+ tags:
+ - '[1-9]+.[0-9]+.[0-9]+-dispatch'
+env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+concurrency:
+ group: ${{ github.ref_name }}
+jobs:
+ ensure-tag-is-on-main:
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@v4.1.6
+ with:
+ ref: main
+ fetch-depth: 0
+ - run: exit $(git merge-base --is-ancestor $GITHUB_REF_NAME HEAD)
+ update-xcframeworks-and-dispatch-publishing:
+ runs-on: macos-14-xlarge
+ needs: [ ensure-tag-is-on-main ]
+ permissions:
+ actions: write
+ contents: write
+ steps:
+ - uses: actions/checkout@v4.1.6
+ - run: |
+ git config --local user.name $GITHUB_ACTOR
+ git config --local user.email noreply@github.com
+ - run: |
+ TAG=$(echo $GITHUB_REF_NAME | tr -d -- -dispatch)
+ .scripts/github/update_xcframeworks.sh
+ git tag $TAG -m $TAG
+ git push origin tag $TAG
+ gh workflow run publish.yml --ref $TAG
diff --git a/.github/workflows/staticAnalysis.yml b/.github/workflows/staticAnalysis.yml
new file mode 100644
index 00000000..df8a6952
--- /dev/null
+++ b/.github/workflows/staticAnalysis.yml
@@ -0,0 +1,29 @@
+name: Static analysis
+on:
+ push:
+ branches:
+ - '**'
+jobs:
+ ktlint:
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@v4.1.6
+ - run: .scripts/check_ktlint.sh
+ codeql:
+ runs-on: macos-14-xlarge
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+ steps:
+ - uses: actions/checkout@v4.1.6
+ - uses: github/codeql-action/init@v2.21.3
+ with:
+ languages: kotlin
+ - uses: ./.github/actions/runGradleTask
+ with:
+ task: build
+ - uses: github/codeql-action/analyze@v2.21.3
+ with:
+ category: "/language:kotlin"
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..dd85f98a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.build
+.DS_Store
+.gradle
+build/
+local.properties
+TidalNetworkTime-xcodebuild-*.xcframework
+xcuserdata/
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 00000000..8e429dfc
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,14 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# Gradle auto-import
+*.iml
+modules.xml
+gradle.xml
+usage.statistics.xml
+libraries.xml
diff --git a/.idea/artifacts/library_jvm.xml b/.idea/artifacts/library_jvm.xml
new file mode 100644
index 00000000..757db4f6
--- /dev/null
+++ b/.idea/artifacts/library_jvm.xml
@@ -0,0 +1,8 @@
+
+
+ $PROJECT_DIR$/library/build/libs
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/artifacts/samples_desktop_jvm.xml b/.idea/artifacts/samples_desktop_jvm.xml
new file mode 100644
index 00000000..2d4ea399
--- /dev/null
+++ b/.idea/artifacts/samples_desktop_jvm.xml
@@ -0,0 +1,6 @@
+
+
+ $PROJECT_DIR$/samples/desktop/build/libs
+
+
+
\ No newline at end of file
diff --git a/.idea/artifacts/samples_multiplatform_kotlin_desktop_jvm.xml b/.idea/artifacts/samples_multiplatform_kotlin_desktop_jvm.xml
new file mode 100644
index 00000000..18f136fb
--- /dev/null
+++ b/.idea/artifacts/samples_multiplatform_kotlin_desktop_jvm.xml
@@ -0,0 +1,8 @@
+
+
+ $PROJECT_DIR$/samples/multiplatform-kotlin/desktop/build/libs
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/artifacts/samples_multiplatform_kotlin_jvm_jvm.xml b/.idea/artifacts/samples_multiplatform_kotlin_jvm_jvm.xml
new file mode 100644
index 00000000..4f270c13
--- /dev/null
+++ b/.idea/artifacts/samples_multiplatform_kotlin_jvm_jvm.xml
@@ -0,0 +1,8 @@
+
+
+ $PROJECT_DIR$/samples/multiplatform-kotlin/jvm/build/libs
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/artifacts/samples_multiplatform_kotlin_shared_jvm.xml b/.idea/artifacts/samples_multiplatform_kotlin_shared_jvm.xml
new file mode 100644
index 00000000..036e9d10
--- /dev/null
+++ b/.idea/artifacts/samples_multiplatform_kotlin_shared_jvm.xml
@@ -0,0 +1,8 @@
+
+
+ $PROJECT_DIR$/samples/multiplatform-kotlin/shared/build/libs
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/artifacts/samples_shared_jvm.xml b/.idea/artifacts/samples_shared_jvm.xml
new file mode 100644
index 00000000..5802e6a2
--- /dev/null
+++ b/.idea/artifacts/samples_shared_jvm.xml
@@ -0,0 +1,6 @@
+
+
+ $PROJECT_DIR$/samples/shared/build/libs
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
new file mode 100644
index 00000000..55c0e02f
--- /dev/null
+++ b/.idea/codeStyles/Project.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 00000000..79ee123c
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 00000000..b589d56e
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 00000000..bff9a9f7
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
new file mode 100644
index 00000000..d2ce72d1
--- /dev/null
+++ b/.idea/jarRepositories.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 00000000..fdf8d994
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 00000000..ea22bc31
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 00000000..35eb1ddf
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.scripts/check_ktlint.sh b/.scripts/check_ktlint.sh
new file mode 100755
index 00000000..3a4ca63f
--- /dev/null
+++ b/.scripts/check_ktlint.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+set -e
+
+KTLINT_VERSION="0.50.0"
+TARGET="build/ktlint-$KTLINT_VERSION/ktlint"
+
+if [ ! -f "$TARGET" ]; then
+ mkdir -p "$(dirname "$TARGET")"
+ curl -L "https://github.com/pinterest/ktlint/releases/download/$KTLINT_VERSION/ktlint" > "$TARGET"
+ chmod u+x "$TARGET"
+fi
+
+# shellcheck disable=SC2046
+"$TARGET" --reporter plain $(git ls-tree --full-tree --name-only -r HEAD | grep -e "\.kt" -e "\.kts")
diff --git a/.scripts/dependency_report_generate.sh b/.scripts/dependency_report_generate.sh
new file mode 100755
index 00000000..64ce4d86
--- /dev/null
+++ b/.scripts/dependency_report_generate.sh
@@ -0,0 +1,50 @@
+#!/bin/bash
+set -e
+
+DIR_TMP="build/report_dependencies"
+rm -rf $DIR_TMP || true
+mkdir -p $DIR_TMP
+
+print_usage()
+{
+ echo "Usage: $0 -m -c "
+}
+
+while getopts ":m:c:" OPT; do
+ case $OPT in
+ m) MODULE="$OPTARG"
+ ;;
+ c) CONFIGURATION="$OPTARG"
+ ;;
+ ?) print_usage
+ exit 1
+ ;;
+ esac
+done
+if [ -z "${MODULE+x}" ]; then
+ print_usage
+ exit 1
+fi
+if [ -z "${CONFIGURATION+x}" ]; then
+ print_usage
+ exit 1
+fi
+
+read -r -d '' -a WITH_ADJUSTED < <(./gradlew --console=plain "$MODULE":dependencies --configuration "$CONFIGURATION" | grep --color=never -o "\S*:.*:.*" | grep --color=never -v "/" | fgrep --color=never -v "$MODULE:dependencies" | tr -d " " | sed 's/(\*)//' | sed 's/(c)//' && printf '\0' )
+
+RESOLVED=()
+REGEX_PATTERN_DEPENDENCY_WITH_VERSION_UPGRADED='.*:.*:.*->.*'
+for DEPENDENCY in "${WITH_ADJUSTED[@]}"
+do
+ if [[ "$DEPENDENCY" =~ $REGEX_PATTERN_DEPENDENCY_WITH_VERSION_UPGRADED ]]; then
+ RESOLVED+=("$(echo "$DEPENDENCY" | grep -o ".*:.*:")$(echo "$DEPENDENCY" | cut -d ">" -f2)")
+ else
+ RESOLVED+=("$DEPENDENCY")
+ fi
+done
+FILE_TMP=$DIR_TMP/"tmp"
+for DEPENDENCY in "${RESOLVED[@]}"
+do
+ echo "$DEPENDENCY" >> $FILE_TMP
+done
+sort -u $FILE_TMP
diff --git a/.scripts/dispatch_release.sh b/.scripts/dispatch_release.sh
new file mode 100755
index 00000000..85f563ac
--- /dev/null
+++ b/.scripts/dispatch_release.sh
@@ -0,0 +1,45 @@
+#!/bin/bash
+set -e
+
+usage() {
+ echo "Usage: $0 -v " >&2
+ exit 1
+}
+
+VERSION=""
+
+if [ -n "$(git status --porcelain)" ]; then
+ echo "Cannot tag from a dirty working tree"
+ exit 1
+fi
+
+while getopts ":v:" opt; do
+ case $opt in
+ v)
+ VERSION=$OPTARG
+ ;;
+ \?)
+ echo "Invalid option: -$OPTARG" >&2
+ exit 1
+ ;;
+ *)
+ usage
+ ;;
+ esac
+done
+
+if [ -z "$VERSION" ]; then
+ usage
+fi
+
+FILE_CHANGELOG="changelog/$VERSION"
+if [ ! -f "$FILE_CHANGELOG" ]; then
+ echo "Changelog file $FILE_CHANGELOG missing"
+ exit 1
+fi
+
+git fetch &&
+ TAG="${VERSION}-dispatch"
+ git tag "${TAG}" "HEAD" -m "Release dispatch for ${VERSION}" &&
+ git push origin tag "${TAG}" &&
+ echo "New release ${VERSION} for revision $(git rev-parse --short "HEAD") requested successfully"
diff --git a/.scripts/github/dependency_report_as_github_json.sh b/.scripts/github/dependency_report_as_github_json.sh
new file mode 100755
index 00000000..1f127c2b
--- /dev/null
+++ b/.scripts/github/dependency_report_as_github_json.sh
@@ -0,0 +1,87 @@
+#!/bin/bash
+set -e
+
+print_usage()
+{
+ echo "Usage: $0 -i -n -s -l "
+}
+
+while getopts ":i:n:s:l:" OPT; do
+ case $OPT in
+ i) INPUT_FILE="$OPTARG"
+ ;;
+ n) MANIFEST_NAME="$OPTARG"
+ ;;
+ s) SCANNED_AT="$OPTARG"
+ ;;
+ l) SOURCE_LOCATION="$OPTARG"
+ ;;
+ ?) print_usage
+ exit 1
+ ;;
+ esac
+done
+if [ -z "${INPUT_FILE+x}" ]; then
+ print_usage
+ exit 1
+fi
+if [ -z "${MANIFEST_NAME+x}" ]; then
+ print_usage
+ exit 1
+fi
+if [ -z "${SCANNED_AT+x}" ]; then
+ print_usage
+ exit 1
+fi
+if [ -z "${SOURCE_LOCATION+x}" ]; then
+ print_usage
+ exit 1
+fi
+
+JSON=$(jq --null-input \
+--argjson VERSION "$(git rev-list --count HEAD)" \
+--arg SHA "$GITHUB_SHA" \
+--arg REF "$GITHUB_REF" \
+--arg CORRELATOR "$GITHUB_WORKFLOW"_"$GITHUB_JOB" \
+--arg RUN_ID "$GITHUB_RUN_ID" \
+--arg HTML_URL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
+--arg DETECTOR_NAME "$GITHUB_REPOSITORY" \
+--arg DETECTOR_VERSION 5 \
+--arg DETECTOR_URL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \
+--arg SCANNED "$SCANNED_AT" \
+--arg MANIFEST_NAME "$MANIFEST_NAME" \
+--arg SOURCE_LOCATION "$SOURCE_LOCATION" \
+'
+{
+ "version":$VERSION,
+ "sha":$SHA,
+ "ref":$REF,
+ "job":{
+ "correlator":$CORRELATOR,
+ "id":$RUN_ID,
+ "html_url":$HTML_URL
+ },
+ "detector":{
+ "name":$DETECTOR_NAME,
+ "version":$DETECTOR_VERSION,
+ "url":$DETECTOR_URL
+ },
+ "scanned":$SCANNED,
+ "manifests":{
+ ($MANIFEST_NAME):{
+ "name":$MANIFEST_NAME,
+ "file": {
+ "source_location":$SOURCE_LOCATION
+ },
+ "resolved":{
+ }
+ }
+ }
+}
+')
+
+while IFS= read -r LINE
+do JSON=$(jq '.manifests.'"$MANIFEST_NAME"'.resolved += {"'"$LINE"'": {"package_url": "pkg:maven/'"$(echo "$LINE" | tr ':' '/' | sed 's/\(.*\)\//\1@/')"'"}}' <<< "$JSON")
+done < "$INPUT_FILE"
+
+jq -r tostring <<< "$JSON"
diff --git a/.scripts/github/release.sh b/.scripts/github/release.sh
new file mode 100755
index 00000000..89df2f29
--- /dev/null
+++ b/.scripts/github/release.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+set -e
+
+git fetch --tags
+
+RELEASE_BODY=$(cat "$GITHUB_WORKSPACE"/changelog/"$GITHUB_REF_NAME")
+
+BODY="{\"tag_name\": \"${GITHUB_REF_NAME}\",\"target_commitish\": \"${GITHUB_SHA}\",\"name\": \"${GITHUB_REF_NAME}\",\"body\": \"${RELEASE_BODY}\"}"
+
+curl -sL \
+ -H "Accept: application/vnd.github+json" \
+ -H "Authorization: Bearer ${GITHUB_TOKEN}" \
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ --request POST \
+ --data "${BODY}" \
+ https://api.github.com/repos/"${GITHUB_REPOSITORY}"/releases
diff --git a/.scripts/github/update_xcframeworks.sh b/.scripts/github/update_xcframeworks.sh
new file mode 100755
index 00000000..b0be5a09
--- /dev/null
+++ b/.scripts/github/update_xcframeworks.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+set -e
+
+XCODEBUILD_VERSIONS=("14.3.1" "15.1" "15.3")
+
+for XCODEBUILD_VERSION in "${XCODEBUILD_VERSIONS[@]}"
+do
+ sudo xcode-select -s /Applications/Xcode_"$XCODEBUILD_VERSION".app
+ ./gradlew library:clean library:assembleTidalNetworkTimeReleaseXCFramework
+ mv library/build/XCFrameworks/release/TidalNetworkTime.xcframework TidalNetworkTime-xcodebuild-"$XCODEBUILD_VERSION".xcframework
+ git add -A -f
+ git commit -m "XCFramework generation for version $TAG (xcodebuild $XCODEBUILD_VERSION)"
+done
diff --git a/.scripts/hooks/pre-commit b/.scripts/hooks/pre-commit
new file mode 100755
index 00000000..781d2e14
--- /dev/null
+++ b/.scripts/hooks/pre-commit
@@ -0,0 +1,38 @@
+#!/bin/bash
+
+REPORT_DIR="build/pre-commit"
+rm -rf $REPORT_DIR || true
+mkdir -p $REPORT_DIR
+
+PIDSTOOUTPUTFILES=()
+TASK_FILE_BUILD=${REPORT_DIR}/build.log
+./gradlew build > $TASK_FILE_BUILD 2>&1 &
+PIDSTOOUTPUTFILES+=("$!:$TASK_FILE_BUILD")
+
+TASK_FILE_KTLINT=${REPORT_DIR}/ktlint.log
+./.scripts/check_ktlint.sh > $TASK_FILE_KTLINT 2>&1 &
+PIDSTOOUTPUTFILES+=("$!:$TASK_FILE_KTLINT")
+
+while :
+do
+ INDEX=-1
+ for PIDTOOUTPUTFILE in "${PIDSTOOUTPUTFILES[@]}"; do
+ INDEX=$INDEX+1
+ PID=${PIDTOOUTPUTFILE%%:*}
+ if ! ps -p "$PID" > /dev/null
+ then
+ if wait "$PID"; then
+ unset 'PIDSTOOUTPUTFILES[$INDEX]'
+ PIDSTOOUTPUTFILES=("${PIDSTOOUTPUTFILES[@]}")
+ if [ ${#PIDSTOOUTPUTFILES[@]} -eq 0 ]; then
+ exit 0
+ fi
+ else
+ jobs -p | xargs kill
+ cat "${PIDTOOUTPUTFILE#*:}"
+ exit 1
+ fi
+ fi
+ done
+ sleep 1
+done
diff --git a/.sdkmanrc b/.sdkmanrc
new file mode 100644
index 00000000..49f2c8cd
--- /dev/null
+++ b/.sdkmanrc
@@ -0,0 +1,3 @@
+# Enable auto-env through the sdkman_auto_env config
+# Add key=value pairs of SDKs to use below
+java=17.0.7-tem
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..0f2b6301
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,7 @@
+### Contributing code
+
+1. Fork the repository.
+2. Create your branch.
+3. Commit your changes.
+4. Push the branch to your fork.
+5. Create a new pull request on this repository from your branch.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..c83043d4
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2024 Block, Inc.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 00000000..08df7130
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,39 @@
+// swift-tools-version:5.8
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import Foundation
+import PackageDescription
+
+#if swift(>=5.10)
+ let xcodeBuildVersion = "15.3"
+#elseif swift(>=5.9)
+ let xcodeBuildVersion = "15.1"
+#elseif swift(>=5.8.1)
+ let xcodeBuildVersion = "14.3.1"
+#else
+ throw VersionBelowMinimumError()
+#endif
+
+let package = Package(
+ name: "TidalNetworkTime",
+ products: [
+ .library(
+ name: "TidalNetworkTime",
+ targets: ["TidalNetworkTime"]
+ ),
+ ],
+ targets: [
+ .binaryTarget(
+ name: "TidalNetworkTime",
+ path: "TidalNetworkTime-xcodebuild-" + xcodeBuildVersion + ".xcframework"
+ ),
+ ]
+)
+
+struct UnsupportedVersionError: LocalizedError {
+ let description: String
+
+ var errorDescription: String? {
+ "This version of Swift is too old and therefore unsupported."
+ }
+}
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..cbfb5a4d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,45 @@
+# network-time
+
+A Kotlin multiplatform implementation of an SNTP client.
+
+## Importing
+
+
+Maven
+
+```kotlin
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation("com.tidal.networktime:library:$VERSION")
+}
+```
+
+
+
+
+Swift Package Manager
+
+```swift
+dependencies: [
+ .package(url: "https://github.com/tidal-music/network-time.git", .upToNextMajor(from: "$VERSION"))
+]
+```
+
+If you plan to use this tool from Objective-C, all public API symbols are prefixed with TNT (for TidalNetworkTime) to avoid naming conflicts.
+
+
+
+Version numbers can be found under [Releases](https://github.com/tidal-music/network-time/releases).
+
+## Usage
+
+Create your `SNTPClient` via its constructor. Its API allows you to toggle synchronization (starts
+off) and to retrieve the time based on the last successful synchronization, if any.
+
+The property that retrieves the aforementioned time is nullable as it will return null if no
+synchronization has occurred successfully during the lifetime of the process and no backup file has
+been specified for the `SNTPClient` instance or said file contains no valid prior synchronization
+data.
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 00000000..bb35a07b
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,20 @@
+buildscript {
+ repositories {
+ gradlePluginPortal()
+ google()
+ }
+ dependencies {
+ val kotlinVersion = "1.9.23"
+ classpath(kotlin("gradle-plugin", version = kotlinVersion))
+ classpath(kotlin("serialization", version = kotlinVersion))
+ classpath("com.android.tools.build:gradle:8.1.4")
+ classpath("org.jetbrains.compose:compose-gradle-plugin:1.6.1")
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 00000000..78cb8b27
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,5 @@
+android.useAndroidX=true
+org.gradle.jvmargs=-Xmx16g
+org.jetbrains.compose.experimental.uikit.enabled=true
+org.jetbrains.compose.experimental.macos.enabled=true
+kotlin.mpp.enableCInteropCommonization=true
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..7f93135c
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..3fa8f862
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 00000000..1aa94a42
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 00000000..93e3f59f
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/library/build.gradle.kts b/library/build.gradle.kts
new file mode 100644
index 00000000..d6689943
--- /dev/null
+++ b/library/build.gradle.kts
@@ -0,0 +1,96 @@
+import com.vanniktech.maven.publish.JavadocJar
+import com.vanniktech.maven.publish.KotlinMultiplatform
+import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework
+import java.net.URI
+
+plugins {
+ id("com.vanniktech.maven.publish") version "0.28.0"
+ kotlin("multiplatform")
+ kotlin("plugin.serialization")
+}
+
+kotlin {
+ jvm()
+ val xCFrameworkName = "TidalNetworkTime"
+ val xCFramework = XCFramework(xCFrameworkName)
+ listOf(
+ macosX64(),
+ macosArm64(),
+ iosX64(),
+ iosArm64(),
+ iosSimulatorArm64(),
+ tvosSimulatorArm64(),
+ tvosX64(),
+ tvosArm64(),
+ ).forEach {
+ it.binaries.framework {
+ baseName = xCFrameworkName
+ binaryOption("bundleId", "TidalNetworkTime")
+ binaryOption("bundleShortVersionString", version as String)
+ binaryOption("bundleVersion", version as String)
+ isStatic = true
+ xCFramework.add(this)
+ }
+ it.compilations.configureEach { cinterops.create("NetworkFrameworkWorkaround") }
+ }
+
+ applyDefaultHierarchyTemplate()
+
+ sourceSets {
+ all {
+ languageSettings.optIn("kotlin.experimental.ExperimentalObjCName")
+ }
+ commonMain.get().dependencies {
+ api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
+ implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.1")
+ implementation("com.squareup.okio:okio:3.6.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.6.1")
+ }
+ }
+}
+
+group = "com.tidal.networktime"
+
+// TODO Delete this block
+publishing {
+ repositories {
+ maven {
+ name = "GithubPackages"
+ url = URI.create("https://maven.pkg.github.com/${System.getenv("GITHUB_REPOSITORY")}")
+ credentials {
+ username = System.getenv("GITHUB_ACTOR")
+ password = System.getenv("GITHUB_TOKEN")
+ }
+ }
+ }
+}
+
+mavenPublishing {
+ pom {
+ name = project.name
+ description = "SNTP client for JVM, Android, native Apple and Kotlin Multiplatform hosts."
+ inceptionYear = "2023"
+ url = "https://github.com/tidal-music/network-time"
+ developers {
+ developer {
+ id = "tidal"
+ name = "TIDAL"
+ }
+ }
+ licenses {
+ license {
+ name = "The Apache License, Version 2.0"
+ url = "https://www.apache.org/licenses/LICENSE-2.0.txt"
+ distribution = "https://www.apache.org/licenses/LICENSE-2.0.txt"
+ }
+ }
+ scm {
+ connection = "scm:git:git://github.com/tidal-music/network-time.git"
+ developerConnection = "scm:git:ssh://github.com:tidal-music/network-time.git"
+ url = "https://github.com/tidal-music/network-time/tree/master"
+ }
+ }
+ configure(KotlinMultiplatform(JavadocJar.None(), true))
+ // TODO uncomment this: signAllPublications()
+ // TODO uncomment this: publishToMavenCentral(SonatypeHost.DEFAULT, false) // TODO Change false to true to autopublish
+}
diff --git a/library/src/appleMain/kotlin/com/tidal/networktime/internal/FileSystemSupplier.kt b/library/src/appleMain/kotlin/com/tidal/networktime/internal/FileSystemSupplier.kt
new file mode 100644
index 00000000..064c7e1a
--- /dev/null
+++ b/library/src/appleMain/kotlin/com/tidal/networktime/internal/FileSystemSupplier.kt
@@ -0,0 +1,8 @@
+package com.tidal.networktime.internal
+
+import okio.FileSystem
+
+@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
+internal actual class FileSystemSupplier {
+ actual val system = FileSystem.SYSTEM
+}
diff --git a/library/src/appleMain/kotlin/com/tidal/networktime/internal/HostNameResolver.kt b/library/src/appleMain/kotlin/com/tidal/networktime/internal/HostNameResolver.kt
new file mode 100644
index 00000000..79629afd
--- /dev/null
+++ b/library/src/appleMain/kotlin/com/tidal/networktime/internal/HostNameResolver.kt
@@ -0,0 +1,138 @@
+package com.tidal.networktime.internal
+
+import kotlinx.cinterop.BooleanVar
+import kotlinx.cinterop.ByteVar
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.alloc
+import kotlinx.cinterop.allocArray
+import kotlinx.cinterop.convert
+import kotlinx.cinterop.memScoped
+import kotlinx.cinterop.pointed
+import kotlinx.cinterop.ptr
+import kotlinx.cinterop.readValue
+import kotlinx.cinterop.reinterpret
+import kotlinx.cinterop.toKString
+import kotlinx.cinterop.value
+import kotlinx.coroutines.withTimeoutOrNull
+import platform.CFNetwork.CFHostCancelInfoResolution
+import platform.CFNetwork.CFHostCreateWithName
+import platform.CFNetwork.CFHostGetAddressing
+import platform.CFNetwork.CFHostRef
+import platform.CFNetwork.CFHostStartInfoResolution
+import platform.CFNetwork.kCFHostAddresses
+import platform.CoreFoundation.CFArrayGetCount
+import platform.CoreFoundation.CFArrayGetValueAtIndex
+import platform.CoreFoundation.CFDataGetBytePtr
+import platform.CoreFoundation.CFDataRef
+import platform.CoreFoundation.CFRelease
+import platform.CoreFoundation.CFStringRef
+import platform.CoreFoundation.CFTypeRef
+import platform.CoreFoundation.kCFAllocatorDefault
+import platform.Foundation.CFBridgingRetain
+import platform.Foundation.NSString
+import platform.darwin.inet_ntop
+import platform.posix.AF_INET
+import platform.posix.AF_INET6
+import platform.posix.INET6_ADDRSTRLEN
+import platform.posix.INET_ADDRSTRLEN
+import platform.posix.sockaddr
+import platform.posix.sockaddr_in
+import platform.posix.sockaddr_in6
+import kotlin.time.Duration
+
+@OptIn(ExperimentalForeignApi::class)
+@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
+internal actual class HostNameResolver {
+ private var cfHost: CFHostRef? = null
+ private var hostReference: CFTypeRef? = null
+
+ actual suspend operator fun invoke(
+ hostName: String,
+ timeout: Duration,
+ includeINET: Boolean,
+ includeINET6: Boolean,
+ ): Iterable {
+ var ret: Iterable? = emptySet()
+ try {
+ ret = withTimeoutOrNull(timeout) { invokeInternal(hostName, includeINET, includeINET6) }
+ } finally {
+ cfHost
+ ?.takeIf { ret == null }
+ ?.let {
+ CFHostCancelInfoResolution(it, kCFHostAddresses)
+ }
+ clear()
+ }
+ return ret ?: emptySet()
+ }
+
+ private fun invokeInternal(
+ hostName: String,
+ includeINET: Boolean,
+ includeINET6: Boolean,
+ ): Iterable {
+ hostReference = CFBridgingRetain(hostName as NSString)
+ cfHost = CFHostCreateWithName(kCFAllocatorDefault, hostReference as CFStringRef)
+ CFHostStartInfoResolution(cfHost, kCFHostAddresses, null)
+ return memScoped {
+ val hasResolved = alloc {
+ value = false
+ }
+ val addresses = CFHostGetAddressing(cfHost, hasResolved.ptr)
+ addresses.takeIf { hasResolved.value }
+ addresses ?: return emptySet()
+ val count = CFArrayGetCount(addresses)
+ val ret = mutableSetOf()
+ (0 until count).forEach {
+ val socketAddressData = CFArrayGetValueAtIndex(addresses, it) as CFDataRef
+ val sockAddr = CFDataGetBytePtr(socketAddressData)!!.reinterpret().pointed
+ val addrPretty = when (sockAddr.sa_family.toInt()) {
+ AF_INET -> {
+ if (includeINET) {
+ val buffer = allocArray(INET_ADDRSTRLEN)
+ inet_ntop(
+ AF_INET,
+ sockAddr.reinterpret().sin_addr.readValue(),
+ buffer,
+ INET_ADDRSTRLEN.convert(),
+ )
+ buffer.toKString()
+ } else {
+ null
+ }
+ }
+
+ AF_INET6 -> {
+ if (includeINET6) {
+ val buffer = allocArray(INET6_ADDRSTRLEN)
+ inet_ntop(
+ AF_INET6,
+ sockAddr.reinterpret().sin6_addr.readValue(),
+ buffer,
+ INET6_ADDRSTRLEN.convert(),
+ )
+ buffer.toKString()
+ } else {
+ null
+ }
+ }
+
+ else -> {
+ null
+ }
+ }
+ if (addrPretty != null) {
+ ret.add(addrPretty)
+ }
+ }
+ ret
+ }
+ }
+
+ private fun clear() {
+ cfHost?.let { CFRelease(it) }
+ cfHost = null
+ hostReference?.let { CFRelease(it) }
+ hostReference = null
+ }
+}
diff --git a/library/src/appleMain/kotlin/com/tidal/networktime/internal/NTPUDPSocketOperations.kt b/library/src/appleMain/kotlin/com/tidal/networktime/internal/NTPUDPSocketOperations.kt
new file mode 100644
index 00000000..5324b949
--- /dev/null
+++ b/library/src/appleMain/kotlin/com/tidal/networktime/internal/NTPUDPSocketOperations.kt
@@ -0,0 +1,91 @@
+package com.tidal.networktime.internal
+
+import com.tidal.networktime.internal.network_framework_workaround.nw_connection_send_default_context
+import com.tidal.networktime.internal.network_framework_workaround.nw_parameters_create_secure_udp_disable_protocol
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.addressOf
+import kotlinx.cinterop.convert
+import kotlinx.cinterop.pin
+import kotlinx.cinterop.usePinned
+import kotlinx.coroutines.CompletableDeferred
+import platform.Network.nw_connection_create
+import platform.Network.nw_connection_force_cancel
+import platform.Network.nw_connection_receive
+import platform.Network.nw_connection_set_queue
+import platform.Network.nw_connection_set_state_changed_handler
+import platform.Network.nw_connection_start
+import platform.Network.nw_connection_state_cancelled
+import platform.Network.nw_connection_state_failed
+import platform.Network.nw_connection_state_ready
+import platform.Network.nw_connection_state_t
+import platform.Network.nw_connection_t
+import platform.Network.nw_endpoint_create_host
+import platform.Network.nw_error_t
+import platform.darwin.dispatch_data_apply
+import platform.darwin.dispatch_data_create
+import platform.darwin.dispatch_data_t
+import platform.darwin.dispatch_get_current_queue
+import platform.posix.memcpy
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+@OptIn(ExperimentalForeignApi::class)
+@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
+internal actual class NTPUDPSocketOperations {
+ private var connection: nw_connection_t = null
+
+ actual suspend fun prepare(address: String, portNumber: Int) {
+ val parameters = nw_parameters_create_secure_udp_disable_protocol()
+ val endpoint = nw_endpoint_create_host(address, portNumber.toString())
+ connection = nw_connection_create(endpoint, parameters)
+ nw_connection_set_queue(connection, dispatch_get_current_queue())
+ val connectionStateDeferred = CompletableDeferred()
+ nw_connection_set_state_changed_handler(connection) { state: nw_connection_state_t, _ ->
+ when (state) {
+ nw_connection_state_ready, nw_connection_state_failed, nw_connection_state_cancelled ->
+ connectionStateDeferred.complete(state)
+ }
+ }
+ nw_connection_start(connection)
+ assertEquals(nw_connection_state_ready, connectionStateDeferred.await())
+ }
+
+ actual suspend fun exchange(buffer: ByteArray) {
+ val data = buffer.pin().run {
+ dispatch_data_create(
+ addressOf(0),
+ buffer.size.convert(),
+ dispatch_get_current_queue(),
+ ({ unpin() }),
+ )
+ }
+ nw_connection_send_default_context(
+ connection,
+ data,
+ true,
+ ) {
+ assertNull(it)
+ }
+ val connectionReceptionDeferred = CompletableDeferred()
+ nw_connection_receive(
+ connection,
+ 1.convert(),
+ buffer.size.convert(),
+ ) { content: dispatch_data_t, _, _, error: nw_error_t ->
+ assertNull(error)
+ connectionReceptionDeferred.complete(content)
+ }
+ val receivedData = connectionReceptionDeferred.await()
+ buffer.usePinned {
+ dispatch_data_apply(receivedData) { _, offset, src, size ->
+ memcpy(it.addressOf(offset.toInt()), src, size)
+ true
+ }
+ }
+ }
+
+ actual fun tearDown() {
+ nw_connection_force_cancel(connection)
+ connection = null
+ }
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/NTPServer.kt b/library/src/commonMain/kotlin/com/tidal/networktime/NTPServer.kt
new file mode 100644
index 00000000..f3a04aa6
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/NTPServer.kt
@@ -0,0 +1,47 @@
+package com.tidal.networktime
+
+import kotlin.native.ObjCName
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Describes a host name that can resolve to any number of NTP unicast servers.
+ *
+ * @param hostName The host name.
+ * @param queryConnectTimeout The per-server timeout for connecting to each of the servers resolved
+ * from [hostName].
+ * @param queryReadTimeout The per-server timeout for receiving responses from each of the servers
+ * resolved from [hostName].
+ * @param protocolFamilies Can be used for filtering addresses resolved from [hostName] based on
+ * address family.
+ * @param queriesPerResolvedAddress The amount of queries to perform to each resolved address. More
+ * queries may or may not increase precision, but they will make synchronization take longer and
+ * also cause more server load.
+ * @param waitBetweenResolvedAddressQueries The amount of time to wait before consecutive requests
+ * to the same resolved address.
+ * @param ntpVersion The version number to write in packets.
+ * @param maxRootDelay The maximum delay to accept a packet. Packets with a root delay higher than
+ * this will be discarded.
+ * @param maxRootDispersion The maximum root dispersion to accept a packet. Packets with a root
+ * dispersion higher than this will be discarded.
+ * @param dnsResolutionTimeout The timeout for DNS lookup for addresses from [hostName].
+ */
+@ObjCName(name = "TNTNTPServer", swiftName = "NTPServer", exact = true)
+class NTPServer(
+ val hostName: String,
+ @ObjCName(name = "queryConnectTimeoutMs")
+ val queryConnectTimeout: Duration = 5.seconds,
+ @ObjCName(name = "queryReadTimeoutMs")
+ val queryReadTimeout: Duration = 5.seconds,
+ vararg val protocolFamilies: ProtocolFamily = arrayOf(ProtocolFamily.INET),
+ val queriesPerResolvedAddress: Int = 3,
+ @ObjCName(name = "waitBetweenResolvedAddressQueriesMs")
+ val waitBetweenResolvedAddressQueries: Duration = 2.seconds,
+ val ntpVersion: NTPVersion = NTPVersion.FOUR,
+ @ObjCName(name = "maxRootDelayMs")
+ val maxRootDelay: Duration = Duration.INFINITE,
+ @ObjCName(name = "maxRootDispersionMs")
+ val maxRootDispersion: Duration = Duration.INFINITE,
+ @ObjCName(name = "dnsResolutionTimeoutMs")
+ val dnsResolutionTimeout: Duration = 30.seconds,
+)
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/NTPVersion.kt b/library/src/commonMain/kotlin/com/tidal/networktime/NTPVersion.kt
new file mode 100644
index 00000000..de9af322
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/NTPVersion.kt
@@ -0,0 +1,12 @@
+package com.tidal.networktime
+
+import kotlin.native.ObjCName
+
+@ObjCName(name = "TNTNTPVersion", swiftName = "NTPVersion", exact = true)
+enum class NTPVersion {
+ ZERO,
+ ONE,
+ TWO,
+ THREE,
+ FOUR,
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/ProtocolFamily.kt b/library/src/commonMain/kotlin/com/tidal/networktime/ProtocolFamily.kt
new file mode 100644
index 00000000..0ad0272d
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/ProtocolFamily.kt
@@ -0,0 +1,19 @@
+package com.tidal.networktime
+
+import kotlin.native.ObjCName
+
+/**
+ * A designation of protocol families to discriminate resolved addresses on.
+ */
+@ObjCName(name = "TNTProtocolFamily", swiftName = "ProtocolFamily", exact = true)
+enum class ProtocolFamily {
+ /**
+ * IPv4.
+ */
+ INET,
+
+ /**
+ * IPv6.
+ */
+ INET6,
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/SNTPClient.kt b/library/src/commonMain/kotlin/com/tidal/networktime/SNTPClient.kt
new file mode 100644
index 00000000..6b56610f
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/SNTPClient.kt
@@ -0,0 +1,58 @@
+package com.tidal.networktime
+
+import com.tidal.networktime.internal.SNTPClientImpl
+import kotlinx.coroutines.Job
+import okio.Path.Companion.toPath
+import kotlin.native.ObjCName
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Constructs a new SNTP client that can be requested to periodically interact with the provided
+ * [ntpServers] to obtain information about their provided time.
+ *
+ * @param ntpServers Representation of supported unicast NTP sources.
+ * @param synchronizationInterval The amount of time to wait between a sync finishing and the next
+ * one being started.
+ * @param backupFilePath A path to a file that will be used to save the selected received NTP
+ * packets, as well as to read packets before one is available from the network. If `null` then
+ * [epochTime] is guaranteed to return `null` from program start-up until at least one valid NTP
+ * packet has been received and processed. If not `null` but writing or reading fail when attempted,
+ * program execution will continue as if it had been `null` until the next attempt.
+ */
+@ObjCName(name = "TNTSNTPClient", swiftName = "SNTPClient", exact = true)
+class SNTPClient(
+ vararg val ntpServers: NTPServer,
+ @ObjCName(name = "synchronizationIntervalMs")
+ val synchronizationInterval: Duration = 64.seconds,
+ val backupFilePath: String? = null,
+) {
+ private val delegate = SNTPClientImpl(
+ ntpServers,
+ backupFilePath?.toPath(),
+ synchronizationInterval,
+ )
+
+ /**
+ * The calculated epoch time if it has been calculated at least once or null otherwise.
+ */
+ @ObjCName("epochTimeMs")
+ val epochTime by delegate::epochTime
+
+ /**
+ * Starts periodic synchronization. If it's already started, it does nothing. Otherwise, it
+ * requests an immediate dispatch of a synchronization and subsequent ones
+ * [synchronizationInterval] after each other.
+ *
+ * @return The [Job] for the task that will run the requested synchronization activity update.
+ */
+ fun enableSynchronization() = delegate.enableSynchronization()
+
+ /**
+ * Stops periodic synchronization if already started, does nothing otherwise. Safe to call
+ * repeatedly.
+ *
+ * @return The [Job] for the task that will run the requested synchronization activity update.
+ */
+ fun disableSynchronization() = delegate.disableSynchronization()
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/EpochTimestamp.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/EpochTimestamp.kt
new file mode 100644
index 00000000..297f1580
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/EpochTimestamp.kt
@@ -0,0 +1,26 @@
+package com.tidal.networktime.internal
+
+import kotlin.jvm.JvmInline
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+
+@JvmInline
+internal value class EpochTimestamp(val epochTime: Duration) {
+ val asNTPTimestamp: NTPTimestamp
+ get() {
+ val millis = epochTime.inWholeMilliseconds
+ val useBase1 = millis < NTPPacket.NTP_TIMESTAMP_BASE_WITH_EPOCH_MSB_0_MILLISECONDS
+ val baseTimeMillis = millis -
+ if (useBase1) {
+ NTPPacket.NTP_TIMESTAMP_BASE_WITH_EPOCH_MSB_1_MILLISECONDS
+ } else {
+ NTPPacket.NTP_TIMESTAMP_BASE_WITH_EPOCH_MSB_0_MILLISECONDS
+ }
+ var seconds = baseTimeMillis / 1_000
+ if (useBase1) {
+ seconds = seconds or 0x80000000L
+ }
+ val fraction = baseTimeMillis % 1_000 * 0x100000000L / 1_000
+ return NTPTimestamp((seconds shl 32 or fraction).milliseconds)
+ }
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/FileSystemSupplier.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/FileSystemSupplier.kt
new file mode 100644
index 00000000..e7da053a
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/FileSystemSupplier.kt
@@ -0,0 +1,8 @@
+package com.tidal.networktime.internal
+
+import okio.FileSystem
+
+@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
+internal expect class FileSystemSupplier() {
+ val system: FileSystem
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/HostNameResolver.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/HostNameResolver.kt
new file mode 100644
index 00000000..f798f5b2
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/HostNameResolver.kt
@@ -0,0 +1,13 @@
+package com.tidal.networktime.internal
+
+import kotlin.time.Duration
+
+@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
+internal expect class HostNameResolver() {
+ suspend operator fun invoke(
+ hostName: String,
+ timeout: Duration,
+ includeINET: Boolean,
+ includeINET6: Boolean,
+ ): Iterable
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/KotlinXDateTimeSystemClock.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/KotlinXDateTimeSystemClock.kt
new file mode 100644
index 00000000..a7285a9a
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/KotlinXDateTimeSystemClock.kt
@@ -0,0 +1,10 @@
+package com.tidal.networktime.internal
+
+import kotlinx.datetime.Clock
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+
+internal class KotlinXDateTimeSystemClock {
+ val referenceEpochTime: Duration
+ get() = Clock.System.now().toEpochMilliseconds().milliseconds
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/MutableState.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/MutableState.kt
new file mode 100644
index 00000000..5dc27eda
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/MutableState.kt
@@ -0,0 +1,10 @@
+package com.tidal.networktime.internal
+
+import kotlinx.coroutines.Job
+import kotlin.concurrent.Volatile
+
+internal class MutableState(
+ var job: Job? = null,
+ @Volatile
+ var synchronizationResult: SynchronizationResult? = null,
+)
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPExchangeCoordinator.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPExchangeCoordinator.kt
new file mode 100644
index 00000000..3ae17bc8
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPExchangeCoordinator.kt
@@ -0,0 +1,42 @@
+package com.tidal.networktime.internal
+
+import kotlinx.coroutines.withTimeout
+import kotlin.time.Duration
+
+internal class NTPExchangeCoordinator(
+ private val referenceClock: KotlinXDateTimeSystemClock,
+ private val ntpPacketSerializer: NTPPacketSerializer,
+ private val ntpPacketDeserializer: NTPPacketDeserializer,
+) {
+ suspend operator fun invoke(
+ address: String,
+ connectTimeout: Duration,
+ queryReadTimeout: Duration,
+ ntpVersion: UByte,
+ ): NTPExchangeResult? {
+ val ntpUdpSocketOperations = NTPUDPSocketOperations()
+ return try {
+ withTimeout(connectTimeout) {
+ ntpUdpSocketOperations.prepare(address, NTP_PORT_NUMBER)
+ }
+ val ntpPacket = NTPPacket(versionNumber = ntpVersion.toInt(), mode = NTP_MODE_CLIENT)
+ val requestTime = referenceClock.referenceEpochTime
+ ntpPacket.transmitEpochTimestamp = EpochTimestamp(requestTime).asNTPTimestamp
+ val buffer = ntpPacketSerializer(ntpPacket)
+ withTimeout(queryReadTimeout) {
+ ntpUdpSocketOperations.exchange(buffer)
+ }
+ val returnTime = referenceClock.referenceEpochTime
+ ntpPacketDeserializer(buffer)?.let { NTPExchangeResult(returnTime, it) }
+ } catch (_: Throwable) {
+ null
+ } finally {
+ ntpUdpSocketOperations.tearDown()
+ }
+ }
+
+ companion object {
+ private const val NTP_MODE_CLIENT = 3
+ private const val NTP_PORT_NUMBER = 123
+ }
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPExchangeResult.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPExchangeResult.kt
new file mode 100644
index 00000000..8a02d138
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPExchangeResult.kt
@@ -0,0 +1,70 @@
+@file:Suppress("DuplicatedCode") // We need the duplicated variable list for performance reasons
+
+package com.tidal.networktime.internal
+
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+
+internal data class NTPExchangeResult(
+ val returnTime: Duration,
+ val ntpPacket: NTPPacket,
+) {
+ val roundTripDelay: Duration
+ get() = ntpPacket.run {
+ val originEpochMillis = originateEpochTimestamp.asEpochTimestamp.epochTime.inWholeMilliseconds
+ val receiveNTPMillis = receiveEpochTimestamp.ntpTime.inWholeMilliseconds
+ val receiveEpochMillis = receiveEpochTimestamp.asEpochTimestamp.epochTime.inWholeMilliseconds
+ val transmitNTPMillis = transmitEpochTimestamp.ntpTime.inWholeMilliseconds
+ val transmitEpochMillis = transmitEpochTimestamp.asEpochTimestamp
+ .epochTime
+ .inWholeMilliseconds
+ val returnTimeMillis = returnTime.inWholeMilliseconds
+ if (receiveNTPMillis == 0L || transmitNTPMillis == 0L) {
+ return@run if (returnTimeMillis >= originEpochMillis) {
+ (returnTimeMillis - originEpochMillis).milliseconds
+ } else {
+ Duration.INFINITE
+ }
+ }
+ var delayMillis = returnTimeMillis - originEpochMillis
+ val deltaMillis = transmitEpochMillis - receiveEpochMillis
+ if (deltaMillis <= delayMillis) {
+ delayMillis -= deltaMillis
+ } else if (deltaMillis - delayMillis == 1L) {
+ if (delayMillis != 0L) {
+ delayMillis = 0
+ }
+ }
+ delayMillis.milliseconds
+ }
+
+ val clockOffset: Duration
+ get() = ntpPacket.run {
+ val originNTPMillis = originateEpochTimestamp.ntpTime.inWholeMilliseconds
+ val originEpochMillis = originateEpochTimestamp.asEpochTimestamp.epochTime.inWholeMilliseconds
+ val receiveNTPMillis = receiveEpochTimestamp.ntpTime.inWholeMilliseconds
+ val receiveEpochMillis = receiveEpochTimestamp.asEpochTimestamp.epochTime.inWholeMilliseconds
+ val transmitNTPMillis = transmitEpochTimestamp.ntpTime.inWholeMilliseconds
+ val transmitEpochMillis = transmitEpochTimestamp.asEpochTimestamp
+ .epochTime
+ .inWholeMilliseconds
+ val returnTimeMillis = returnTime.inWholeMilliseconds
+ if (originNTPMillis == 0L) {
+ if (transmitNTPMillis != 0L) {
+ return@run (transmitEpochMillis - returnTimeMillis).milliseconds
+ }
+ return@run Duration.INFINITE
+ }
+ if (receiveNTPMillis == 0L || transmitNTPMillis == 0L) {
+ if (receiveNTPMillis != 0L) {
+ return@run (receiveEpochMillis - originEpochMillis).milliseconds
+ }
+ if (transmitNTPMillis != 0L) {
+ return@run (transmitEpochMillis - returnTimeMillis).milliseconds
+ }
+ return@run Duration.INFINITE
+ }
+ ((receiveEpochMillis - originEpochMillis + transmitEpochMillis - returnTimeMillis) / 2)
+ .milliseconds
+ }
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPPacket.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPPacket.kt
new file mode 100644
index 00000000..65fc56dd
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPPacket.kt
@@ -0,0 +1,25 @@
+package com.tidal.networktime.internal
+
+import kotlin.time.Duration
+
+internal data class NTPPacket(
+ val leapIndicator: Int = 0,
+ val versionNumber: Int,
+ val mode: Int,
+ val stratum: Int = 0,
+ val poll: Duration = Duration.INFINITE,
+ val precision: Duration = Duration.INFINITE,
+ val rootDelay: Duration = Duration.INFINITE,
+ val rootDispersion: Duration = Duration.INFINITE,
+ val referenceIdentifier: String = "",
+ val referenceEpochTimestamp: NTPTimestamp = NTPTimestamp(Duration.ZERO),
+ val originateEpochTimestamp: NTPTimestamp = NTPTimestamp(Duration.ZERO),
+ val receiveEpochTimestamp: NTPTimestamp = NTPTimestamp(Duration.ZERO),
+ /** Keep this mutable to minimize delay (avoids an allocation) **/
+ var transmitEpochTimestamp: NTPTimestamp = NTPTimestamp(Duration.ZERO),
+) {
+ companion object {
+ const val NTP_TIMESTAMP_BASE_WITH_EPOCH_MSB_0_MILLISECONDS = 2085978496000
+ const val NTP_TIMESTAMP_BASE_WITH_EPOCH_MSB_1_MILLISECONDS = -2208988800000
+ }
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPPacketDeserializer.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPPacketDeserializer.kt
new file mode 100644
index 00000000..fca67b51
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPPacketDeserializer.kt
@@ -0,0 +1,94 @@
+package com.tidal.networktime.internal
+
+import kotlin.math.pow
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+internal class NTPPacketDeserializer {
+ operator fun invoke(bytes: ByteArray): NTPPacket? {
+ var index = 0
+ val leapIndicator = (bytes[index].toInt() shr 6) and 0b11
+ if (leapIndicator == LEAP_INDICATOR_CLOCK_UNSYNCHRONIZED) {
+ return null
+ }
+ val versionNumber = (bytes[index].toInt() shr 3) and 0b111
+ val mode = bytes[index].toInt() and 0b111
+ if (mode != MODE_SERVER) {
+ return null
+ }
+ ++index
+ val stratum = bytes[index++].asUnsignedInt
+ if (stratum >= STRATUM_CLOCK_NOT_SYNCHRONIZED) {
+ return null
+ }
+ val poll = bytes[index++].asSignedIntToThePowerOf2.seconds
+ val precision = bytes[index++].asSignedIntToThePowerOf2.milliseconds
+ val rootDelay = bytes.sliceArray(index until index + 4).asNTPIntervalToInterval
+ index += 4
+ val rootDispersion = bytes.sliceArray(index until index + 4).asNTPIntervalToInterval
+ index += 4
+ val referenceIdentifier = bytes.sliceArray(index until index + 4).decodeToString()
+ index += 4
+ val reference = bytes.sliceArray(index until index + 8).asNTPTimestamp
+ index += 8
+ val originate = bytes.sliceArray(index until index + 8).asNTPTimestamp
+ index += 8
+ val receive = bytes.sliceArray(index until index + 8).asNTPTimestamp
+ index += 8
+ val transmit = bytes.sliceArray(index until index + 8).asNTPTimestamp
+ return NTPPacket(
+ leapIndicator,
+ versionNumber,
+ mode,
+ stratum,
+ poll,
+ precision,
+ rootDelay,
+ rootDispersion,
+ referenceIdentifier,
+ reference,
+ originate,
+ receive,
+ transmit,
+ )
+ }
+
+ private val Byte.asSignedIntToThePowerOf2
+ get() = 2.toDouble().pow(toInt())
+
+ private val Byte.asUnsignedInt: Int
+ get() = toUByte().toInt()
+
+ private val ByteArray.asNTPIntervalToInterval: Duration
+ get() {
+ var index = 0
+ val seconds = (this[index++].asUnsignedInt shl 8) + this[index++].asUnsignedInt
+ val fraction = ((this[index++].asUnsignedInt shl 8) + this[index].asUnsignedInt)
+ .toDouble() / (1 shl 16) * 1_000
+ return seconds.seconds + fraction.milliseconds
+ }
+
+ private val Byte.asUnsignedLong: Long
+ get() = toUByte().toLong()
+
+ private val ByteArray.asNTPTimestamp: NTPTimestamp
+ get() {
+ var index = 0
+ val ntpMillis = (this[index++].asUnsignedLong shl 56) or
+ (this[index++].asUnsignedLong shl 48) or
+ (this[index++].asUnsignedLong shl 40) or
+ (this[index++].asUnsignedLong shl 32) or
+ (this[index++].asUnsignedLong shl 24) or
+ (this[index++].asUnsignedLong shl 16) or
+ (this[index++].asUnsignedLong shl 8) or
+ this[index].asUnsignedLong
+ return NTPTimestamp(ntpMillis.milliseconds)
+ }
+
+ companion object {
+ private const val LEAP_INDICATOR_CLOCK_UNSYNCHRONIZED = 0b11
+ private const val MODE_SERVER = 4
+ private const val STRATUM_CLOCK_NOT_SYNCHRONIZED = 16
+ }
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPPacketSerializer.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPPacketSerializer.kt
new file mode 100644
index 00000000..ecd7051a
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPPacketSerializer.kt
@@ -0,0 +1,30 @@
+package com.tidal.networktime.internal
+
+import kotlin.time.Duration
+
+internal class NTPPacketSerializer {
+ operator fun invoke(ntpPacket: NTPPacket) = ntpPacket.run {
+ ByteArray(48).apply {
+ set(0, ((0 shl 6) or (versionNumber shl 3) or mode).toByte())
+ transmitEpochTimestamp.ntpTime
+ .ntpTimestampAsByteArray
+ .forEachIndexed { i, it ->
+ set(40 + i, it)
+ }
+ }
+ }
+
+ private val Duration.ntpTimestampAsByteArray: ByteArray
+ get() = inWholeMilliseconds.run {
+ byteArrayOf(
+ (this shr 56 and 0xff).toByte(),
+ (this shr 48 and 0xff).toByte(),
+ (this shr 40 and 0xff).toByte(),
+ (this shr 32 and 0xff).toByte(),
+ (this shr 24 and 0xff).toByte(),
+ (this shr 16 and 0xff).toByte(),
+ (this shr 8 and 0xff).toByte(),
+ (this and 0xff).toByte(),
+ )
+ }
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPTimestamp.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPTimestamp.kt
new file mode 100644
index 00000000..5bfa25dc
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPTimestamp.kt
@@ -0,0 +1,27 @@
+package com.tidal.networktime.internal
+
+import kotlin.jvm.JvmInline
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * NTP timestamps have more precision than epochs represented with Kotlin's Long, so use them as the
+ * non-computed property.
+ */
+@JvmInline
+internal value class NTPTimestamp(val ntpTime: Duration) {
+ val asEpochTimestamp: EpochTimestamp
+ get() {
+ val ntpTimeValue = ntpTime.inWholeMilliseconds
+ val seconds = ntpTimeValue ushr 32 and 0xffffffff
+ val fraction = (1000.0 * (ntpTimeValue and 0xffffffff) / 0x100000000).toLong()
+ val mostSignificantBit = seconds and 0x80000000L
+ return (
+ if (mostSignificantBit == 0L) {
+ NTPPacket.NTP_TIMESTAMP_BASE_WITH_EPOCH_MSB_0_MILLISECONDS
+ } else {
+ NTPPacket.NTP_TIMESTAMP_BASE_WITH_EPOCH_MSB_1_MILLISECONDS
+ } + seconds * 1000 + fraction
+ ).milliseconds.let { EpochTimestamp(it) }
+ }
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPUDPSocketOperations.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPUDPSocketOperations.kt
new file mode 100644
index 00000000..dca57282
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/NTPUDPSocketOperations.kt
@@ -0,0 +1,10 @@
+package com.tidal.networktime.internal
+
+@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
+internal expect class NTPUDPSocketOperations() {
+ suspend fun prepare(address: String, portNumber: Int)
+
+ suspend fun exchange(buffer: ByteArray)
+
+ fun tearDown()
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/OperationCoordinator.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/OperationCoordinator.kt
new file mode 100644
index 00000000..07fa2934
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/OperationCoordinator.kt
@@ -0,0 +1,38 @@
+package com.tidal.networktime.internal
+
+import com.tidal.networktime.NTPServer
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlin.time.Duration
+
+internal class OperationCoordinator
+@OptIn(ExperimentalCoroutinesApi::class)
+constructor(
+ private val mutableState: MutableState,
+ private val synchronizationResultProcessor: SynchronizationResultProcessor,
+ private val coroutineScope: CoroutineScope,
+ globalDispatcher: CoroutineDispatcher,
+ private val syncInterval: Duration,
+ private val ntpServers: Iterable,
+ private val referenceClock: KotlinXDateTimeSystemClock,
+ private val toggleDispatcher: CoroutineDispatcher = globalDispatcher.limitedParallelism(1),
+ private val syncDispatcher: CoroutineDispatcher = globalDispatcher,
+) {
+ fun dispatchStartSync() = dispatch(
+ SyncEnable(
+ mutableState,
+ synchronizationResultProcessor,
+ coroutineScope,
+ syncDispatcher,
+ syncInterval,
+ ntpServers,
+ referenceClock,
+ ),
+ )
+
+ fun dispatchStopSync() = dispatch(SyncDisable(mutableState))
+
+ private fun dispatch(block: () -> Unit) = coroutineScope.launch(toggleDispatcher) { block() }
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/SNTPClientImpl.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/SNTPClientImpl.kt
new file mode 100644
index 00000000..2a5086ea
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/SNTPClientImpl.kt
@@ -0,0 +1,45 @@
+package com.tidal.networktime.internal
+
+import com.tidal.networktime.NTPServer
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.IO
+import okio.Path
+import kotlin.time.Duration
+
+internal class SNTPClientImpl
+@OptIn(DelicateCoroutinesApi::class)
+constructor(
+ ntpServers: Array,
+ backupFilePath: Path?,
+ syncInterval: Duration,
+ private val referenceClock: KotlinXDateTimeSystemClock = KotlinXDateTimeSystemClock(),
+ private val mutableState: MutableState = MutableState(),
+ private val synchronizationResultProcessor: SynchronizationResultProcessor =
+ SynchronizationResultProcessor(
+ mutableState,
+ backupFilePath,
+ ),
+ private val operationCoordinator: OperationCoordinator =
+ OperationCoordinator(
+ mutableState,
+ synchronizationResultProcessor,
+ GlobalScope,
+ Dispatchers.IO,
+ syncInterval,
+ ntpServers.asIterable(),
+ referenceClock,
+ ),
+) {
+ val epochTime: Duration?
+ get() {
+ val (synchronizedTime, synchronizedAt) =
+ synchronizationResultProcessor.synchronizationResult ?: return null
+ return synchronizedTime - synchronizedAt + referenceClock.referenceEpochTime
+ }
+
+ fun enableSynchronization() = operationCoordinator.dispatchStartSync()
+
+ fun disableSynchronization() = operationCoordinator.dispatchStopSync()
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/SyncDisable.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/SyncDisable.kt
new file mode 100644
index 00000000..131bff8e
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/SyncDisable.kt
@@ -0,0 +1,11 @@
+package com.tidal.networktime.internal
+
+internal class SyncDisable(private val mutableState: MutableState) : () -> Unit {
+ override operator fun invoke() = with(mutableState) {
+ val job = job ?: return
+ if (!job.isCancelled) {
+ job.cancel()
+ }
+ this.job = null
+ }
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/SyncEnable.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/SyncEnable.kt
new file mode 100644
index 00000000..fd6c389d
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/SyncEnable.kt
@@ -0,0 +1,27 @@
+package com.tidal.networktime.internal
+
+import com.tidal.networktime.NTPServer
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import kotlin.time.Duration
+
+internal class SyncEnable(
+ private val mutableState: MutableState,
+ private val synchronizationResultProcessor: SynchronizationResultProcessor,
+ private val coroutineScope: CoroutineScope,
+ private val syncDispatcher: CoroutineDispatcher,
+ private val syncInterval: Duration,
+ private val ntpServers: Iterable,
+ private val referenceClock: KotlinXDateTimeSystemClock,
+) : () -> Unit {
+ override operator fun invoke() = with(mutableState) {
+ val job = job
+ if (job != null && !job.isCancelled) {
+ return
+ }
+ this.job = coroutineScope.launch(syncDispatcher) {
+ SyncPeriodic(ntpServers, syncInterval, referenceClock, synchronizationResultProcessor)()
+ }
+ }
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/SyncPeriodic.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/SyncPeriodic.kt
new file mode 100644
index 00000000..84326273
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/SyncPeriodic.kt
@@ -0,0 +1,29 @@
+package com.tidal.networktime.internal
+
+import com.tidal.networktime.NTPServer
+import kotlinx.coroutines.delay
+import kotlin.time.Duration
+
+internal class SyncPeriodic(
+ private val ntpServers: Iterable,
+ private val syncInterval: Duration,
+ private val referenceClock: KotlinXDateTimeSystemClock,
+ private val synchronizationResultProcessor: SynchronizationResultProcessor,
+ private val ntpExchangeCoordinator: NTPExchangeCoordinator = NTPExchangeCoordinator(
+ referenceClock,
+ NTPPacketSerializer(),
+ NTPPacketDeserializer(),
+ ),
+) {
+ suspend operator fun invoke() {
+ while (true) {
+ SyncSingular(
+ ntpServers,
+ ntpExchangeCoordinator,
+ referenceClock,
+ synchronizationResultProcessor,
+ )()
+ delay(syncInterval)
+ }
+ }
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/SyncSingular.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/SyncSingular.kt
new file mode 100644
index 00000000..7dbe2e9b
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/SyncSingular.kt
@@ -0,0 +1,80 @@
+package com.tidal.networktime.internal
+
+import com.tidal.networktime.NTPServer
+import com.tidal.networktime.NTPVersion
+import com.tidal.networktime.ProtocolFamily
+import kotlinx.coroutines.async
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+
+internal class SyncSingular(
+ private val ntpServers: Iterable,
+ private val ntpExchangeCoordinator: NTPExchangeCoordinator,
+ private val referenceClock: KotlinXDateTimeSystemClock,
+ private val synchronizationResultProcessor: SynchronizationResultProcessor,
+ private val hostNameResolver: HostNameResolver = HostNameResolver(),
+) {
+ suspend operator fun invoke() {
+ val selectedResult = ntpServers.map {
+ withContext(currentCoroutineContext()) {
+ async { pickNTPPacketWithShortestRoundTrip(it) }
+ }
+ }.flatMap {
+ it.await()
+ }.filterNotNull()
+ .sortedBy { it.clockOffset }
+ .run {
+ if (isEmpty()) {
+ return
+ } else {
+ this[size / 2]
+ }
+ }
+ synchronizationResultProcessor.synchronizationResult = SynchronizationResult(
+ selectedResult.run { returnTime + clockOffset },
+ referenceClock.referenceEpochTime,
+ )
+ }
+
+ private suspend fun pickNTPPacketWithShortestRoundTrip(ntpServer: NTPServer) = with(ntpServer) {
+ try {
+ hostNameResolver(
+ hostName,
+ dnsResolutionTimeout,
+ ProtocolFamily.INET in protocolFamilies,
+ ProtocolFamily.INET6 in protocolFamilies,
+ ).map { resolvedName ->
+ (1..queriesPerResolvedAddress).mapNotNull {
+ val ret = ntpExchangeCoordinator(
+ resolvedName,
+ queryConnectTimeout,
+ queryReadTimeout,
+ when (ntpVersion) {
+ NTPVersion.ZERO -> 0U
+ NTPVersion.ONE -> 1U
+ NTPVersion.TWO -> 2U
+ NTPVersion.THREE -> 3U
+ NTPVersion.FOUR -> 4U
+ },
+ )
+ if (it != queriesPerResolvedAddress) {
+ delay(waitBetweenResolvedAddressQueries)
+ }
+ if (
+ ret?.ntpPacket?.run { rootDelay <= maxRootDelay && rootDispersion <= maxRootDispersion }
+ == true
+ ) {
+ ret
+ } else {
+ null
+ }
+ }
+ .takeIf { it.isNotEmpty() }
+ ?.minBy { it.roundTripDelay }
+ }
+ } catch (_: Throwable) {
+ emptySet()
+ }
+ }
+}
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/SynchronizationResult.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/SynchronizationResult.kt
new file mode 100644
index 00000000..6e564f74
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/SynchronizationResult.kt
@@ -0,0 +1,10 @@
+package com.tidal.networktime.internal
+
+import kotlinx.serialization.Serializable
+import kotlin.time.Duration
+
+@Serializable
+internal data class SynchronizationResult(
+ val synchronizedEpochTime: Duration,
+ val synchronizedAtReferenceEpochTime: Duration,
+)
diff --git a/library/src/commonMain/kotlin/com/tidal/networktime/internal/SynchronizationResultProcessor.kt b/library/src/commonMain/kotlin/com/tidal/networktime/internal/SynchronizationResultProcessor.kt
new file mode 100644
index 00000000..fbb90fc2
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/tidal/networktime/internal/SynchronizationResultProcessor.kt
@@ -0,0 +1,42 @@
+package com.tidal.networktime.internal
+
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.okio.decodeFromBufferedSource
+import kotlinx.serialization.json.okio.encodeToBufferedSink
+import okio.FileSystem
+import okio.Path
+
+@OptIn(ExperimentalSerializationApi::class)
+internal class SynchronizationResultProcessor(
+ private val mutableState: MutableState,
+ private val backupFilePath: Path?,
+ private val fileSystem: FileSystem = FileSystemSupplier().system,
+) {
+ var synchronizationResult: SynchronizationResult?
+ get() {
+ val value = mutableState.synchronizationResult
+ if (value == null && backupFilePath != null) {
+ try {
+ fileSystem.read(backupFilePath) {
+ val readValue = Json.decodeFromBufferedSource(this)
+ mutableState.synchronizationResult = readValue
+ return readValue
+ }
+ } catch (_: Throwable) {
+ }
+ }
+ return value
+ }
+ set(value) {
+ mutableState.synchronizationResult = value
+ if (backupFilePath != null && value != null) {
+ try {
+ fileSystem.write(backupFilePath) {
+ Json.encodeToBufferedSink(value, this)
+ }
+ } catch (_: Throwable) {
+ }
+ }
+ }
+}
diff --git a/library/src/jvmMain/kotlin/com/tidal/networktime/internal/FileSystemSupplier.kt b/library/src/jvmMain/kotlin/com/tidal/networktime/internal/FileSystemSupplier.kt
new file mode 100644
index 00000000..064c7e1a
--- /dev/null
+++ b/library/src/jvmMain/kotlin/com/tidal/networktime/internal/FileSystemSupplier.kt
@@ -0,0 +1,8 @@
+package com.tidal.networktime.internal
+
+import okio.FileSystem
+
+@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
+internal actual class FileSystemSupplier {
+ actual val system = FileSystem.SYSTEM
+}
diff --git a/library/src/jvmMain/kotlin/com/tidal/networktime/internal/HostNameResolver.kt b/library/src/jvmMain/kotlin/com/tidal/networktime/internal/HostNameResolver.kt
new file mode 100644
index 00000000..4c52465b
--- /dev/null
+++ b/library/src/jvmMain/kotlin/com/tidal/networktime/internal/HostNameResolver.kt
@@ -0,0 +1,32 @@
+package com.tidal.networktime.internal
+
+import com.tidal.networktime.ProtocolFamily
+import kotlinx.coroutines.withTimeoutOrNull
+import java.net.Inet4Address
+import java.net.Inet6Address
+import java.net.InetAddress
+import kotlin.time.Duration
+
+@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
+internal actual class HostNameResolver {
+ actual suspend operator fun invoke(
+ hostName: String,
+ timeout: Duration,
+ includeINET: Boolean,
+ includeINET6: Boolean,
+ ): Iterable = withTimeoutOrNull(timeout) {
+ InetAddress.getAllByName(hostName)
+ }?.mapNotNull {
+ val protocolFamily = when (it) {
+ is Inet4Address -> ProtocolFamily.INET
+ is Inet6Address -> ProtocolFamily.INET6
+ else -> null
+ }
+ when {
+ protocolFamily == ProtocolFamily.INET && includeINET ||
+ protocolFamily == ProtocolFamily.INET6 && includeINET6 -> it.hostAddress
+
+ else -> null
+ }
+ } ?: emptySet()
+}
diff --git a/library/src/jvmMain/kotlin/com/tidal/networktime/internal/NTPUDPSocketOperations.kt b/library/src/jvmMain/kotlin/com/tidal/networktime/internal/NTPUDPSocketOperations.kt
new file mode 100644
index 00000000..3ba6aeab
--- /dev/null
+++ b/library/src/jvmMain/kotlin/com/tidal/networktime/internal/NTPUDPSocketOperations.kt
@@ -0,0 +1,25 @@
+package com.tidal.networktime.internal
+
+import java.net.DatagramPacket
+import java.net.DatagramSocket
+import java.net.InetAddress
+
+@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "BlockingMethodInNonBlockingContext")
+internal actual class NTPUDPSocketOperations {
+ private var datagramSocket: DatagramSocket? = null
+
+ actual suspend fun prepare(address: String, portNumber: Int) {
+ datagramSocket = DatagramSocket()
+ datagramSocket!!.connect(InetAddress.getByName(address), portNumber)
+ }
+
+ actual suspend fun exchange(buffer: ByteArray) {
+ val exchangePacket = DatagramPacket(buffer, buffer.size)
+ datagramSocket!!.send(exchangePacket)
+ datagramSocket!!.receive(exchangePacket)
+ }
+
+ actual fun tearDown() {
+ datagramSocket?.close()
+ }
+}
diff --git a/library/src/nativeInterop/cinterop/NetworkFrameworkWorkaround.def b/library/src/nativeInterop/cinterop/NetworkFrameworkWorkaround.def
new file mode 100644
index 00000000..6299952b
--- /dev/null
+++ b/library/src/nativeInterop/cinterop/NetworkFrameworkWorkaround.def
@@ -0,0 +1,31 @@
+package = com.tidal.networktime.internal.network_framework_workaround
+language = Objective-C
+
+---
+
+#import
+#import
+
+// https://stackoverflow.com/a/63050804
+NW_RETURNS_RETAINED nw_parameters_t nw_parameters_create_secure_udp_disable_protocol() {
+ return nw_parameters_create_secure_udp(
+ NW_PARAMETERS_DISABLE_PROTOCOL,
+ NW_PARAMETERS_DEFAULT_CONFIGURATION
+ );
+}
+
+// https://youtrack.jetbrains.com/issue/KT-62102/
+void nw_connection_send_default_context(
+ nw_connection_t connection,
+ _Nullable dispatch_data_t content,
+ bool is_complete,
+ nw_connection_send_completion_t completion
+) {
+ nw_connection_send(
+ connection,
+ content,
+ NW_CONNECTION_DEFAULT_MESSAGE_CONTEXT,
+ is_complete,
+ completion
+ );
+}
diff --git a/samples/multiplatform-kotlin/android/build.gradle.kts b/samples/multiplatform-kotlin/android/build.gradle.kts
new file mode 100644
index 00000000..ce7ebe3c
--- /dev/null
+++ b/samples/multiplatform-kotlin/android/build.gradle.kts
@@ -0,0 +1,33 @@
+plugins {
+ kotlin("multiplatform")
+ id("org.jetbrains.compose")
+ id("com.android.application")
+}
+
+kotlin {
+ androidTarget {
+ compilations.all {
+ kotlinOptions {
+ jvmTarget = "1.8"
+ freeCompilerArgs += "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api"
+ }
+ }
+ }
+ sourceSets {
+ androidMain.get().dependencies {
+ implementation(project(":samples-multiplatform-kotlin-shared"))
+ implementation("androidx.activity:activity-compose:1.8.0")
+ implementation(project.dependencies.platform("androidx.compose:compose-bom:2023.03.00"))
+ implementation("androidx.compose.material3:material3")
+ }
+ }
+}
+
+android {
+ compileSdk = 34
+ defaultConfig {
+ minSdk = 21
+ targetSdk = 34
+ }
+ namespace = "com.tidal.networktime.sample.android"
+}
diff --git a/samples/multiplatform-kotlin/android/src/androidMain/AndroidManifest.xml b/samples/multiplatform-kotlin/android/src/androidMain/AndroidManifest.xml
new file mode 100644
index 00000000..0e4baf0b
--- /dev/null
+++ b/samples/multiplatform-kotlin/android/src/androidMain/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/multiplatform-kotlin/android/src/androidMain/kotlin/root/MainActivity.kt b/samples/multiplatform-kotlin/android/src/androidMain/kotlin/root/MainActivity.kt
new file mode 100644
index 00000000..72cc4ecc
--- /dev/null
+++ b/samples/multiplatform-kotlin/android/src/androidMain/kotlin/root/MainActivity.kt
@@ -0,0 +1,35 @@
+package root
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLayoutDirection
+
+internal class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ Scaffold { paddingValues ->
+ Column(
+ modifier = Modifier.padding(
+ PaddingValues(
+ start = paddingValues.calculateStartPadding(LocalLayoutDirection.current),
+ top = paddingValues.calculateTopPadding(),
+ end = paddingValues.calculateEndPadding(LocalLayoutDirection.current),
+ bottom = paddingValues.calculateBottomPadding(),
+ ),
+ ),
+ ) {
+ MainScreen((application as MainApplication).viewModel)
+ }
+ }
+ }
+ }
+}
diff --git a/samples/multiplatform-kotlin/android/src/androidMain/kotlin/root/MainApplication.kt b/samples/multiplatform-kotlin/android/src/androidMain/kotlin/root/MainApplication.kt
new file mode 100644
index 00000000..7a320cfb
--- /dev/null
+++ b/samples/multiplatform-kotlin/android/src/androidMain/kotlin/root/MainApplication.kt
@@ -0,0 +1,8 @@
+package root
+
+import android.app.Application
+
+internal class MainApplication : Application() {
+
+ val viewModel by lazy { MainViewModel() }
+}
diff --git a/samples/multiplatform-kotlin/android/src/androidMain/res/values/themes.xml b/samples/multiplatform-kotlin/android/src/androidMain/res/values/themes.xml
new file mode 100644
index 00000000..c2f7eea4
--- /dev/null
+++ b/samples/multiplatform-kotlin/android/src/androidMain/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/samples/multiplatform-kotlin/iOS.xcodeproj/project.pbxproj b/samples/multiplatform-kotlin/iOS.xcodeproj/project.pbxproj
new file mode 100644
index 00000000..8f643c50
--- /dev/null
+++ b/samples/multiplatform-kotlin/iOS.xcodeproj/project.pbxproj
@@ -0,0 +1,385 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 3C8236822B34C68E007EEB30 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8236812B34C68E007EEB30 /* iOSApp.swift */; };
+ 3C8236842B34C68E007EEB30 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8236832B34C68E007EEB30 /* ContentView.swift */; };
+ 3C8236862B34C68F007EEB30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3C8236852B34C68F007EEB30 /* Assets.xcassets */; };
+ 3C8236892B34C68F007EEB30 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3C8236882B34C68F007EEB30 /* Preview Assets.xcassets */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 3C82367E2B34C68E007EEB30 /* iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3C8236812B34C68E007EEB30 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; };
+ 3C8236832B34C68E007EEB30 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ 3C8236852B34C68F007EEB30 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 3C8236882B34C68F007EEB30 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 3C82367B2B34C68E007EEB30 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 3C8236752B34C68E007EEB30 = {
+ isa = PBXGroup;
+ children = (
+ 3C8236802B34C68E007EEB30 /* iOS */,
+ 3C82367F2B34C68E007EEB30 /* Products */,
+ );
+ sourceTree = "";
+ };
+ 3C82367F2B34C68E007EEB30 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 3C82367E2B34C68E007EEB30 /* iOS.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 3C8236802B34C68E007EEB30 /* iOS */ = {
+ isa = PBXGroup;
+ children = (
+ 3C8236812B34C68E007EEB30 /* iOSApp.swift */,
+ 3C8236832B34C68E007EEB30 /* ContentView.swift */,
+ 3C8236852B34C68F007EEB30 /* Assets.xcassets */,
+ 3C8236872B34C68F007EEB30 /* Preview Content */,
+ );
+ path = iOS;
+ sourceTree = "";
+ };
+ 3C8236872B34C68F007EEB30 /* Preview Content */ = {
+ isa = PBXGroup;
+ children = (
+ 3C8236882B34C68F007EEB30 /* Preview Assets.xcassets */,
+ );
+ path = "Preview Content";
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 3C82367D2B34C68E007EEB30 /* iOS */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 3C82368C2B34C68F007EEB30 /* Build configuration list for PBXNativeTarget "iOS" */;
+ buildPhases = (
+ 3C82368F2B34C78D007EEB30 /* ShellScript */,
+ 3C82367A2B34C68E007EEB30 /* Sources */,
+ 3C82367B2B34C68E007EEB30 /* Frameworks */,
+ 3C82367C2B34C68E007EEB30 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = iOS;
+ productName = iOS;
+ productReference = 3C82367E2B34C68E007EEB30 /* iOS.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 3C8236762B34C68E007EEB30 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1510;
+ LastUpgradeCheck = 1510;
+ TargetAttributes = {
+ 3C82367D2B34C68E007EEB30 = {
+ CreatedOnToolsVersion = 15.1;
+ };
+ };
+ };
+ buildConfigurationList = 3C8236792B34C68E007EEB30 /* Build configuration list for PBXProject "iOS" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 3C8236752B34C68E007EEB30;
+ productRefGroup = 3C82367F2B34C68E007EEB30 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 3C82367D2B34C68E007EEB30 /* iOS */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 3C82367C2B34C68E007EEB30 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 3C8236892B34C68F007EEB30 /* Preview Assets.xcassets in Resources */,
+ 3C8236862B34C68F007EEB30 /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 3C82368F2B34C78D007EEB30 /* ShellScript */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "cd \"$SRCROOT/../..\"\n./gradlew :samples-multiplatform-kotlin-shared:embedAndSignAppleFrameworkForXcode\n";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 3C82367A2B34C68E007EEB30 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 3C8236842B34C68E007EEB30 /* ContentView.swift in Sources */,
+ 3C8236822B34C68E007EEB30 /* iOSApp.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 3C82368A2B34C68F007EEB30 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.2;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 3C82368B2B34C68F007EEB30 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.2;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 3C82368D2B34C68F007EEB30 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "\"iOS/Preview Content\"";
+ DEVELOPMENT_TEAM = 2J8542RTUT;
+ ENABLE_PREVIEWS = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)";
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-framework",
+ samples_multiplatform_kotlin_shared,
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.tidal.networktime.sample.iOS;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 3C82368E2B34C68F007EEB30 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "\"iOS/Preview Content\"";
+ DEVELOPMENT_TEAM = 2J8542RTUT;
+ ENABLE_PREVIEWS = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)";
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-framework",
+ samples_multiplatform_kotlin_shared,
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.tidal.networktime.sample.iOS;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 3C8236792B34C68E007EEB30 /* Build configuration list for PBXProject "iOS" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 3C82368A2B34C68F007EEB30 /* Debug */,
+ 3C82368B2B34C68F007EEB30 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 3C82368C2B34C68F007EEB30 /* Build configuration list for PBXNativeTarget "iOS" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 3C82368D2B34C68F007EEB30 /* Debug */,
+ 3C82368E2B34C68F007EEB30 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 3C8236762B34C68E007EEB30 /* Project object */;
+}
diff --git a/samples/multiplatform-kotlin/iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/samples/multiplatform-kotlin/iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..919434a6
--- /dev/null
+++ b/samples/multiplatform-kotlin/iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/samples/multiplatform-kotlin/iOS.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/samples/multiplatform-kotlin/iOS.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 00000000..18d98100
--- /dev/null
+++ b/samples/multiplatform-kotlin/iOS.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/samples/multiplatform-kotlin/iOS/Assets.xcassets/AccentColor.colorset/Contents.json b/samples/multiplatform-kotlin/iOS/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 00000000..eb878970
--- /dev/null
+++ b/samples/multiplatform-kotlin/iOS/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/samples/multiplatform-kotlin/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/samples/multiplatform-kotlin/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000..13613e3e
--- /dev/null
+++ b/samples/multiplatform-kotlin/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,13 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/samples/multiplatform-kotlin/iOS/Assets.xcassets/Contents.json b/samples/multiplatform-kotlin/iOS/Assets.xcassets/Contents.json
new file mode 100644
index 00000000..73c00596
--- /dev/null
+++ b/samples/multiplatform-kotlin/iOS/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/samples/multiplatform-kotlin/iOS/ContentView.swift b/samples/multiplatform-kotlin/iOS/ContentView.swift
new file mode 100644
index 00000000..32ed22bd
--- /dev/null
+++ b/samples/multiplatform-kotlin/iOS/ContentView.swift
@@ -0,0 +1,24 @@
+import samples_multiplatform_kotlin_shared
+import SwiftUI
+
+struct ComposeView: UIViewControllerRepresentable {
+ func makeUIViewController(context: Context) -> UIViewController {
+ return MainViewControllerKt.MainViewController()
+ }
+
+ func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
+}
+
+struct ContentView: View {
+ var body: some View {
+ VStack {
+ ComposeView()
+ .ignoresSafeArea(.keyboard) // Compose has its own keyboard handler
+ }
+ .padding()
+ }
+}
+
+#Preview {
+ ContentView()
+}
diff --git a/samples/multiplatform-kotlin/iOS/Preview Content/Preview Assets.xcassets/Contents.json b/samples/multiplatform-kotlin/iOS/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 00000000..73c00596
--- /dev/null
+++ b/samples/multiplatform-kotlin/iOS/Preview Content/Preview Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/samples/multiplatform-kotlin/iOS/iOSApp.swift b/samples/multiplatform-kotlin/iOS/iOSApp.swift
new file mode 100644
index 00000000..927e0b97
--- /dev/null
+++ b/samples/multiplatform-kotlin/iOS/iOSApp.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+
+@main
+struct iOSApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
diff --git a/samples/multiplatform-kotlin/jvm/.gitignore b/samples/multiplatform-kotlin/jvm/.gitignore
new file mode 100644
index 00000000..3a8c2d6d
--- /dev/null
+++ b/samples/multiplatform-kotlin/jvm/.gitignore
@@ -0,0 +1 @@
+sampleClockBackupFile
diff --git a/samples/multiplatform-kotlin/jvm/build.gradle.kts b/samples/multiplatform-kotlin/jvm/build.gradle.kts
new file mode 100644
index 00000000..124188bd
--- /dev/null
+++ b/samples/multiplatform-kotlin/jvm/build.gradle.kts
@@ -0,0 +1,28 @@
+import org.jetbrains.compose.desktop.application.dsl.TargetFormat
+
+plugins {
+ kotlin("multiplatform")
+ id("org.jetbrains.compose")
+}
+
+kotlin {
+ jvm {
+ withJava()
+ }
+ sourceSets {
+ jvmMain.get().dependencies {
+ implementation(project(":samples-multiplatform-kotlin-shared"))
+ implementation(compose.desktop.currentOs)
+ }
+ }
+}
+
+compose.desktop {
+ application {
+ mainClass = "root.MainKt"
+ nativeDistributions {
+ targetFormats(TargetFormat.Dmg, TargetFormat.Deb, TargetFormat.Msi)
+ packageName = "${rootProject.name}-${project.rootDir.name}"
+ }
+ }
+}
diff --git a/samples/multiplatform-kotlin/jvm/src/jvmMain/kotlin/root/Main.kt b/samples/multiplatform-kotlin/jvm/src/jvmMain/kotlin/root/Main.kt
new file mode 100644
index 00000000..c37909a4
--- /dev/null
+++ b/samples/multiplatform-kotlin/jvm/src/jvmMain/kotlin/root/Main.kt
@@ -0,0 +1,9 @@
+package root
+
+import androidx.compose.ui.window.singleWindowApplication
+
+private val VIEWMODEL = MainViewModel()
+
+internal fun main() = singleWindowApplication(title = "Desktop sample") {
+ MainScreen(VIEWMODEL)
+}
diff --git a/samples/multiplatform-kotlin/shared/build.gradle.kts b/samples/multiplatform-kotlin/shared/build.gradle.kts
new file mode 100644
index 00000000..6aeec218
--- /dev/null
+++ b/samples/multiplatform-kotlin/shared/build.gradle.kts
@@ -0,0 +1,33 @@
+plugins {
+ kotlin("multiplatform")
+ id("org.jetbrains.compose")
+}
+
+kotlin {
+ jvm()
+ listOf(
+ macosX64(),
+ macosArm64(),
+ iosX64(),
+ iosArm64(),
+ iosSimulatorArm64(),
+ ).forEach {
+ it.binaries.framework {
+ baseName = project.name
+ binaryOption("bundleId", "com.tidal.networktime.sample-${it.targetName}")
+ }
+ }
+
+ sourceSets {
+ commonMain.get().dependencies {
+ dependencies {
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ implementation(compose.material3)
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
+ implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.1")
+ implementation(project(":library"))
+ }
+ }
+ }
+}
diff --git a/samples/multiplatform-kotlin/shared/src/commonMain/kotlin/root/MainScreen.kt b/samples/multiplatform-kotlin/shared/src/commonMain/kotlin/root/MainScreen.kt
new file mode 100644
index 00000000..dc88c2df
--- /dev/null
+++ b/samples/multiplatform-kotlin/shared/src/commonMain/kotlin/root/MainScreen.kt
@@ -0,0 +1,74 @@
+package root
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kotlinx.datetime.Instant
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Duration
+
+@Composable
+fun MainScreen(mainViewModel: MainViewModel) {
+ val state = mainViewModel.uiState.collectAsState().value
+ val textStyle = MaterialTheme.typography.bodyLarge.copy(fontFeatureSettings = "tnum")
+ MaterialTheme {
+ Scaffold { _ ->
+ Column(
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier.padding(12.dp).fillMaxSize(),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Sys=", style = textStyle)
+ Text(state.localEpoch.epochToString, style = textStyle)
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ val synchronizedEpoch = state.synchronizedEpoch
+ val deltaString = if (synchronizedEpoch == null) {
+ "N/A"
+ } else {
+ synchronizedEpoch - state.localEpoch
+ }
+ Text("Net (δ=$deltaString)=", style = textStyle)
+ Text(synchronizedEpoch?.epochToString ?: "Not yet available", style = textStyle)
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Synchronization ${if (state.synchronizationEnabled) "enabled" else "disabled"}")
+ Switch(
+ checked = state.synchronizationEnabled,
+ onCheckedChange = { mainViewModel.toggleSynchronization() },
+ )
+ }
+ }
+ }
+ }
+}
+
+private val Duration.epochToString
+ get() = Instant.fromEpochMilliseconds(inWholeMilliseconds)
+ .toLocalDateTime(TimeZone.UTC)
+ .toString()
diff --git a/samples/multiplatform-kotlin/shared/src/commonMain/kotlin/root/MainState.kt b/samples/multiplatform-kotlin/shared/src/commonMain/kotlin/root/MainState.kt
new file mode 100644
index 00000000..b54b6b75
--- /dev/null
+++ b/samples/multiplatform-kotlin/shared/src/commonMain/kotlin/root/MainState.kt
@@ -0,0 +1,9 @@
+package root
+
+import kotlin.time.Duration
+
+data class MainState(
+ val localEpoch: Duration,
+ val synchronizedEpoch: Duration?,
+ val synchronizationEnabled: Boolean,
+)
diff --git a/samples/multiplatform-kotlin/shared/src/commonMain/kotlin/root/MainViewModel.kt b/samples/multiplatform-kotlin/shared/src/commonMain/kotlin/root/MainViewModel.kt
new file mode 100644
index 00000000..5932d5d2
--- /dev/null
+++ b/samples/multiplatform-kotlin/shared/src/commonMain/kotlin/root/MainViewModel.kt
@@ -0,0 +1,56 @@
+package root
+
+import com.tidal.networktime.NTPServer
+import com.tidal.networktime.SNTPClient
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlin.properties.Delegates
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+class MainViewModel {
+ private val sntpClient = SNTPClient(
+ NTPServer(
+ "time.google.com",
+ queriesPerResolvedAddress = 1,
+ waitBetweenResolvedAddressQueries = 1.seconds,
+ ),
+ synchronizationInterval = 5.seconds,
+ backupFilePath = "sampleClockBackupFile",
+ )
+ private val stateCalculator = StateCalculator(sntpClient)
+ private var synchronizationEnabled by Delegates.observable(false) { _, _, _ ->
+ publishState()
+ }
+ private val _uiState = MutableStateFlow(calculateState())
+ val uiState = _uiState.asStateFlow()
+
+ init {
+ toggleSynchronization()
+ @Suppress("OPT_IN_USAGE")
+ GlobalScope.launch {
+ while (true) {
+ publishState()
+ delay(1.milliseconds)
+ }
+ }
+ }
+
+ fun toggleSynchronization() {
+ when (synchronizationEnabled) {
+ true -> sntpClient.disableSynchronization()
+ false -> sntpClient.enableSynchronization()
+ }
+ synchronizationEnabled = !synchronizationEnabled
+ }
+
+ private fun calculateState() = stateCalculator(synchronizationEnabled)
+
+ private fun publishState() {
+ _uiState.update { calculateState() }
+ }
+}
diff --git a/samples/multiplatform-kotlin/shared/src/commonMain/kotlin/root/StateCalculator.kt b/samples/multiplatform-kotlin/shared/src/commonMain/kotlin/root/StateCalculator.kt
new file mode 100644
index 00000000..a4b00e5f
--- /dev/null
+++ b/samples/multiplatform-kotlin/shared/src/commonMain/kotlin/root/StateCalculator.kt
@@ -0,0 +1,16 @@
+package root
+
+import com.tidal.networktime.SNTPClient
+import kotlinx.datetime.Clock
+import kotlin.time.Duration.Companion.milliseconds
+
+internal class StateCalculator(
+ private val sntpClient: SNTPClient,
+ private val localClock: Clock = Clock.System,
+) {
+ operator fun invoke(synchronizationEnabled: Boolean): MainState = MainState(
+ localEpoch = localClock.now().toEpochMilliseconds().milliseconds,
+ synchronizedEpoch = sntpClient.epochTime,
+ synchronizationEnabled,
+ )
+}
diff --git a/samples/multiplatform-kotlin/shared/src/iosMain/kotlin/MainViewController.kt b/samples/multiplatform-kotlin/shared/src/iosMain/kotlin/MainViewController.kt
new file mode 100644
index 00000000..94ebd390
--- /dev/null
+++ b/samples/multiplatform-kotlin/shared/src/iosMain/kotlin/MainViewController.kt
@@ -0,0 +1,8 @@
+import androidx.compose.ui.window.ComposeUIViewController
+import root.MainScreen
+import root.MainViewModel
+
+private val VIEWMODEL = MainViewModel()
+
+@Suppress("unused") // Used from sample iOS app XCode project
+fun MainViewController() = ComposeUIViewController { MainScreen(VIEWMODEL) }
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 00000000..d8c34e70
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,13 @@
+import java.nio.file.Paths
+
+rootProject.name = "network-time"
+
+include("library")
+listOf("android", "jvm", "shared")
+ .forEach {
+ include(":samples-multiplatform-kotlin-$it")
+ project(":samples-multiplatform-kotlin-$it").projectDir = Paths.get("samples")
+ .resolve("multiplatform-kotlin")
+ .resolve(it)
+ .toFile()
+ }