Initialize Android NTP app framework
Some checks failed
android-apk / debug-apk (push) Has been cancelled
Some checks failed
android-apk / debug-apk (push) Has been cancelled
This commit is contained in:
commit
bfb027fcd2
39
.gitea/workflows/android-apk.yml
Normal file
39
.gitea/workflows/android-apk.yml
Normal 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
12
.gitignore
vendored
Normal 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
31
AGENTS.md
Normal 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
22
Makefile
Normal 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
26
README.md
Normal 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
63
app/build.gradle.kts
Normal 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
1
app/proguard-rules.pro
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Project-specific ProGuard rules can be added here.
|
||||||
20
app/src/main/AndroidManifest.xml
Normal file
20
app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
26
app/src/main/java/com/wayfinderak/ntpcheck/MainActivity.kt
Normal file
26
app/src/main/java/com/wayfinderak/ntpcheck/MainActivity.kt
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) }
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/src/main/java/com/wayfinderak/ntpcheck/data/Dao.kt
Normal file
31
app/src/main/java/com/wayfinderak/ntpcheck/data/Dao.kt
Normal 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)
|
||||||
|
}
|
||||||
38
app/src/main/java/com/wayfinderak/ntpcheck/data/Models.kt
Normal file
38
app/src/main/java/com/wayfinderak/ntpcheck/data/Models.kt
Normal 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,
|
||||||
|
)
|
||||||
114
app/src/main/java/com/wayfinderak/ntpcheck/ntp/NtpClient.kt
Normal file
114
app/src/main/java/com/wayfinderak/ntpcheck/ntp/NtpClient.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpScreen.kt
Normal file
51
app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpScreen.kt
Normal 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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
5
app/src/main/res/drawable/ic_launcher.xml
Normal file
5
app/src/main/res/drawable/ic_launcher.xml
Normal 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>
|
||||||
3
app/src/main/res/values/styles.xml
Normal file
3
app/src/main/res/values/styles.xml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
6
build.gradle.kts
Normal file
6
build.gradle.kts
Normal 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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
gradlew
vendored
Normal 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
93
gradlew.bat
vendored
Normal 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
|
||||||
17
project-docs/decisions/0001-native-android-local-first.md
Normal file
17
project-docs/decisions/0001-native-android-local-first.md
Normal 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.
|
||||||
@ -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.
|
||||||
17
project-docs/handoff/00-START-HERE.md
Normal file
17
project-docs/handoff/00-START-HERE.md
Normal 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`
|
||||||
27
project-docs/handoff/01-product-handoff.md
Normal file
27
project-docs/handoff/01-product-handoff.md
Normal 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.
|
||||||
86
project-docs/implementation/phase-plan.md
Normal file
86
project-docs/implementation/phase-plan.md
Normal 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.
|
||||||
28
project-docs/runbooks/gitea-apk-builds.md
Normal file
28
project-docs/runbooks/gitea-apk-builds.md
Normal 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
|
||||||
|
```
|
||||||
5
project-docs/status/phase-0-open-issues.md
Normal file
5
project-docs/status/phase-0-open-issues.md
Normal 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.
|
||||||
12
project-docs/status/phase-0-summary.md
Normal file
12
project-docs/status/phase-0-summary.md
Normal 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.
|
||||||
9
project-docs/status/phase-0-test-results.md
Normal file
9
project-docs/status/phase-0-test-results.md
Normal 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
18
settings.gradle.kts
Normal 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")
|
||||||
Loading…
x
Reference in New Issue
Block a user