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

This commit is contained in:
WayfinderAK 2026-07-09 15:44:22 -08:00
commit bfb027fcd2
No known key found for this signature in database
33 changed files with 1196 additions and 0 deletions

View File

@ -0,0 +1,39 @@
name: android-apk
on:
push:
branches:
- main
workflow_dispatch:
jobs:
debug-apk:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: https://github.com/actions/checkout@v4
- name: Set up JDK 17
uses: https://github.com/actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Set up Android SDK
uses: https://github.com/android-actions/setup-android@v3
- name: Install Android packages
run: |
sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
- name: Set up Gradle
uses: https://github.com/gradle/actions/setup-gradle@v4
- name: Build debug APK
run: gradle --no-daemon :app:assembleDebug
- name: Upload debug APK
uses: https://github.com/actions/upload-artifact@v4
with:
name: ntp-check-debug-apk
path: app/build/outputs/apk/debug/*.apk

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
.gradle/
build/
**/build/
local.properties
*.apk
*.aab
*.jks
*.keystore
.DS_Store
.idea/
*.iml
.pi-subagents/

31
AGENTS.md Normal file
View File

@ -0,0 +1,31 @@
# AGENTS.md
This repository is built phase-by-phase with hard gates for a private Android NTP comparison utility.
## Hard rules
- Never skip a phase gate.
- Never mark a phase complete without recorded evidence in `project-docs/status/`.
- Keep the app local-first: no backend, no telemetry, no analytics, no cloud sync unless explicitly approved.
- Android permissions must stay minimal. Network access for NTP probes is expected; do not add location, contacts, storage, or account permissions without approval.
- NTP probing must use UDP packets directly for v1; no external time-service backend.
- Store saved servers, profiles, and short history locally in Room.
- Do not commit signing keys, keystores, tokens, private server lists, customer names, or exported reports.
## Required artifacts per phase
- `project-docs/status/phase-N-summary.md`
- `project-docs/status/phase-N-test-results.md`
- `project-docs/status/phase-N-open-issues.md`
## Required validation before advancing
Run the checks required by the phase, including as applicable:
- structure validation
- Android debug build
- unit tests
- lint/static checks
- manual emulator/device smoke tests
- privacy/permission review
- export-data review

22
Makefile Normal file
View File

@ -0,0 +1,22 @@
GRADLE ?= $(shell if [ -x ./gradlew ]; then echo ./gradlew; else echo gradle; fi)
.PHONY: build-debug test lint clean validate-structure
build-debug:
$(GRADLE) :app:assembleDebug
test:
$(GRADLE) testDebugUnitTest
lint:
$(GRADLE) :app:lintDebug
clean:
$(GRADLE) clean
validate-structure:
test -f AGENTS.md
test -f project-docs/handoff/00-START-HERE.md
test -f project-docs/implementation/phase-plan.md
test -f .gitea/workflows/android-apk.yml
test -f app/build.gradle.kts

26
README.md Normal file
View File

@ -0,0 +1,26 @@
# NTP Check
Native Android NTP comparison dashboard built with Kotlin, Jetpack Compose, raw UDP NTP probes, and local Room storage.
## Mission
Provide a no-backend Android utility for saving NTP servers, testing them on demand or continuously, comparing health metrics, and exporting results for work/customer troubleshooting.
## Build
```bash
make build-debug
```
If a Gradle wrapper is later generated, `make` will use it. Otherwise it falls back to `gradle` from PATH.
## CI / Gitea
This repo includes `.gitea/workflows/android-apk.yml` to build a debug APK on push and manual dispatch. It is possible to build APKs automatically in Gitea Actions as long as the runner has or can install:
- JDK 17
- Android SDK/cmdline tools
- Gradle or a checked-in Gradle wrapper
- Network access to Maven/Google repositories for dependencies
Release signing should use Gitea secrets; debug APK builds do not need signing secrets.

63
app/build.gradle.kts Normal file
View File

