commit bfb027fcd21125ab50041d5915a2ec8ad2887997 Author: WayfinderAK Date: Thu Jul 9 15:44:22 2026 -0800 Initialize Android NTP app framework diff --git a/.gitea/workflows/android-apk.yml b/.gitea/workflows/android-apk.yml new file mode 100644 index 0000000..f44f1e0 --- /dev/null +++ b/.gitea/workflows/android-apk.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3768628 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.gradle/ +build/ +**/build/ +local.properties +*.apk +*.aab +*.jks +*.keystore +.DS_Store +.idea/ +*.iml +.pi-subagents/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9a19c8d --- /dev/null +++ b/AGENTS.md @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c90d69b --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2ca235 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..d19e23f --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..60ebd8b --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1 @@ +# Project-specific ProGuard rules can be added here. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..929980e --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/app/src/main/java/com/wayfinderak/ntpcheck/MainActivity.kt b/app/src/main/java/com/wayfinderak/ntpcheck/MainActivity.kt new file mode 100644 index 0000000..a29547d --- /dev/null +++ b/app/src/main/java/com/wayfinderak/ntpcheck/MainActivity.kt @@ -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 create(modelClass: Class, extras: CreationExtras): T = + NtpViewModel(app.database.ntpDao()) as T + }, + )[NtpViewModel::class.java] + setContent { NtpApp(viewModel) } + } +} diff --git a/app/src/main/java/com/wayfinderak/ntpcheck/NtpCheckApplication.kt b/app/src/main/java/com/wayfinderak/ntpcheck/NtpCheckApplication.kt new file mode 100644 index 0000000..982b061 --- /dev/null +++ b/app/src/main/java/com/wayfinderak/ntpcheck/NtpCheckApplication.kt @@ -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) } +} diff --git a/app/src/main/java/com/wayfinderak/ntpcheck/data/AppDatabase.kt b/app/src/main/java/com/wayfinderak/ntpcheck/data/AppDatabase.kt new file mode 100644 index 0000000..a5a8e8d --- /dev/null +++ b/app/src/main/java/com/wayfinderak/ntpcheck/data/AppDatabase.kt @@ -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() + } +} diff --git a/app/src/main/java/com/wayfinderak/ntpcheck/data/Dao.kt b/app/src/main/java/com/wayfinderak/ntpcheck/data/Dao.kt new file mode 100644 index 0000000..cf4a59d --- /dev/null +++ b/app/src/main/java/com/wayfinderak/ntpcheck/data/Dao.kt @@ -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> + + @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> + + @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> + + @Query("DELETE FROM ntp_results WHERE testedAtEpochMs < :cutoffEpochMs") + suspend fun deleteOldResults(cutoffEpochMs: Long) +} diff --git a/app/src/main/java/com/wayfinderak/ntpcheck/data/Models.kt b/app/src/main/java/com/wayfinderak/ntpcheck/data/Models.kt new file mode 100644 index 0000000..758f411 --- /dev/null +++ b/app/src/main/java/com/wayfinderak/ntpcheck/data/Models.kt @@ -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, +) diff --git a/app/src/main/java/com/wayfinderak/ntpcheck/ntp/NtpClient.kt b/app/src/main/java/com/wayfinderak/ntpcheck/ntp/NtpClient.kt new file mode 100644 index 0000000..09f75fa --- /dev/null +++ b/app/src/main/java/com/wayfinderak/ntpcheck/ntp/NtpClient.kt @@ -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 + } +} diff --git a/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpScreen.kt b/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpScreen.kt new file mode 100644 index 0000000..447246a --- /dev/null +++ b/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpScreen.kt @@ -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.") + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpViewModel.kt b/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpViewModel.kt new file mode 100644 index 0000000..3857d04 --- /dev/null +++ b/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpViewModel.kt @@ -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> = 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 + } +} diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..746da60 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..27adb98 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,3 @@ + +