This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.wayfinderak.ntpcheck"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.wayfinderak.ntpcheck"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
# Project-specific ProGuard rules can be added here.
|
||||
@@ -0,0 +1,20 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".NtpCheckApplication"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="NTP Check"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.wayfinderak.ntpcheck
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import com.wayfinderak.ntpcheck.ui.NtpApp
|
||||
import com.wayfinderak.ntpcheck.ui.NtpViewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val app = application as NtpCheckApplication
|
||||
val viewModel = ViewModelProvider(
|
||||
this,
|
||||
object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T =
|
||||
NtpViewModel(app.database.ntpDao()) as T
|
||||
},
|
||||
)[NtpViewModel::class.java]
|
||||
setContent { NtpApp(viewModel) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.wayfinderak.ntpcheck
|
||||
|
||||
import android.app.Application
|
||||
import com.wayfinderak.ntpcheck.data.AppDatabase
|
||||
|
||||
class NtpCheckApplication : Application() {
|
||||
val database: AppDatabase by lazy { AppDatabase.create(this) }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.wayfinderak.ntpcheck.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [ProfileEntity::class, NtpServerEntity::class, NtpResultEntity::class],
|
||||
version = 1,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun ntpDao(): NtpDao
|
||||
|
||||
companion object {
|
||||
fun create(context: Context): AppDatabase = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"ntp-check.db",
|
||||
).build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.wayfinderak.ntpcheck.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface NtpDao {
|
||||
@Query("SELECT * FROM profiles ORDER BY name")
|
||||
fun profiles(): Flow<List<ProfileEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertProfile(profile: ProfileEntity): Long
|
||||
|
||||
@Query("SELECT * FROM ntp_servers WHERE profileId = :profileId ORDER BY name")
|
||||
fun servers(profileId: Long): Flow<List<NtpServerEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertServer(server: NtpServerEntity): Long
|
||||
|
||||
@Insert
|
||||
suspend fun insertResult(result: NtpResultEntity): Long
|
||||
|
||||
@Query("SELECT * FROM ntp_results WHERE serverId = :serverId ORDER BY testedAtEpochMs DESC LIMIT :limit")
|
||||
fun recentResults(serverId: Long, limit: Int = 50): Flow<List<NtpResultEntity>>
|
||||
|
||||
@Query("DELETE FROM ntp_results WHERE testedAtEpochMs < :cutoffEpochMs")
|
||||
suspend fun deleteOldResults(cutoffEpochMs: Long)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.wayfinderak.ntpcheck.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "profiles")
|
||||
data class ProfileEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Entity(tableName = "ntp_servers")
|
||||
data class NtpServerEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val profileId: Long = 1,
|
||||
val name: String,
|
||||
val host: String,
|
||||
val port: Int = 123,
|
||||
val notes: String = "",
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
@Entity(tableName = "ntp_results")
|
||||
data class NtpResultEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val serverId: Long,
|
||||
val testedAtEpochMs: Long,
|
||||
val status: String,
|
||||
val rttMs: Double? = null,
|
||||
val offsetMs: Double? = null,
|
||||
val stratum: Int? = null,
|
||||
val referenceId: String? = null,
|
||||
val rootDelayMs: Double? = null,
|
||||
val rootDispersionMs: Double? = null,
|
||||
val leapStatus: String? = null,
|
||||
val precision: Int? = null,
|
||||
val warning: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.wayfinderak.ntpcheck.ntp
|
||||
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetAddress
|
||||
import kotlin.math.pow
|
||||
|
||||
sealed interface ProbeResult {
|
||||
data class Success(
|
||||
val rttMs: Double,
|
||||
val offsetMs: Double,
|
||||
val stratum: Int,
|
||||
val referenceId: String,
|
||||
val rootDelayMs: Double,
|
||||
val rootDispersionMs: Double,
|
||||
val leapStatus: String,
|
||||
val precision: Int,
|
||||
) : ProbeResult
|
||||
|
||||
data class Failure(val status: String, val message: String) : ProbeResult
|
||||
}
|
||||
|
||||
class NtpClient(private val timeoutMs: Int = 1500) {
|
||||
fun probe(host: String, port: Int = 123): ProbeResult {
|
||||
val address = try {
|
||||
InetAddress.getByName(host)
|
||||
} catch (ex: Exception) {
|
||||
return ProbeResult.Failure("DNS_FAILURE", ex.message ?: "DNS lookup failed")
|
||||
}
|
||||
|
||||
return try {
|
||||
DatagramSocket().use { socket ->
|
||||
socket.soTimeout = timeoutMs
|
||||
val request = ByteArray(48)
|
||||
request[0] = 0x1B // LI=0, VN=3, Mode=3 client
|
||||
val t1 = System.currentTimeMillis()
|
||||
writeTimestamp(request, 40, t1)
|
||||
socket.send(DatagramPacket(request, request.size, address, port))
|
||||
|
||||
val response = ByteArray(48)
|
||||
socket.receive(DatagramPacket(response, response.size))
|
||||
val t4 = System.currentTimeMillis()
|
||||
|
||||
val t2 = readTimestamp(response, 32)
|
||||
val t3 = readTimestamp(response, 40)
|
||||
val rtt = (t4 - t1) - (t3 - t2)
|
||||
val offset = ((t2 - t1) + (t3 - t4)) / 2.0
|
||||
|
||||
ProbeResult.Success(
|
||||
rttMs = rtt,
|
||||
offsetMs = offset,
|
||||
stratum = response[1].toInt() and 0xff,
|
||||
referenceId = referenceId(response),
|
||||
rootDelayMs = fixedPoint16_16(response, 4) * 1000.0,
|
||||
rootDispersionMs = fixedPoint16_16(response, 8) * 1000.0,
|
||||
leapStatus = leapStatus((response[0].toInt() ushr 6) and 0x3),
|
||||
precision = response[3].toInt(),
|
||||
)
|
||||
}
|
||||
} catch (ex: java.net.SocketTimeoutException) {
|
||||
ProbeResult.Failure("TIMEOUT", "No response within ${timeoutMs}ms")
|
||||
} catch (ex: Exception) {
|
||||
ProbeResult.Failure("PACKET_ERROR", ex.message ?: "Packet error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeTimestamp(buffer: ByteArray, offset: Int, timeMs: Long) {
|
||||
val seconds = timeMs / 1000L + NTP_EPOCH_OFFSET_SECONDS
|
||||
val fraction = ((timeMs % 1000L) * 0x100000000L) / 1000L
|
||||
writeUInt32(buffer, offset, seconds)
|
||||
writeUInt32(buffer, offset + 4, fraction)
|
||||
}
|
||||
|
||||
private fun readTimestamp(buffer: ByteArray, offset: Int): Double {
|
||||
val seconds = readUInt32(buffer, offset) - NTP_EPOCH_OFFSET_SECONDS
|
||||
val fractionMs = readUInt32(buffer, offset + 4) * 1000.0 / 0x100000000L.toDouble()
|
||||
return seconds * 1000.0 + fractionMs
|
||||
}
|
||||
|
||||
private fun fixedPoint16_16(buffer: ByteArray, offset: Int): Double {
|
||||
val raw = readUInt32(buffer, offset)
|
||||
return raw / 2.0.pow(16)
|
||||
}
|
||||
|
||||
private fun referenceId(buffer: ByteArray): String {
|
||||
val bytes = buffer.copyOfRange(12, 16)
|
||||
val ascii = bytes.map { it.toInt() and 0xff }.filter { it in 32..126 }
|
||||
return if (ascii.size == 4) ascii.map { it.toChar() }.joinToString("") else bytes.joinToString(".") { (it.toInt() and 0xff).toString() }
|
||||
}
|
||||
|
||||
private fun leapStatus(value: Int): String = when (value) {
|
||||
0 -> "NO_WARNING"
|
||||
1 -> "LAST_MINUTE_61_SECONDS"
|
||||
2 -> "LAST_MINUTE_59_SECONDS"
|
||||
else -> "ALARM_UNSYNCHRONIZED"
|
||||
}
|
||||
|
||||
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 ushr 24).toByte()
|
||||
buffer[offset + 1] = (value ushr 16).toByte()
|
||||
buffer[offset + 2] = (value ushr 8).toByte()
|
||||
buffer[offset + 3] = value.toByte()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val NTP_EPOCH_OFFSET_SECONDS = 2_208_988_800L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.wayfinderak.ntpcheck.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun NtpApp(viewModel: NtpViewModel) {
|
||||
val servers by viewModel.servers.collectAsState()
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("NTP Check", style = MaterialTheme.typography.headlineMedium)
|
||||
Button(onClick = viewModel::testAll) { Text("Test all") }
|
||||
}
|
||||
Text("Server list + live comparison dashboard foundation")
|
||||
Spacer(Modifier.height(16.dp))
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
items(servers) { server ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(server.name, style = MaterialTheme.typography.titleMedium)
|
||||
Text("${server.host}:${server.port}")
|
||||
if (server.notes.isNotBlank()) Text(server.notes)
|
||||
Text("Initial scaffold: latest results, sorting, scores, and warnings land in Phase 2.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.wayfinderak.ntpcheck.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.wayfinderak.ntpcheck.data.NtpDao
|
||||
import com.wayfinderak.ntpcheck.data.NtpResultEntity
|
||||
import com.wayfinderak.ntpcheck.data.NtpServerEntity
|
||||
import com.wayfinderak.ntpcheck.data.ProfileEntity
|
||||
import com.wayfinderak.ntpcheck.ntp.NtpClient
|
||||
import com.wayfinderak.ntpcheck.ntp.ProbeResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NtpViewModel(private val dao: NtpDao) : ViewModel() {
|
||||
private val client = NtpClient()
|
||||
val servers: StateFlow<List<NtpServerEntity>> = dao.servers(1).stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.insertProfile(ProfileEntity(id = 1, name = "Default"))
|
||||
if (servers.value.isEmpty()) {
|
||||
dao.insertServer(NtpServerEntity(name = "NIST", host = "time.nist.gov"))
|
||||
dao.insertServer(NtpServerEntity(name = "Cloudflare", host = "time.cloudflare.com"))
|
||||
dao.insertServer(NtpServerEntity(name = "Google", host = "time.google.com"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun testAll() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
servers.value.filter { it.enabled }.forEach { server ->
|
||||
val result = when (val probe = client.probe(server.host, server.port)) {
|
||||
is ProbeResult.Success -> NtpResultEntity(
|
||||
serverId = server.id,
|
||||
testedAtEpochMs = System.currentTimeMillis(),
|
||||
status = "REACHABLE",
|
||||
rttMs = probe.rttMs,
|
||||
offsetMs = probe.offsetMs,
|
||||
stratum = probe.stratum,
|
||||
referenceId = probe.referenceId,
|
||||
rootDelayMs = probe.rootDelayMs,
|
||||
rootDispersionMs = probe.rootDispersionMs,
|
||||
leapStatus = probe.leapStatus,
|
||||
precision = probe.precision,
|
||||
warning = warningFor(probe),
|
||||
)
|
||||
is ProbeResult.Failure -> NtpResultEntity(
|
||||
serverId = server.id,
|
||||
testedAtEpochMs = System.currentTimeMillis(),
|
||||
status = probe.status,
|
||||
warning = probe.message,
|
||||
)
|
||||
}
|
||||
dao.insertResult(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun warningFor(probe: ProbeResult.Success): String? = when {
|
||||
probe.leapStatus == "ALARM_UNSYNCHRONIZED" -> "Leap alarm / unsynchronized"
|
||||
probe.rttMs > 250 -> "High RTT"
|
||||
probe.rootDispersionMs > 100 -> "High root dispersion"
|
||||
probe.stratum >= 16 -> "Unsynchronized stratum"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" android:viewportWidth="48" android:viewportHeight="48">
|
||||
<path android:fillColor="#0F172A" android:pathData="M0,0h48v48h-48z"/>
|
||||
<path android:fillColor="#38BDF8" android:pathData="M24,6a18,18 0,1 0,0.1 0M24,10a14,14 0,1 1,-0.1 0"/>
|
||||
<path android:strokeColor="#E0F2FE" android:strokeWidth="3" android:strokeLineCap="round" android:fillColor="#00000000" android:pathData="M24,14v11l8,5"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
Reference in New Issue
Block a user