@ -0,0 +1,63 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.devtools.ksp")
}
android {
namespace = "com.wayfinderak.ntpcheck"
compileSdk = 35
defaultConfig {
applicationId = "com.wayfinderak.ntpcheck"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
debugImplementation("androidx.compose.ui:ui-tooling")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
}

1
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1 @@
# Project-specific ProGuard rules can be added here.

View File

@ -0,0 +1,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".NtpCheckApplication"
android:allowBackup="true"
android:fullBackupContent="true"
android:icon="@mipmap/ic_launcher"
android:label="NTP Check"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,26 @@
package com.wayfinderak.ntpcheck
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import com.wayfinderak.ntpcheck.ui.NtpApp
import com.wayfinderak.ntpcheck.ui.NtpViewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val app = application as NtpCheckApplication
val viewModel = ViewModelProvider(
this,
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T =
NtpViewModel(app.database.ntpDao()) as T
},
)[NtpViewModel::class.java]
setContent { NtpApp(viewModel) }
}
}

View File

@ -0,0 +1,8 @@
package com.wayfinderak.ntpcheck
import android.app.Application
import com.wayfinderak.ntpcheck.data.AppDatabase
class NtpCheckApplication : Application() {
val database: AppDatabase by lazy { AppDatabase.create(this) }
}

View File

@ -0,0 +1,23 @@
package com.wayfinderak.ntpcheck.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [ProfileEntity::class, NtpServerEntity::class, NtpResultEntity::class],
version = 1,
exportSchema = true,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun ntpDao(): NtpDao
companion object {
fun create(context: Context): AppDatabase = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"ntp-check.db",
).build()
}
}

View File

@ -0,0 +1,31 @@
package com.wayfinderak.ntpcheck.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface NtpDao {
@Query("SELECT * FROM profiles ORDER BY name")
fun profiles(): Flow<List<ProfileEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertProfile(profile: ProfileEntity): Long
@Query("SELECT * FROM ntp_servers WHERE profileId = :profileId ORDER BY name")
fun servers(profileId: Long): Flow<List<NtpServerEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertServer(server: NtpServerEntity): Long
@Insert
suspend fun insertResult(result: NtpResultEntity): Long
@Query("SELECT * FROM ntp_results WHERE serverId = :serverId ORDER BY testedAtEpochMs DESC LIMIT :limit")
fun recentResults(serverId: Long, limit: Int = 50): Flow<List<NtpResultEntity>>
@Query("DELETE FROM ntp_results WHERE testedAtEpochMs < :cutoffEpochMs")
suspend fun deleteOldResults(cutoffEpochMs: Long)
}

View File

@ -0,0 +1,38 @@
package com.wayfinderak.ntpcheck.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "profiles")
data class ProfileEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
)
@Entity(tableName = "ntp_servers")
data class NtpServerEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val profileId: Long = 1,
val name: String,
val host: String,
val port: Int = 123,
val notes: String = "",
val enabled: Boolean = true,
)
@Entity(tableName = "ntp_results")
data class NtpResultEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val serverId: Long,
val testedAtEpochMs: Long,
val status: String,
val rttMs: Double? = null,
val offsetMs: Double? = null,
val stratum: Int? = null,
val referenceId: String? = null,
val rootDelayMs: Double? = null,
val rootDispersionMs: Double? = null,
val leapStatus: String? = null,
val precision: Int? = null,
val warning: String? = null,
)

View File

