diff --git a/.gitignore b/.gitignore index 3768628..3c2cf9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .gradle/ +.kotlin/ build/ **/build/ local.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d19e23f..ce8320a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -41,6 +41,10 @@ android { } } +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + dependencies { val composeBom = platform("androidx.compose:compose-bom:2024.12.01") implementation(composeBom) diff --git a/app/schemas/com.wayfinderak.ntpcheck.data.AppDatabase/1.json b/app/schemas/com.wayfinderak.ntpcheck.data.AppDatabase/1.json new file mode 100644 index 0000000..7b2e8b4 --- /dev/null +++ b/app/schemas/com.wayfinderak.ntpcheck.data.AppDatabase/1.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 929980e..1cbe9d0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,8 +4,7 @@ > + @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> + @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> + @Query("SELECT * FROM ntp_results WHERE serverId = :serverId ORDER BY testedAtEpochMs DESC LIMIT :limit") fun recentResults(serverId: Long, limit: Int = 50): Flow> + @Query("SELECT * FROM ntp_results ORDER BY testedAtEpochMs DESC LIMIT :limit") + suspend fun exportResults(limit: Int = 500): List + @Query("DELETE FROM ntp_results WHERE testedAtEpochMs < :cutoffEpochMs") suspend fun deleteOldResults(cutoffEpochMs: Long) } diff --git a/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpScreen.kt b/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpScreen.kt index 447246a..13ae525 100644 --- a/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpScreen.kt +++ b/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpScreen.kt @@ -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)) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("NTP Check", style = MaterialTheme.typography.headlineMedium) - Button(onClick = viewModel::testAll) { Text("Test all") } + 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") } + } + } +} diff --git a/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpViewModel.kt b/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpViewModel.kt index 3857d04..0a9f411 100644 --- a/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpViewModel.kt +++ b/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpViewModel.kt @@ -9,65 +9,251 @@ 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> = 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(null) + private var continuousJob: Job? = null + + private val controls = combine(activeProfileId, sortMode, continuousIntervalMs) { profileId, sort, interval -> + UiControls(profileId, sort, interval) + } + + val uiState: StateFlow = 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) { 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) } } } - 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) + 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) { + 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(), + 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, + ) + } + 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 = when (sort) { + SortMode.HEALTH -> compareByDescending { it.healthScore }.thenBy { it.server.name } + SortMode.LATENCY -> compareBy { it.latest?.rttMs ?: Double.MAX_VALUE }.thenBy { it.server.name } + SortMode.OFFSET -> compareBy { abs(it.latest?.offsetMs ?: Double.MAX_VALUE) }.thenBy { it.server.name } + SortMode.STRATUM -> compareBy { it.latest?.stratum ?: Int.MAX_VALUE }.thenBy { it.server.name } + SortMode.REACHABILITY -> compareByDescending { it.latest?.status == "REACHABLE" }.thenBy { it.server.name } + } + + private fun List.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 = emptyList(), + val activeProfileId: Long = 1, + val rows: List = 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) diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..bae9034 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +android.nonTransitiveRClass=true +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 diff --git a/project-docs/decisions/0003-privileged-install-track.md b/project-docs/decisions/0003-privileged-install-track.md new file mode 100644 index 0000000..1d62819 --- /dev/null +++ b/project-docs/decisions/0003-privileged-install-track.md @@ -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 diff --git a/project-docs/status/phase-0-test-results.md b/project-docs/status/phase-0-test-results.md index 9af1a58..91fc54d 100644 --- a/project-docs/status/phase-0-test-results.md +++ b/project-docs/status/phase-0-test-results.md @@ -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. diff --git a/project-docs/status/phase-1-open-issues.md b/project-docs/status/phase-1-open-issues.md new file mode 100644 index 0000000..47cfb29 --- /dev/null +++ b/project-docs/status/phase-1-open-issues.md @@ -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. diff --git a/project-docs/status/phase-1-summary.md b/project-docs/status/phase-1-summary.md new file mode 100644 index 0000000..334d39b --- /dev/null +++ b/project-docs/status/phase-1-summary.md @@ -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 . diff --git a/project-docs/status/phase-1-test-results.md b/project-docs/status/phase-1-test-results.md new file mode 100644 index 0000000..8476b6a --- /dev/null +++ b/project-docs/status/phase-1-test-results.md @@ -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. diff --git a/project-docs/status/phase-2-open-issues.md b/project-docs/status/phase-2-open-issues.md new file mode 100644 index 0000000..938fd34 --- /dev/null +++ b/project-docs/status/phase-2-open-issues.md @@ -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. diff --git a/project-docs/status/phase-2-summary.md b/project-docs/status/phase-2-summary.md new file mode 100644 index 0000000..c0d2a5b --- /dev/null +++ b/project-docs/status/phase-2-summary.md @@ -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 . diff --git a/project-docs/status/phase-2-test-results.md b/project-docs/status/phase-2-test-results.md new file mode 100644 index 0000000..c7ffc14 --- /dev/null +++ b/project-docs/status/phase-2-test-results.md @@ -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. diff --git a/project-docs/status/phase-3-open-issues.md b/project-docs/status/phase-3-open-issues.md new file mode 100644 index 0000000..71b43a3 --- /dev/null +++ b/project-docs/status/phase-3-open-issues.md @@ -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. diff --git a/project-docs/status/phase-3-summary.md b/project-docs/status/phase-3-summary.md new file mode 100644 index 0000000..033c44c --- /dev/null +++ b/project-docs/status/phase-3-summary.md @@ -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 . diff --git a/project-docs/status/phase-3-test-results.md b/project-docs/status/phase-3-test-results.md new file mode 100644 index 0000000..227a30f --- /dev/null +++ b/project-docs/status/phase-3-test-results.md @@ -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. diff --git a/project-docs/status/phase-4-open-issues.md b/project-docs/status/phase-4-open-issues.md new file mode 100644 index 0000000..ba8c3c6 --- /dev/null +++ b/project-docs/status/phase-4-open-issues.md @@ -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. diff --git a/project-docs/status/phase-4-summary.md b/project-docs/status/phase-4-summary.md new file mode 100644 index 0000000..d87b68a --- /dev/null +++ b/project-docs/status/phase-4-summary.md @@ -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 . diff --git a/project-docs/status/phase-4-test-results.md b/project-docs/status/phase-4-test-results.md new file mode 100644 index 0000000..1c82c99 --- /dev/null +++ b/project-docs/status/phase-4-test-results.md @@ -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. diff --git a/project-docs/status/phase-5-open-issues.md b/project-docs/status/phase-5-open-issues.md new file mode 100644 index 0000000..8e6236a --- /dev/null +++ b/project-docs/status/phase-5-open-issues.md @@ -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. diff --git a/project-docs/status/phase-5-summary.md b/project-docs/status/phase-5-summary.md new file mode 100644 index 0000000..4a3d30a --- /dev/null +++ b/project-docs/status/phase-5-summary.md @@ -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 . diff --git a/project-docs/status/phase-5-test-results.md b/project-docs/status/phase-5-test-results.md new file mode 100644 index 0000000..ed574a6 --- /dev/null +++ b/project-docs/status/phase-5-test-results.md @@ -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.