One-shot NTP app phases
Some checks failed
android-apk / debug-apk (push) Failing after 2m21s

This commit is contained in:
WayfinderAK 2026-07-09 15:55:03 -08:00
parent bfb027fcd2
commit ff48fe2ef2
No known key found for this signature in database
25 changed files with 768 additions and 53 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
.gradle/
.kotlin/
build/
**/build/
local.properties

View File

@ -41,6 +41,10 @@ android {
}
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)

View File

@ -0,0 +1,188 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "716ef3f2e3462026bd1e0029da2e3d68",
"entities": [
{
"tableName": "profiles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ntp_servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `profileId` INTEGER NOT NULL, `name` TEXT NOT NULL, `host` TEXT NOT NULL, `port` INTEGER NOT NULL, `notes` TEXT NOT NULL, `enabled` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "profileId",
"columnName": "profileId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "port",
"columnName": "port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notes",
"columnName": "notes",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ntp_results",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serverId` INTEGER NOT NULL, `testedAtEpochMs` INTEGER NOT NULL, `status` TEXT NOT NULL, `rttMs` REAL, `offsetMs` REAL, `stratum` INTEGER, `referenceId` TEXT, `rootDelayMs` REAL, `rootDispersionMs` REAL, `leapStatus` TEXT, `precision` INTEGER, `warning` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "testedAtEpochMs",
"columnName": "testedAtEpochMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rttMs",
"columnName": "rttMs",
"affinity": "REAL",
"notNull": false
},
{
"fieldPath": "offsetMs",
"columnName": "offsetMs",
"affinity": "REAL",
"notNull": false
},
{
"fieldPath": "stratum",
"columnName": "stratum",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "referenceId",
"columnName": "referenceId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rootDelayMs",
"columnName": "rootDelayMs",
"affinity": "REAL",
"notNull": false
},
{
"fieldPath": "rootDispersionMs",
"columnName": "rootDispersionMs",
"affinity": "REAL",
"notNull": false
},
{
"fieldPath": "leapStatus",
"columnName": "leapStatus",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "precision",
"columnName": "precision",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "warning",
"columnName": "warning",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '716ef3f2e3462026bd1e0029da2e3d68')"
]
}
}

View File

@ -4,8 +4,7 @@
<application
android:name=".NtpCheckApplication"
android:allowBackup="true"
android:fullBackupContent="true"
android:icon="@mipmap/ic_launcher"
android:icon="@drawable/ic_launcher"
android:label="NTP Check"
android:theme="@style/AppTheme">
<activity

View File

@ -1,9 +1,11 @@
package com.wayfinderak.ntpcheck.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
@ -11,21 +13,45 @@ interface NtpDao {
@Query("SELECT * FROM profiles ORDER BY name")
fun profiles(): Flow<List<ProfileEntity>>
@Query("SELECT COUNT(*) FROM profiles")
suspend fun profileCount(): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertProfile(profile: ProfileEntity): Long
@Update
suspend fun updateProfile(profile: ProfileEntity)
@Delete
suspend fun deleteProfile(profile: ProfileEntity)
@Query("SELECT * FROM ntp_servers WHERE profileId = :profileId ORDER BY name")
fun servers(profileId: Long): Flow<List<NtpServerEntity>>
@Query("SELECT COUNT(*) FROM ntp_servers")
suspend fun serverCount(): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertServer(server: NtpServerEntity): Long
@Update
suspend fun updateServer(server: NtpServerEntity)
@Delete
suspend fun deleteServer(server: NtpServerEntity)
@Insert
suspend fun insertResult(result: NtpResultEntity): Long
@Query("SELECT * FROM ntp_results WHERE id IN (SELECT MAX(id) FROM ntp_results GROUP BY serverId)")
fun latestResults(): Flow<List<NtpResultEntity>>
@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("SELECT * FROM ntp_results ORDER BY testedAtEpochMs DESC LIMIT :limit")
suspend fun exportResults(limit: Int = 500): List<NtpResultEntity>
@Query("DELETE FROM ntp_results WHERE testedAtEpochMs < :cutoffEpochMs")
suspend fun deleteOldResults(cutoffEpochMs: Long)
}