@ -0,0 +1,114 @@
package com.wayfinderak.ntpcheck.ntp
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import kotlin.math.pow
sealed interface ProbeResult {
data class Success(
val rttMs: Double,
val offsetMs: Double,
val stratum: Int,
val referenceId: String,
val rootDelayMs: Double,
val rootDispersionMs: Double,
val leapStatus: String,
val precision: Int,
) : ProbeResult
data class Failure(val status: String, val message: String) : ProbeResult
}
class NtpClient(private val timeoutMs: Int = 1500) {
fun probe(host: String, port: Int = 123): ProbeResult {
val address = try {
InetAddress.getByName(host)
} catch (ex: Exception) {
return ProbeResult.Failure("DNS_FAILURE", ex.message ?: "DNS lookup failed")
}
return try {
DatagramSocket().use { socket ->
socket.soTimeout = timeoutMs
val request = ByteArray(48)
request[0] = 0x1B // LI=0, VN=3, Mode=3 client
val t1 = System.currentTimeMillis()
writeTimestamp(request, 40, t1)
socket.send(DatagramPacket(request, request.size, address, port))
val response = ByteArray(48)
socket.receive(DatagramPacket(response, response.size))
val t4 = System.currentTimeMillis()
val t2 = readTimestamp(response, 32)
val t3 = readTimestamp(response, 40)
val rtt = (t4 - t1) - (t3 - t2)
val offset = ((t2 - t1) + (t3 - t4)) / 2.0
ProbeResult.Success(
rttMs = rtt,
offsetMs = offset,
stratum = response[1].toInt() and 0xff,
referenceId = referenceId(response),
rootDelayMs = fixedPoint16_16(response, 4) * 1000.0,
rootDispersionMs = fixedPoint16_16(response, 8) * 1000.0,
leapStatus = leapStatus((response[0].toInt() ushr 6) and 0x3),
precision = response[3].toInt(),
)
}
} catch (ex: java.net.SocketTimeoutException) {
ProbeResult.Failure("TIMEOUT", "No response within ${timeoutMs}ms")
} catch (ex: Exception) {
ProbeResult.Failure("PACKET_ERROR", ex.message ?: "Packet error")
}
}
private fun writeTimestamp(buffer: ByteArray, offset: Int, timeMs: Long) {
val seconds = timeMs / 1000L + NTP_EPOCH_OFFSET_SECONDS
val fraction = ((timeMs % 1000L) * 0x100000000L) / 1000L
writeUInt32(buffer, offset, seconds)
writeUInt32(buffer, offset + 4, fraction)
}
private fun readTimestamp(buffer: ByteArray, offset: Int): Double {
val seconds = readUInt32(buffer, offset) - NTP_EPOCH_OFFSET_SECONDS
val fractionMs = readUInt32(buffer, offset + 4) * 1000.0 / 0x100000000L.toDouble()
return seconds * 1000.0 + fractionMs
}
private fun fixedPoint16_16(buffer: ByteArray, offset: Int): Double {
val raw = readUInt32(buffer, offset)
return raw / 2.0.pow(16)
}
private fun referenceId(buffer: ByteArray): String {
val bytes = buffer.copyOfRange(12, 16)
val ascii = bytes.map { it.toInt() and 0xff }.filter { it in 32..126 }
return if (ascii.size == 4) ascii.map { it.toChar() }.joinToString("") else bytes.joinToString(".") { (it.toInt() and 0xff).toString() }
}
private fun leapStatus(value: Int): String = when (value) {
0 -> "NO_WARNING"
1 -> "LAST_MINUTE_61_SECONDS"
2 -> "LAST_MINUTE_59_SECONDS"
else -> "ALARM_UNSYNCHRONIZED"
}
private fun readUInt32(buffer: ByteArray, offset: Int): Long =
((buffer[offset].toLong() and 0xff) shl 24) or
((buffer[offset + 1].toLong() and 0xff) shl 16) or
((buffer[offset + 2].toLong() and 0xff) shl 8) or
(buffer[offset + 3].toLong() and 0xff)
private fun writeUInt32(buffer: ByteArray, offset: Int, value: Long) {
buffer[offset] = (value ushr 24).toByte()
buffer[offset + 1] = (value ushr 16).toByte()
buffer[offset + 2] = (value ushr 8).toByte()
buffer[offset + 3] = value.toByte()
}
private companion object {
const val NTP_EPOCH_OFFSET_SECONDS = 2_208_988_800L
}
}

View File

