This commit is contained in:
parent
bfb027fcd2
commit
ff48fe2ef2
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
.gradle/
|
||||
.kotlin/
|
||||
build/
|
||||
**/build/
|
||||
local.properties
|
||||
|
||||
@ -41,6 +41,10 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
||||
implementation(composeBom)
|
||||
|
||||
188
app/schemas/com.wayfinderak.ntpcheck.data.AppDatabase/1.json
Normal file
188
app/schemas/com.wayfinderak.ntpcheck.data.AppDatabase/1.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
3
gradle.properties
Normal file
@ -0,0 +1,3 @@
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
18
project-docs/decisions/0003-privileged-install-track.md
Normal file
18
project-docs/decisions/0003-privileged-install-track.md
Normal 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
|
||||
@ -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.
|
||||
|
||||
6
project-docs/status/phase-1-open-issues.md
Normal file
6
project-docs/status/phase-1-open-issues.md
Normal 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.
|
||||
11
project-docs/status/phase-1-summary.md
Normal file
11
project-docs/status/phase-1-summary.md
Normal 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 .
|
||||
10
project-docs/status/phase-1-test-results.md
Normal file
10
project-docs/status/phase-1-test-results.md
Normal 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.
|
||||
6
project-docs/status/phase-2-open-issues.md
Normal file
6
project-docs/status/phase-2-open-issues.md
Normal 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.
|
||||
11
project-docs/status/phase-2-summary.md
Normal file
11
project-docs/status/phase-2-summary.md
Normal 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 .
|
||||
10
project-docs/status/phase-2-test-results.md
Normal file
10
project-docs/status/phase-2-test-results.md
Normal 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.
|
||||
6
project-docs/status/phase-3-open-issues.md
Normal file
6
project-docs/status/phase-3-open-issues.md
Normal 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.
|
||||
11
project-docs/status/phase-3-summary.md
Normal file
11
project-docs/status/phase-3-summary.md
Normal 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 .
|
||||
10
project-docs/status/phase-3-test-results.md
Normal file
10
project-docs/status/phase-3-test-results.md
Normal 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.
|
||||
6
project-docs/status/phase-4-open-issues.md
Normal file
6
project-docs/status/phase-4-open-issues.md
Normal 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.
|
||||
11
project-docs/status/phase-4-summary.md
Normal file
11
project-docs/status/phase-4-summary.md
Normal 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 .
|
||||
10
project-docs/status/phase-4-test-results.md
Normal file
10
project-docs/status/phase-4-test-results.md
Normal 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.
|
||||
6
project-docs/status/phase-5-open-issues.md
Normal file
6
project-docs/status/phase-5-open-issues.md
Normal 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.
|
||||
11
project-docs/status/phase-5-summary.md
Normal file
11
project-docs/status/phase-5-summary.md
Normal 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 .
|
||||
10
project-docs/status/phase-5-test-results.md
Normal file
10
project-docs/status/phase-5-test-results.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user