View File

@ -1,7 +1,12 @@
@file:OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
package com.wayfinderak.ntpcheck.ui
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@ -12,40 +17,191 @@ 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.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@Composable
fun NtpApp(viewModel: NtpViewModel) {
val servers by viewModel.servers.collectAsState()
val state by viewModel.uiState.collectAsState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Column(Modifier.padding(16.dp)) {
LazyColumn(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text("NTP Check", style = MaterialTheme.typography.headlineMedium)
Text("Local-first NTP comparison dashboard")
}
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 ->
}
item { Profiles(state, viewModel) }
item { Controls(state, viewModel) }
item {
state.bestCandidate?.let {
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.")
Text("Best candidate", style = MaterialTheme.typography.titleMedium)
Text("${it.server.name} — health ${it.healthScore}/100")
}
}
}
}
item { AddServerForm(viewModel) }
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = {
scope.launch {
val csv = viewModel.exportCsv()
context.startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND).apply {
type = "text/csv"
putExtra(Intent.EXTRA_SUBJECT, "NTP Check results")
putExtra(Intent.EXTRA_TEXT, csv)
},
"Share NTP results",
),
)
}
},
) { Text("Export CSV") }
OutlinedButton(onClick = { context.startActivity(Intent(Settings.ACTION_DATE_SETTINGS)) }) {
Text("Date & Time settings")
}
}
}
items(state.rows, key = { it.server.id }) { row -> ServerCard(row, viewModel) }
}
}
}
}
@Composable
private fun Profiles(state: NtpUiState, viewModel: NtpViewModel) {
var profileName by remember { mutableStateOf("") }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Profiles", style = MaterialTheme.typography.titleMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
state.profiles.forEach { profile ->
FilterChip(
selected = profile.id == state.activeProfileId,
onClick = { viewModel.setProfile(profile.id) },
label = { Text(profile.name) },
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = profileName,
onValueChange = { profileName = it },
label = { Text("New profile") },
modifier = Modifier.weight(1f),
)
Button(onClick = { viewModel.addProfile(profileName); profileName = "" }) { Text("Add") }
}
}
}
}
@Composable
private fun Controls(state: NtpUiState, viewModel: NtpViewModel) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Sort", style = MaterialTheme.typography.titleMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SortMode.entries.forEach { mode ->
FilterChip(
selected = state.sortMode == mode,
onClick = { viewModel.setSortMode(mode) },
label = { Text(mode.name.lowercase().replaceFirstChar { it.uppercase() }) },
)
}
}
Text("Continuous test", style = MaterialTheme.typography.titleMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(null to "Off", 1_000L to "1s", 5_000L to "5s", 10_000L to "10s", 30_000L to "30s").forEach { (interval, label) ->
FilterChip(
selected = state.continuousIntervalMs == interval,
onClick = { viewModel.setContinuous(interval) },
label = { Text(label) },
)
}
}
}
}
}
@Composable
private fun AddServerForm(viewModel: NtpViewModel) {
var name by remember { mutableStateOf("") }
var host by remember { mutableStateOf("") }
var port by remember { mutableStateOf("123") }
var notes by remember { mutableStateOf("") }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Add server", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(name, { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(host, { host = it }, label = { Text("Hostname/IP") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(port, { port = it }, label = { Text("Port") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(notes, { notes = it }, label = { Text("Notes") }, modifier = Modifier.fillMaxWidth())
Button(onClick = {
viewModel.addServer(name, host, port, notes)
name = ""; host = ""; port = "123"; notes = ""
}) { Text("Save server") }
}
}
}
@Composable
private fun ServerCard(row: ServerRow, viewModel: NtpViewModel) {
val result = row.latest
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text(row.server.name, style = MaterialTheme.typography.titleMedium)
Text("${row.server.host}:${row.server.port}")
}
Text("${row.healthScore}/100")
}
Text("Status: ${result?.status ?: "untested"}")
result?.rttMs?.let { Text("RTT: ${it.formatMs()}") }
result?.offsetMs?.let { Text("Offset: ${it.formatMs()}") }
result?.stratum?.let { Text("Stratum: $it") }
result?.referenceId?.let { Text("Reference: $it") }
result?.rootDelayMs?.let { Text("Root delay: ${it.formatMs()}") }
result?.rootDispersionMs?.let { Text("Root dispersion: ${it.formatMs()}") }
result?.leapStatus?.let { Text("Leap: $it") }
result?.precision?.let { Text("Precision: $it") }
result?.warning?.let { Text("Warning: $it", color = MaterialTheme.colorScheme.error) }
row.comparisonWarning?.let { Text("Comparison: $it", color = MaterialTheme.colorScheme.error) }
if (row.server.notes.isNotBlank()) Text("Notes: ${row.server.notes}")
Spacer(Modifier.height(4.dp))
TextButton(onClick = { viewModel.deleteServer(row.server) }) { Text("Delete") }
}
}
}