@ -0,0 +1,51 @@
package com.wayfinderak.ntpcheck.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun NtpApp(viewModel: NtpViewModel) {
val servers by viewModel.servers.collectAsState()
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("NTP Check", style = MaterialTheme.typography.headlineMedium)
Button(onClick = viewModel::testAll) { Text("Test all") }
}
Text("Server list + live comparison dashboard foundation")
Spacer(Modifier.height(16.dp))
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
items(servers) { server ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(server.name, style = MaterialTheme.typography.titleMedium)
Text("${server.host}:${server.port}")
if (server.notes.isNotBlank()) Text(server.notes)
Text("Initial scaffold: latest results, sorting, scores, and warnings land in Phase 2.")
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,73 @@
package com.wayfinderak.ntpcheck.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wayfinderak.ntpcheck.data.NtpDao
import com.wayfinderak.ntpcheck.data.NtpResultEntity
import com.wayfinderak.ntpcheck.data.NtpServerEntity
import com.wayfinderak.ntpcheck.data.ProfileEntity
import com.wayfinderak.ntpcheck.ntp.NtpClient
import com.wayfinderak.ntpcheck.ntp.ProbeResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class NtpViewModel(private val dao: NtpDao) : ViewModel() {
private val client = NtpClient()
val servers: StateFlow<List<NtpServerEntity>> = dao.servers(1).stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
emptyList(),
)
init {
viewModelScope.launch(Dispatchers.IO) {
dao.insertProfile(ProfileEntity(id = 1, name = "Default"))
if (servers.value.isEmpty()) {
dao.insertServer(NtpServerEntity(name = "NIST", host = "time.nist.gov"))
dao.insertServer(NtpServerEntity(name = "Cloudflare", host = "time.cloudflare.com"))
dao.insertServer(NtpServerEntity(name = "Google", host = "time.google.com"))
}
}
}
fun testAll() {
viewModelScope.launch(Dispatchers.IO) {
servers.value.filter { it.enabled }.forEach { server ->
val result = when (val probe = client.probe(server.host, server.port)) {
is ProbeResult.Success -> NtpResultEntity(
serverId = server.id,
testedAtEpochMs = System.currentTimeMillis(),
status = "REACHABLE",
rttMs = probe.rttMs,
offsetMs = probe.offsetMs,
stratum = probe.stratum,
referenceId = probe.referenceId,
rootDelayMs = probe.rootDelayMs,
rootDispersionMs = probe.rootDispersionMs,
leapStatus = probe.leapStatus,
precision = probe.precision,
warning = warningFor(probe),
)
is ProbeResult.Failure -> NtpResultEntity(
serverId = server.id,
testedAtEpochMs = System.currentTimeMillis(),
status = probe.status,
warning = probe.message,
)
}
dao.insertResult(result)
}
}
}
private fun warningFor(probe: ProbeResult.Success): String? = when {
probe.leapStatus == "ALARM_UNSYNCHRONIZED" -> "Leap alarm / unsynchronized"
probe.rttMs > 250 -> "High RTT"
probe.rootDispersionMs > 100 -> "High root dispersion"
probe.stratum >= 16 -> "Unsynchronized stratum"
else -> null
}
}

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" android:viewportWidth="48" android:viewportHeight="48">
<path android:fillColor="#0F172A" android:pathData="M0,0h48v48h-48z"/>
<path android:fillColor="#38BDF8" android:pathData="M24,6a18,18 0,1 0,0.1 0M24,10a14,14 0,1 1,-0.1 0"/>
<path android:strokeColor="#E0F2FE" android:strokeWidth="3" android:strokeLineCap="round" android:fillColor="#00000000" android:pathData="M24,14v11l8,5"/>
</vector>

View File

@ -0,0 +1,3 @@
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar" />
</resources>

6
build.gradle.kts Normal file
View File

@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
}

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
gradlew vendored Normal file
View File

@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/<unknown>/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
gradlew.bat vendored Normal file
View File

@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,17 @@
# ADR 0001: Native Android local-first app
## Decision
Build v1 as a native Kotlin Android app using Jetpack Compose, Room, and direct UDP NTP probes.
## Rationale
- The app needs low-friction device-side network diagnostics.
- No backend is required for saved servers, profiles, local history, comparison, or export.
- Local-only storage avoids telemetry and customer data exposure.
## Consequences
- APKs can be built locally or in Gitea Actions.
- Gitea runners must have Android build tooling or install it during the workflow.
- Release builds require signing keys managed through secrets, not committed files.

