diff --git a/app/src/main/java/at/lockstep/player/util/NtpClient.kt b/app/src/main/java/at/lockstep/player/util/NtpClient.kt new file mode 100644 index 0000000..e8efdc5 --- /dev/null +++ b/app/src/main/java/at/lockstep/player/util/NtpClient.kt @@ -0,0 +1,102 @@ +package at.lockstep.player.util + +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.SocketTimeoutException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +object NtpClient { + + private const val NTP_HOST = "pool.ntp.org" + private const val NTP_PORT = 123 + private const val NTP_PACKET_SIZE = 48 + private const val TIMEOUT_MS = 5000 + private const val NTP_EPOCH_OFFSET_SECONDS = 2208988800L + + /** + * Queries [NTP_HOST] and returns how many seconds the system clock is ahead of the server. + * Positive = local clock is fast; negative = local clock is slow. + */ + @Throws(Exception::class) + fun clockOffsetSeconds(): Double { + val socket = DatagramSocket() + try { + socket.soTimeout = TIMEOUT_MS + + val requestBuffer = ByteArray(NTP_PACKET_SIZE) + requestBuffer[0] = 0x1B // LI=0, VN=3, mode=client + + val t1Millis = System.currentTimeMillis() + writeNtpTimestamp(requestBuffer, 40, t1Millis) + + val address = InetAddress.getByName(NTP_HOST) + val requestPacket = DatagramPacket(requestBuffer, requestBuffer.size, address, NTP_PORT) + socket.send(requestPacket) + + val responseBuffer = ByteArray(NTP_PACKET_SIZE) + val responsePacket = DatagramPacket(responseBuffer, responseBuffer.size) + + val errorRef = AtomicReference(null) + val receiveTimeRef = AtomicReference(null) + val latch = CountDownLatch(1) + + Thread { + try { + socket.receive(responsePacket) + receiveTimeRef.set(System.currentTimeMillis()) + } catch (e: Exception) { + errorRef.set(e) + } finally { + latch.countDown() + } + }.start() + + if (!latch.await((TIMEOUT_MS + 1000).toLong(), TimeUnit.MILLISECONDS)) { + throw SocketTimeoutException("NTP request timed out") + } + errorRef.get()?.let { throw it } + + val t4Millis = receiveTimeRef.get() + ?: throw IllegalStateException("NTP response received without timestamp") + + val t1 = t1Millis / 1000.0 + val t2 = ntpBytesToSeconds(responseBuffer, 32) + val t3 = ntpBytesToSeconds(responseBuffer, 40) + val t4 = t4Millis / 1000.0 + + return ((t1 - t2) + (t4 - t3)) / 2.0 + } finally { + socket.close() + } + } + + private fun ntpBytesToSeconds(buffer: ByteArray, offset: Int): Double { + val seconds = readUint32(buffer, offset) + val fraction = readUint32(buffer, offset + 4) + return (seconds - NTP_EPOCH_OFFSET_SECONDS) + fraction / 4294967296.0 + } + + private fun writeNtpTimestamp(buffer: ByteArray, offset: Int, millis: Long) { + val unixSeconds = millis / 1000.0 + val ntpSeconds = (unixSeconds + NTP_EPOCH_OFFSET_SECONDS).toLong() + val fraction = ((unixSeconds - unixSeconds.toLong()) * 4294967296.0).toLong() + writeUint32(buffer, offset, ntpSeconds) + writeUint32(buffer, offset + 4, fraction) + } + + private fun readUint32(buffer: ByteArray, offset: Int): Long = + ((buffer[offset].toLong() and 0xFF) shl 24) or + ((buffer[offset + 1].toLong() and 0xFF) shl 16) or + ((buffer[offset + 2].toLong() and 0xFF) shl 8) or + (buffer[offset + 3].toLong() and 0xFF) + + private fun writeUint32(buffer: ByteArray, offset: Int, value: Long) { + buffer[offset] = ((value shr 24) and 0xFF).toByte() + buffer[offset + 1] = ((value shr 16) and 0xFF).toByte() + buffer[offset + 2] = ((value shr 8) and 0xFF).toByte() + buffer[offset + 3] = (value and 0xFF).toByte() + } +}