View File

@ -9,34 +9,156 @@ 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.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
import kotlin.math.abs
@Suppress("OPT_IN_USAGE")
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(),
private val activeProfileId = MutableStateFlow(1L)
private val sortMode = MutableStateFlow(SortMode.HEALTH)
private val continuousIntervalMs = MutableStateFlow<Long?>(null)
private var continuousJob: Job? = null
private val controls = combine(activeProfileId, sortMode, continuousIntervalMs) { profileId, sort, interval ->
UiControls(profileId, sort, interval)
}
val uiState: StateFlow<NtpUiState> = combine(
dao.profiles(),
activeProfileId.flatMapLatest { dao.servers(it) },
dao.latestResults(),
controls,
) { profiles, servers, results, controls ->
val latestByServer = results.associateBy { it.serverId }
val medianOffset = results.mapNotNull { it.offsetMs }.sorted().medianOrNull()
val rows = servers.map { server ->
val latest = latestByServer[server.id]
ServerRow(
server = server,
latest = latest,
healthScore = healthScore(latest, medianOffset),
comparisonWarning = comparisonWarning(latest, medianOffset),
)
}.sortedWith(comparatorFor(controls.sortMode))
NtpUiState(
profiles = profiles,
activeProfileId = controls.profileId,
rows = rows,
sortMode = controls.sortMode,
continuousIntervalMs = controls.continuousIntervalMs,
bestCandidate = rows.filter { it.latest?.status == "REACHABLE" }.maxByOrNull { it.healthScore },
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), NtpUiState())
init {
viewModelScope.launch(Dispatchers.IO) { seedDefaults() }
}
fun setSortMode(mode: SortMode) {
sortMode.value = mode
}
fun setProfile(id: Long) {
activeProfileId.value = id
}
fun addProfile(name: String) {
val clean = name.trim()
if (clean.isBlank()) return
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"))
val id = dao.insertProfile(ProfileEntity(name = clean))
activeProfileId.value = id
}
}
fun addServer(name: String, host: String, port: String, notes: String) {
val cleanHost = host.trim()
if (cleanHost.isBlank()) return
viewModelScope.launch(Dispatchers.IO) {
dao.insertServer(
NtpServerEntity(
profileId = activeProfileId.value,
name = name.trim().ifBlank { cleanHost },
host = cleanHost,
port = port.toIntOrNull()?.coerceIn(1, 65535) ?: 123,
notes = notes.trim(),
),
)
}
}
fun deleteServer(server: NtpServerEntity) {
viewModelScope.launch(Dispatchers.IO) { dao.deleteServer(server) }
}
fun testAll() {
viewModelScope.launch(Dispatchers.IO) {
servers.value.filter { it.enabled }.forEach { server ->
val result = when (val probe = client.probe(server.host, server.port)) {
viewModelScope.launch(Dispatchers.IO) { testServers(uiState.value.rows.map { it.server }.filter { it.enabled }) }
}
fun setContinuous(intervalMs: Long?) {
continuousJob?.cancel()
continuousIntervalMs.value = intervalMs
if (intervalMs == null) return
continuousJob = viewModelScope.launch(Dispatchers.IO) {
while (true) {
testServers(uiState.value.rows.map { it.server }.filter { it.enabled })
delay(intervalMs)
}
}
}
suspend fun exportCsv(): String = withContext(Dispatchers.IO) {
val serversById = uiState.value.rows.associate { it.server.id to it.server }
buildString {
appendLine("tested_at_epoch_ms,profile_id,server_name,host,port,status,rtt_ms,offset_ms,stratum,reference_id,root_delay_ms,root_dispersion_ms,leap_status,precision,warning")
dao.exportResults().asReversed().forEach { result ->
val server = serversById[result.serverId]
appendCsv(result.testedAtEpochMs)
appendCsv(server?.profileId ?: "")
appendCsv(server?.name ?: result.serverId)
appendCsv(server?.host ?: "")
appendCsv(server?.port ?: "")
appendCsv(result.status)
appendCsv(result.rttMs ?: "")
appendCsv(result.offsetMs ?: "")
appendCsv(result.stratum ?: "")
appendCsv(result.referenceId ?: "")
appendCsv(result.rootDelayMs ?: "")
appendCsv(result.rootDispersionMs ?: "")
appendCsv(result.leapStatus ?: "")
appendCsv(result.precision ?: "")
appendCsv(result.warning ?: "", last = true)
}
}
}
private suspend fun seedDefaults() {
if (dao.profileCount() == 0) dao.insertProfile(ProfileEntity(id = 1, name = "Default"))
if (dao.serverCount() == 0) {
dao.insertServer(NtpServerEntity(profileId = 1, name = "NIST", host = "time.nist.gov"))
dao.insertServer(NtpServerEntity(profileId = 1, name = "Cloudflare", host = "time.cloudflare.com"))
dao.insertServer(NtpServerEntity(profileId = 1, name = "Google", host = "time.google.com"))
}
}
private suspend fun testServers(servers: List<NtpServerEntity>) {
dao.deleteOldResults(System.currentTimeMillis() - HISTORY_RETENTION_MS)
servers.forEach { server -> dao.insertResult(probe(server)) }
}
private fun probe(server: NtpServerEntity): NtpResultEntity = when (val probe = client.probe(server.host, server.port)) {
is ProbeResult.Success -> NtpResultEntity(
serverId = server.id,
testedAtEpochMs = System.currentTimeMillis(),
@ -58,16 +180,80 @@ class NtpViewModel(private val dao: NtpDao) : ViewModel() {
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"
abs(probe.offsetMs) > 1_000 -> "Local clock offset exceeds 1s"
else -> null
}
private fun comparisonWarning(result: NtpResultEntity?, medianOffset: Double?): String? = when {
result == null || medianOffset == null || result.offsetMs == null -> null
abs(result.offsetMs - medianOffset) > 100 -> "Offset differs from peer median"
else -> null
}
private fun healthScore(result: NtpResultEntity?, medianOffset: Double?): Int {
if (result?.status != "REACHABLE") return 0
var score = 100
score -= ((result.rttMs ?: 0.0) / 10).toInt().coerceAtMost(30)
score -= ((result.rootDispersionMs ?: 0.0) / 10).toInt().coerceAtMost(25)
score -= ((result.stratum ?: 16) - 1).coerceAtLeast(0).coerceAtMost(15)
if (result.leapStatus != "NO_WARNING") score -= 25
if (medianOffset != null && result.offsetMs != null && abs(result.offsetMs - medianOffset) > 100) score -= 30
return score.coerceIn(0, 100)
}
private fun comparatorFor(sort: SortMode): Comparator<ServerRow> = when (sort) {
SortMode.HEALTH -> compareByDescending<ServerRow> { it.healthScore }.thenBy { it.server.name }
SortMode.LATENCY -> compareBy<ServerRow> { it.latest?.rttMs ?: Double.MAX_VALUE }.thenBy { it.server.name }
SortMode.OFFSET -> compareBy<ServerRow> { abs(it.latest?.offsetMs ?: Double.MAX_VALUE) }.thenBy { it.server.name }
SortMode.STRATUM -> compareBy<ServerRow> { it.latest?.stratum ?: Int.MAX_VALUE }.thenBy { it.server.name }
SortMode.REACHABILITY -> compareByDescending<ServerRow> { it.latest?.status == "REACHABLE" }.thenBy { it.server.name }
}
private fun List<Double>.medianOrNull(): Double? = when {
isEmpty() -> null
size % 2 == 1 -> this[size / 2]
else -> (this[size / 2 - 1] + this[size / 2]) / 2.0
}
private fun StringBuilder.appendCsv(value: Any, last: Boolean = false) {
val escaped = value.toString().replace("\"", "\"\"")
append('"').append(escaped).append('"')
if (last) appendLine() else append(',')
}
companion object {
private const val HISTORY_RETENTION_MS = 7L * 24L * 60L * 60L * 1000L
}
}
data class NtpUiState(
val profiles: List<ProfileEntity> = emptyList(),
val activeProfileId: Long = 1,
val rows: List<ServerRow> = emptyList(),
val sortMode: SortMode = SortMode.HEALTH,
val continuousIntervalMs: Long? = null,
val bestCandidate: ServerRow? = null,
)
data class ServerRow(
val server: NtpServerEntity,
val latest: NtpResultEntity?,
val healthScore: Int,
val comparisonWarning: String?,
)
private data class UiControls(
val profileId: Long,
val sortMode: SortMode,
val continuousIntervalMs: Long?,
)
enum class SortMode { HEALTH, LATENCY, OFFSET, STRATUM, REACHABILITY }
fun Double.formatMs(): String = String.format(Locale.US, "%.1f ms", this)

3
gradle.properties Normal file
View File

@ -0,0 +1,3 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

View File

@ -0,0 +1,18 @@
# ADR 0003: Privileged install track
## Decision
Ordinary sideloading will not make NTP Check a privileged app. The main app remains a normal diagnostic app. A future separate privileged/root build track may be designed, but it must not be mixed into the default release.
## Notes
A privileged Android app generally has to be installed on a privileged partition such as `/system/priv-app/...` and allowed through `privapp-permissions` XML. On many devices, clock-setting APIs may also require platform signing or ROM control. Root/Magisk or a custom ROM can make this possible, but it is device-specific and not appropriate for v1.
## Consequence
Default release behavior is:
- measure NTP offset
- warn when local time appears wrong
- open Android Date & Time settings
- never silently attempt clock changes

View File

@ -4,6 +4,5 @@
- `make validate-structure`: passed.
- `gradle wrapper --gradle-version 8.10.2 --distribution-type bin`: passed after adding Kotlin Compose compiler plugin.
- `./gradlew --no-daemon :app:assembleDebug`: blocked locally because the default Java 26 runtime is unsupported by Gradle 8.10.2.
- `JAVA_HOME=/usr/lib/jvm/java-21-openjdk ./gradlew --no-daemon :app:assembleDebug`: blocked locally because the installed Java 21 environment does not provide `javac` compiler capabilities.
- `gradle --no-daemon :app:assembleDebug`: Android resources, KSP, and Kotlin compilation passed; final Java compile/JDK image step is blocked locally by Java 26 `jlink` incompatibility. CI uses JDK 17.
- Gitea workflow added for automatic debug APK builds with JDK 17 and Android SDK setup.

View File

@ -0,0 +1,6 @@
# Phase 1 Open Issues
- Confirm first Gitea Actions APK artifact builds successfully.
- Add instrumented emulator/device smoke testing once runner or local SDK/JDK is confirmed.
- Add signed release workflow using Gitea secrets.
- Privileged/system-time setting remains out of default scope; see ADR 0002 and ADR 0003.

View File

@ -0,0 +1,11 @@
# Phase 1 Summary
Implemented in the one-shot pass:
- Phase 1 probe correctness: retained raw UDP NTP probing with reachable/DNS/timeout/packet statuses and all requested NTP metrics in the persisted result model.
- Phase 2 dashboard MVP: added profile-aware server list state, latest results, sorting modes, health scoring, and best-candidate selection.
- Phase 3 continuous testing: added continuous polling intervals of 1s, 5s, 10s, and 30s with stop/off control, history retention cleanup, median-offset disagreement warning, and local clock offset warning.
- Phase 4 profiles/export: added profile creation/switching, add/delete server flow, and CSV export/share intent.
- Phase 5 UX/release prep: added Date & Time settings action, Gradle wrapper, Gitea debug APK artifact workflow, and documented release signing follow-up.
See the app source under .

View File

@ -0,0 +1,10 @@
# Phase 1 Test Results
Validation performed in this session:
- Structure validation previously passed.
- Gradle wrapper generation passed after adding the Kotlin Compose compiler plugin.
- `gradle --no-daemon :app:assembleDebug`: Android resources, KSP, and Kotlin compilation passed; final Java compile/JDK image step is blocked locally by Java 26 `jlink` incompatibility. CI uses JDK 17.
- Gitea Actions workflow is configured to build with JDK 17 and Android SDK, so push-triggered CI is the intended APK validation path.
Required follow-up: inspect the first Gitea Actions run and fix any Android compile/lint findings from the runner.

View File

@ -0,0 +1,6 @@
# Phase 2 Open Issues
- Confirm first Gitea Actions APK artifact builds successfully.
- Add instrumented emulator/device smoke testing once runner or local SDK/JDK is confirmed.
- Add signed release workflow using Gitea secrets.
- Privileged/system-time setting remains out of default scope; see ADR 0002 and ADR 0003.

View File

@ -0,0 +1,11 @@
# Phase 2 Summary
Implemented in the one-shot pass:
- Phase 1 probe correctness: retained raw UDP NTP probing with reachable/DNS/timeout/packet statuses and all requested NTP metrics in the persisted result model.
- Phase 2 dashboard MVP: added profile-aware server list state, latest results, sorting modes, health scoring, and best-candidate selection.
- Phase 3 continuous testing: added continuous polling intervals of 1s, 5s, 10s, and 30s with stop/off control, history retention cleanup, median-offset disagreement warning, and local clock offset warning.
- Phase 4 profiles/export: added profile creation/switching, add/delete server flow, and CSV export/share intent.
- Phase 5 UX/release prep: added Date & Time settings action, Gradle wrapper, Gitea debug APK artifact workflow, and documented release signing follow-up.
See the app source under .

View File

@ -0,0 +1,10 @@
# Phase 2 Test Results
Validation performed in this session:
- Structure validation previously passed.
- Gradle wrapper generation passed after adding the Kotlin Compose compiler plugin.
- `gradle --no-daemon :app:assembleDebug`: Android resources, KSP, and Kotlin compilation passed; final Java compile/JDK image step is blocked locally by Java 26 `jlink` incompatibility. CI uses JDK 17.
- Gitea Actions workflow is configured to build with JDK 17 and Android SDK, so push-triggered CI is the intended APK validation path.
Required follow-up: inspect the first Gitea Actions run and fix any Android compile/lint findings from the runner.

View File

@ -0,0 +1,6 @@
# Phase 3 Open Issues
- Confirm first Gitea Actions APK artifact builds successfully.
- Add instrumented emulator/device smoke testing once runner or local SDK/JDK is confirmed.
- Add signed release workflow using Gitea secrets.
- Privileged/system-time setting remains out of default scope; see ADR 0002 and ADR 0003.

View File

@ -0,0 +1,11 @@
# Phase 3 Summary
Implemented in the one-shot pass:
- Phase 1 probe correctness: retained raw UDP NTP probing with reachable/DNS/timeout/packet statuses and all requested NTP metrics in the persisted result model.
- Phase 2 dashboard MVP: added profile-aware server list state, latest results, sorting modes, health scoring, and best-candidate selection.
- Phase 3 continuous testing: added continuous polling intervals of 1s, 5s, 10s, and 30s with stop/off control, history retention cleanup, median-offset disagreement warning, and local clock offset warning.
- Phase 4 profiles/export: added profile creation/switching, add/delete server flow, and CSV export/share intent.
- Phase 5 UX/release prep: added Date & Time settings action, Gradle wrapper, Gitea debug APK artifact workflow, and documented release signing follow-up.
See the app source under .

View File

@ -0,0 +1,10 @@
# Phase 3 Test Results
Validation performed in this session:
- Structure validation previously passed.
- Gradle wrapper generation passed after adding the Kotlin Compose compiler plugin.
- `gradle --no-daemon :app:assembleDebug`: Android resources, KSP, and Kotlin compilation passed; final Java compile/JDK image step is blocked locally by Java 26 `jlink` incompatibility. CI uses JDK 17.
- Gitea Actions workflow is configured to build with JDK 17 and Android SDK, so push-triggered CI is the intended APK validation path.
Required follow-up: inspect the first Gitea Actions run and fix any Android compile/lint findings from the runner.

View File

@ -0,0 +1,6 @@
# Phase 4 Open Issues
- Confirm first Gitea Actions APK artifact builds successfully.
- Add instrumented emulator/device smoke testing once runner or local SDK/JDK is confirmed.
- Add signed release workflow using Gitea secrets.
- Privileged/system-time setting remains out of default scope; see ADR 0002 and ADR 0003.

View File

@ -0,0 +1,11 @@
# Phase 4 Summary
Implemented in the one-shot pass:
- Phase 1 probe correctness: retained raw UDP NTP probing with reachable/DNS/timeout/packet statuses and all requested NTP metrics in the persisted result model.
- Phase 2 dashboard MVP: added profile-aware server list state, latest results, sorting modes, health scoring, and best-candidate selection.
- Phase 3 continuous testing: added continuous polling intervals of 1s, 5s, 10s, and 30s with stop/off control, history retention cleanup, median-offset disagreement warning, and local clock offset warning.
- Phase 4 profiles/export: added profile creation/switching, add/delete server flow, and CSV export/share intent.
- Phase 5 UX/release prep: added Date & Time settings action, Gradle wrapper, Gitea debug APK artifact workflow, and documented release signing follow-up.
See the app source under .

View File

@ -0,0 +1,10 @@
# Phase 4 Test Results
Validation performed in this session:
- Structure validation previously passed.
- Gradle wrapper generation passed after adding the Kotlin Compose compiler plugin.
- `gradle --no-daemon :app:assembleDebug`: Android resources, KSP, and Kotlin compilation passed; final Java compile/JDK image step is blocked locally by Java 26 `jlink` incompatibility. CI uses JDK 17.
- Gitea Actions workflow is configured to build with JDK 17 and Android SDK, so push-triggered CI is the intended APK validation path.
Required follow-up: inspect the first Gitea Actions run and fix any Android compile/lint findings from the runner.

View File

@ -0,0 +1,6 @@
# Phase 5 Open Issues
- Confirm first Gitea Actions APK artifact builds successfully.
- Add instrumented emulator/device smoke testing once runner or local SDK/JDK is confirmed.
- Add signed release workflow using Gitea secrets.
- Privileged/system-time setting remains out of default scope; see ADR 0002 and ADR 0003.

View File

@ -0,0 +1,11 @@
# Phase 5 Summary
Implemented in the one-shot pass:
- Phase 1 probe correctness: retained raw UDP NTP probing with reachable/DNS/timeout/packet statuses and all requested NTP metrics in the persisted result model.
- Phase 2 dashboard MVP: added profile-aware server list state, latest results, sorting modes, health scoring, and best-candidate selection.
- Phase 3 continuous testing: added continuous polling intervals of 1s, 5s, 10s, and 30s with stop/off control, history retention cleanup, median-offset disagreement warning, and local clock offset warning.
- Phase 4 profiles/export: added profile creation/switching, add/delete server flow, and CSV export/share intent.
- Phase 5 UX/release prep: added Date & Time settings action, Gradle wrapper, Gitea debug APK artifact workflow, and documented release signing follow-up.
See the app source under .

View File

@ -0,0 +1,10 @@
# Phase 5 Test Results
Validation performed in this session:
- Structure validation previously passed.
- Gradle wrapper generation passed after adding the Kotlin Compose compiler plugin.
- `gradle --no-daemon :app:assembleDebug`: Android resources, KSP, and Kotlin compilation passed; final Java compile/JDK image step is blocked locally by Java 26 `jlink` incompatibility. CI uses JDK 17.
- Gitea Actions workflow is configured to build with JDK 17 and Android SDK, so push-triggered CI is the intended APK validation path.
Required follow-up: inspect the first Gitea Actions run and fix any Android compile/lint findings from the runner.