View File

@ -0,0 +1,37 @@
# ADR 0002: Android system time update capability
## Question
Can NTP Check update the Android device system time?
## Finding
For a normal Play/user-installed Android app: **no**. Android protects system clock changes behind privileged APIs/permissions. An app can measure offset and show guidance, but it cannot directly set wall-clock time unless it is installed with elevated trust.
## Practical options
1. **Normal app behavior for v1**
- Probe NTP servers.
- Show estimated device offset.
- Warn when local clock appears wrong.
- Provide a button to open Android Date & Time settings.
- No special permissions beyond `INTERNET`.
2. **Device-owner / enterprise-managed device**
- Some Android management APIs allow a device owner/profile owner to control certain time/timezone policies depending on Android version and ownership mode.
- This requires provisioning the app as a device policy controller, not a typical install.
3. **Privileged/system app or custom ROM**
- A system-signed or `/system/priv-app` application can use privileged permissions such as `android.permission.SET_TIME`.
- This is not available to ordinary APK installs.
4. **Rooted device helper**
- A rooted-device mode could call shell commands such as `date`/`toybox date`, but this is out of scope for v1 and has safety/security risks.
## Decision
V1 will not set Android system time. It will remain a diagnostic/comparison tool and may include an "Open Date & Time settings" action. Any future clock-setting mode must be explicitly separate and gated as enterprise/device-owner, privileged-system, or rooted-device functionality.
## Product implication
Market the app as an NTP testing and comparison dashboard, not as a general Android time synchronization daemon.

View File

@ -0,0 +1,17 @@
# Start Here
## Project mission
Build a local-first Android NTP app for comparing saved time servers, identifying the best candidate, and exporting troubleshooting evidence.
## Current state
Repository framework, Android skeleton, Gitea APK workflow, and initial phase plan are initialized. Product work should proceed through the phase gates in `project-docs/implementation/phase-plan.md`.
## Read next
1. `AGENTS.md`
2. `project-docs/handoff/01-product-handoff.md`
3. `project-docs/implementation/phase-plan.md`
4. `project-docs/decisions/0001-native-android-local-first.md`
5. `project-docs/decisions/0002-android-system-time-capability.md`

View File

@ -0,0 +1,27 @@
# Product Handoff
## Core v1 features
- Saved NTP servers: name, hostname/IP, port, notes, enabled state.
- Profiles: Home, Customer A, Customer B, etc.
- One-tap test all.
- Continuous test mode intervals: 1s, 5s, 10s, 30s.
- Per-server status: reachable, timeout, DNS failure, packet error.
- Per-server metrics: RTT, offset estimate, stratum, reference ID, root delay, root dispersion, leap status, precision.
- Sort by offset, latency, stratum, reachability, and health score.
- Highlight disagreement between servers and choose a best candidate.
- Export/share test results as text and CSV.
- Keep short local history per server.
- Dark/light mode.
- Local-only privacy posture with no telemetry.
## Comparison logic
Poll each enabled server and compare reachability, stratum, root distance, offset stability, and median disagreement. Warnings should include unreachable server, high RTT, high root dispersion, unsynchronized stratum, leap warning, offset outlier, and reference ID changes.
## Later features
- SSH mode for Linux/chrony servers: `chronyc tracking`, `chronyc sources -v`, `timedatectl timesync-status`.
- Offset/RTT graphs.
- Alerts for drops or drift.
- CSV/YAML import.

View File

