Initialize Android NTP app framework
android-apk / debug-apk (push) Has been cancelled

This commit is contained in:
2026-07-09 15:44:22 -08:00
commit bfb027fcd2
33 changed files with 1196 additions and 0 deletions
+63
View File
@@ -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")
}
+1
View File
@@ -0,0 +1 @@
# Project-specific ProGuard rules can be added here.
+20
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar" />
</resources>