@ -0,0 +1,86 @@
# Phase Plan
## NTP-P0 — Repository framework and delivery workflow
Goal: initialize the project like the phase-gated Second Brain workflow.
Deliverables:
- Android Kotlin/Compose skeleton.
- Room storage model draft.
- Raw UDP NTP client draft.
- Gitea Actions debug APK workflow.
- Project docs, hard rules, and validation target.
Gate:
- `make validate-structure` passes.
- Repo pushes to Gitea.
## NTP-P1 — NTP probe correctness
Goal: make the raw UDP NTP client reliable and testable.
Deliverables:
- Unit tests for packet timestamp parsing/encoding and warning mapping.
- Probe result model with all required NTP fields.
- Error handling for DNS failure, timeout, malformed packets, and unreachable hosts.
Gate:
- Unit tests pass.
- Manual test against known public NTP servers succeeds.
## NTP-P2 — Saved servers and dashboard MVP
Goal: provide a useful saved server list and one-tap comparison dashboard.
Deliverables:
- Add/edit/delete servers.
- Default server seed only on first run.
- Latest result per server visible on dashboard.
- Sort by latency, offset, stratum, reachability, health score.
- Best candidate calculation.
Gate:
- Debug APK builds.
- Manual emulator/device smoke test passes.
## NTP-P3 — Continuous testing and disagreement detection
Goal: support polling intervals and peer-group comparison.
Deliverables:
- Continuous mode at 1s, 5s, 10s, 30s.
- Offset median and threshold outlier warnings.
- Reference ID change warning.
- Short history retention policy.
Gate:
- Continuous mode stops cleanly when disabled/backgrounded.
- Battery/network behavior reviewed.
## NTP-P4 — Profiles and export
Goal: make the app practical for home/customer troubleshooting.
Deliverables:
- Profile CRUD and server assignment.
- Export/share current results and history as text/CSV.
- Import server list design for later CSV/YAML.
Gate:
- Export contains no hidden telemetry or private app metadata.
- Profile switching works on device.
## NTP-P5 — UX polish and release prep
Goal: prepare signed release builds.
Deliverables:
- Dark/light mode polish.
- Empty/error/loading states.
- Signed release build workflow using Gitea secrets.
- Release checklist.
- Open Android Date & Time settings action if local clock offset warning is present.
Gate:
- Lint/tests/build pass.
- Permission and privacy review passes.

View File

@ -0,0 +1,28 @@
# Gitea APK Build Runbook
## Can Gitea build the APK automatically?
Yes. Gitea Actions can build Android APKs automatically on push or manual dispatch if an Actions runner is available with Docker or enough tooling to install Android commandline tools.
## Debug APKs
Debug APKs are straightforward and do not need signing secrets. The workflow in `.gitea/workflows/android-apk.yml` builds `app/build/outputs/apk/debug/app-debug.apk` and uploads it as an artifact.
## Release APKs
Release APKs should be added later after creating secrets:
- `ANDROID_KEYSTORE_BASE64`
- `ANDROID_KEYSTORE_PASSWORD`
- `ANDROID_KEY_ALIAS`
- `ANDROID_KEY_PASSWORD`
Never commit keystores or passwords.
## Local fallback
If the runner cannot install Android SDK components or dependency downloads are blocked, build locally with:
```bash
make build-debug
```

View File

@ -0,0 +1,5 @@
# Phase 0 Open Issues
- Confirm Gitea Actions runner has Android SDK support or can install it with `android-actions/setup-android`.
- Local machine needs a full supported JDK with `javac` (JDK 17 or 21) to build locally; current default Java 26 is unsupported by Gradle 8.10.2, and the Java 21 install appears runtime-only.
- Add release signing workflow after a keystore is created and stored as Gitea secrets.

View File

@ -0,0 +1,12 @@
# Phase 0 Summary
Initialized the NTP Android project framework with:
- Kotlin/Jetpack Compose Android project skeleton.
- Room data model for profiles, servers, and probe results.
- Raw UDP NTP client draft.
- Initial dashboard scaffold with seeded NTP servers and one-tap test action.
- Phase-gated documentation modeled after Second Brain.
- Gitea Actions workflow for debug APK artifacts.
Status: framework complete; build validation still depends on local/runner Android Gradle tooling.

View File

@ -0,0 +1,9 @@
# Phase 0 Test Results
## 2026-07-09
- `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.
- Gitea workflow added for automatic debug APK builds with JDK 17 and Android SDK setup.

18
settings.gradle.kts Normal file
View File

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "NtpCheck"
include(":app")