Google Time Sync β Implementation Deep Dive
How Google Time Sync works: HTTP-based NTP, clock offset calculation, XSSI prefix stripping, and proxy-aware HTTP
Overview
Google Time Sync queries Google's HTTP time endpoint (http://clients2.google.com/time/1/current) to measure the clock offset between the local device and Google's NTP servers. Unlike the NTP Check screen (which uses UDP/123 and the commons-net library), this feature uses a plain HTTP GET request and parses a JSON response.
The endpoint returns a JSON object like:
)]}'
{"current_time_millis": 1705312200000}The )]}' prefix is XSSI (Cross-Site Script Injection) protection β it prevents the response from being executed as JavaScript if embedded in a <script> tag. The app strips the first 4 characters before parsing.
From the single JSON field (current_time_millis), the app computes:
- RTT (round-trip time) β
T4 β T1 - Corrected server time β
serverTime + RTT / 2 - Clock offset β
correctedServerTime β T4(positive = local clock is behind, negative = ahead)
The feature supports a customizable URL, allowing users to query alternative Google time endpoints or their own time-sync servers.
The feature is implemented across four files (the only feature that uses a sealed class for UI state instead of a data class with an isLoading boolean):
| File | Role |
|---|---|
GoogleTimeSyncRepository.kt | Network I/O β HttpURLConnection, JSON parsing, proxy resolution, XSSI stripping |
GoogleTimeSyncViewModel.kt | State management β StateFlow<GoogleTimeSyncScreenState>, sealed class sync state, history deduplication by URL |
GoogleTimeSyncHistoryStore.kt | Persistence β DataStore-backed history (last 5 entries, pipe-delimited) |
GoogleTimeSyncScreen.kt | UI β Jetpack Compose screen with color-coded offset display, copy-to-clipboard, and retry button |
GoogleTimeSyncRepository β HTTP Time Query
File: app/src/main/java/.../GoogleTimeSyncRepository.kt
Domain model
data class TimeSyncResult(
val serverTimeMillis: Long, // Raw current_time_millis from JSON
val rttMillis: Long, // T4 β T1
val offsetMillis: Long, // correctedServerTime β T4
val correctedServerTimeMillis: Long, // serverTime + RTT / 2
val requestTimestamp: Long, // T1: client timestamp before request
val responseTimestamp: Long, // T4: client timestamp after response fully read
)This is the most mathematically rich data class in the app β it captures all four timestamps needed for NTP-style offset calculation (though only one server timestamp is received, T1 and T4 are measured locally).
Result sealed hierarchy
sealed class GoogleTimeSyncResult {
data class Success(val data: TimeSyncResult) : GoogleTimeSyncResult()
data object NoNetwork : GoogleTimeSyncResult()
data class Timeout(val host: String) : GoogleTimeSyncResult()
data class HttpError(val code: Int, val host: String) : GoogleTimeSyncResult()
data class ParseError(val message: String) : GoogleTimeSyncResult()
data class Error(val message: String) : GoogleTimeSyncResult()
}Six variants β more than any other feature. This granularity lets the UI display specific error messages:
| Variant | User message |
|---|---|
NoNetwork | "No network connection" |
Timeout(host) | "Request timed out" |
HttpError(code, host) | "HTTP error \${code}" |
ParseError(message) | "Parse error: \${message}" |
Error(message) | "\${message}" |
The fetchGoogleTime() method
class GoogleTimeSyncRepository(
private val proxyResolver: ProxyResolver? = null,
) {
companion object {
const val DEFAULT_URL = "http://clients2.google.com/time/1/current"
private const val CONNECT_TIMEOUT_MS = 10_000 // 10 s
private const val READ_TIMEOUT_MS = 15_000 // 15 s
private const val XSSI_PREFIX_LEN = 4 // )]}'
}
suspend fun fetchGoogleTime(
url: String = DEFAULT_URL,
): GoogleTimeSyncResult = withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null
try {
// T1: record timestamp BEFORE the request goes out.
val t1 = System.currentTimeMillis()
// Resolve proxy (if configured); null β direct connection
val proxy = proxyResolver?.resolveProxy(url)
connection = if (proxy != null) {
URL(url).openConnection(proxy) as HttpURLConnection
} else {
URL(url).openConnection() as HttpURLConnection
}
connection.apply {
requestMethod = "GET"
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
instanceFollowRedirects = true
setRequestProperty("Accept", "application/json")
}
val responseCode = connection.responseCode
if (responseCode != HttpURLConnection.HTTP_OK) {
return@withContext GoogleTimeSyncResult.HttpError(responseCode, url)
}
val rawBody = connection.inputStream.bufferedReader(Charsets.UTF_8).readText()
// T4: record timestamp AFTER the response body is fully read.
val t4 = System.currentTimeMillis()
// ββ Strip XSSI prefix ββββββββββββββββββββββββββββββββββββββββ
if (rawBody.length < XSSI_PREFIX_LEN) {
return@withContext GoogleTimeSyncResult.ParseError(
"Response body too short to contain XSSI prefix (got ${rawBody.length} chars)"
)
}
val jsonStr = rawBody.substring(XSSI_PREFIX_LEN).trim()
// ββ Parse JSON βββββββββββββββββββββββββββββββββββββββββββββββ
val json = try {
JSONObject(jsonStr)
} catch (e: Exception) {
return@withContext GoogleTimeSyncResult.ParseError(
"Malformed JSON: ${e.localizedMessage}"
)
}
if (!json.has("current_time_millis")) {
return@withContext GoogleTimeSyncResult.ParseError(
"Missing field: current_time_millis"
)
}
val serverTimeMillis = json.getLong("current_time_millis")
// ββ Time-sync calculation ββββββββββββββββββββββββββββββββββββ
// RTT = T4 β T1
val rtt = t4 - t1
// correctedServerTime = serverTime + RTT / 2
val correctedServerTime = serverTimeMillis + (rtt / 2L)
// offset = correctedServerTime β T4
// Positive β local clock is behind
// Negative β local clock is ahead
val offset = correctedServerTime - t4
GoogleTimeSyncResult.Success(
TimeSyncResult(
serverTimeMillis = serverTimeMillis,
rttMillis = rtt,
offsetMillis = offset,
correctedServerTimeMillis = correctedServerTime,
requestTimestamp = t1,
responseTimestamp = t4,
)
)
} catch (e: SocketTimeoutException) {
GoogleTimeSyncResult.Timeout(url)
} catch (e: IOException) {
val msg = e.message.orEmpty()
if (msg.contains("unreachable", ignoreCase = true) ||
msg.contains("connect failed", ignoreCase = true) ||
msg.contains("network", ignoreCase = true)
) {
GoogleTimeSyncResult.NoNetwork
} else {
GoogleTimeSyncResult.Error(e.localizedMessage ?: "Network error")
}
} catch (e: Exception) {
GoogleTimeSyncResult.Error(e.localizedMessage ?: "Unknown error")
} finally {
connection?.disconnect()
}
}
}Key implementation details
-
T1/T4 timestamping β
t1is recorded before the HTTP request is sent,t4is recorded after the response body is fully read. This gives an accurate RTT measurement that includes network latency + server processing time + response body transfer time. -
Proxy support β
proxyResolver?.resolveProxy(url)is called before opening the connection. If a PAC (Proxy Auto-Config) script is configured in Settings, the request goes through the resolved proxy. Otherwise, a direct connection is used. This is the only feature (along with Settings) that integrates with the proxy subsystem. -
Timeouts β two separate timeouts:
connectTimeout = 10_000(10s) β time to establish the TCP connectionreadTimeout = 15_000(15s) β time to wait for response data- The user-configurable timeout from Settings wraps the entire operation in
withTimeout(), which is the outer boundary.
-
XSSI prefix stripping β the raw response body is at least 4 characters long (to contain
)]}'), thenrawBody.substring(4).trim()removes the prefix and whitespace before JSON parsing. -
JSON parsing β
org.json.JSONObject(Android's built-in JSON library) parses the response. The app checks for thecurrent_time_millisfield and throwsParseErrorif missing. -
Offset calculation:
RTT = T4 β T1 correctedServerTime = serverTime + RTT / 2 offset = correctedServerTime β T4The
RTT / 2correction assumes symmetric network latency (same delay in both directions). The corrected server time estimates what the server's time was at the midpoint of the round trip. The offset tells the user how far their local clock differs from the server. -
Error classification β
IOExceptionis classified by message content:"unreachable","connect failed","network"βNoNetwork- Otherwise β
Error
-
finally { connection?.disconnect() }β ensures the HTTP connection is released even on errors.
GoogleTimeSyncViewModel β Sealed Class State
File: app/src/main/java/.../GoogleTimeSyncViewModel.kt
UI State β sealed class pattern
sealed class GoogleTimeSyncUiState {
/** No sync has been attempted yet (or state was explicitly reset). */
data object Idle : GoogleTimeSyncUiState()
/** A sync request is currently in-flight. */
data object Loading : GoogleTimeSyncUiState()
/** The sync completed successfully. */
data class Success(val result: TimeSyncResult) : GoogleTimeSyncUiState()
/** The sync failed for any reason. */
data class Error(val message: String) : GoogleTimeSyncUiState()
}
data class GoogleTimeSyncScreenState(
val syncState: GoogleTimeSyncUiState = GoogleTimeSyncUiState.Idle,
/** Up to 5 most-recent sync results, newest-first. */
val history: List<GoogleTimeSyncHistoryEntry> = emptyList(),
)This is the only feature that uses a sealed class for its primary UI state (alongside NTP Check). The pattern has four distinct states:
| State | Meaning | UI behavior |
|---|---|---|
Idle | No sync attempted, or explicitly reset | Show URL field + "Sync Now" button |
Loading | Sync request in-flight | Show spinner, disable button |
Success(result) | Sync completed successfully | Show TimeSyncResultCard with offset |
Error(message) | Sync failed | Show ErrorCard with retry button |
The GoogleTimeSyncScreenState data class wraps syncState and history β a single data class that holds a sealed class, mirroring the NTP screen pattern.
The syncTime() method
fun syncTime(url: String) {
val effectiveUrl = url.trim().ifBlank { GoogleTimeSyncRepository.DEFAULT_URL }
syncJob?.cancel()
_uiState.value = _uiState.value.copy(syncState = GoogleTimeSyncUiState.Loading)
syncJob = viewModelScope.launch {
val timeoutMs = settingsRepository.timeoutSecondsFlow.first() * 1000L
val result = try {
withTimeout(timeoutMs) {
repository.fetchGoogleTime(effectiveUrl)
}
} catch (_: TimeoutCancellationException) {
GoogleTimeSyncResult.Timeout(effectiveUrl)
}
val syncState: GoogleTimeSyncUiState
val newEntry: GoogleTimeSyncHistoryEntry
val timestamp = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"))
when (result) {
is GoogleTimeSyncResult.Success -> {
syncState = GoogleTimeSyncUiState.Success(result.data)
newEntry = GoogleTimeSyncHistoryEntry(
timestamp = timestamp,
url = effectiveUrl,
offsetMs = result.data.offsetMillis,
rttMs = result.data.rttMillis,
success = true,
)
}
else -> {
val message = when (result) {
is GoogleTimeSyncResult.NoNetwork -> "No network connection"
is GoogleTimeSyncResult.Timeout -> "Request timed out"
is GoogleTimeSyncResult.HttpError -> "HTTP error ${result.code}"
is GoogleTimeSyncResult.ParseError -> "Parse error: ${result.message}"
is GoogleTimeSyncResult.Error -> result.message
else -> "Unknown error"
}
syncState = GoogleTimeSyncUiState.Error(message)
newEntry = GoogleTimeSyncHistoryEntry(
timestamp = timestamp,
url = effectiveUrl,
offsetMs = 0L,
rttMs = 0L,
success = false,
)
}
}
// Prepend new entry; remove duplicates by URL; cap at 5.
val updatedHistory = (listOf(newEntry) + _uiState.value.history
.filter { it.url != effectiveUrl })
.take(5)
_uiState.value = _uiState.value.copy(
syncState = syncState,
history = updatedHistory,
)
historyStore.save(updatedHistory)
}
}Key implementation details
-
Blank URL defaults to Google β
url.trim().ifBlank { DEFAULT_URL }means the user can leave the URL field empty (or fill it with only whitespace) and the default endpoint is used. -
Job cancellation β
syncJob?.cancel()cancels any in-flight request before starting a new one. This is important because the user might change the URL and hit "Sync Now" again while the previous request is still pending. -
Loading state β
syncState = Loadingis set before the coroutine launches, so the UI immediately shows the spinner. -
Dual timeout β
withTimeout(timeoutMs)wraps the repository call. If the user-configurable timeout expires, aTimeoutCancellationExceptionis caught and converted toGoogleTimeSyncResult.Timeout. Note: the repository's ownreadTimeout(15s) is an inner timeout β the outerwithTimeoutcan expire first if the user sets a short timeout. -
Result classification β the
whenblock maps eachGoogleTimeSyncResultvariant to both a UI state and a history entry. Success entries captureoffsetMsandrttMs; failure entries set both to0Landsuccess = false. -
History deduplication by URL β
_uiState.value.history.filter { it.url != effectiveUrl }removes any existing entry with the same URL before prepending the new entry. This means querying the same URL twice results in only one history entry (the newest). -
History cap β
.take(5)ensures at most 5 entries, same as other features.
The reset() method
fun reset() {
syncJob?.cancel()
_uiState.value = _uiState.value.copy(syncState = GoogleTimeSyncUiState.Idle)
}Cancels any in-flight request and returns to Idle. History is preserved β reset() only clears the syncState, not the history field. This is the only way to dismiss a result/error without performing another sync.
The selectHistoryEntry() method
fun selectHistoryEntry(entry: GoogleTimeSyncHistoryEntry, onUrlSelected: (String) -> Unit) {
onUrlSelected(entry.url)
syncTime(entry.url)
}Unlike other features that populate the text field directly, this method uses a callback pattern (onUrlSelected). The screen passes { url = it } as the callback, which updates a local mutableStateOf variable (url) rather than the ViewModel. This is because the URL field is a local screen state, not part of the ViewModel's UI state.
Factory delegate β with ProxyResolver
companion object {
fun factory(context: Context): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val appContext = context.applicationContext
val settingsRepo = SettingsRepository(appContext)
val logger = ProxyPacLogger.getInstance(
logFile = java.io.File(appContext.filesDir, "proxypac-logs.txt"),
)
val proxyResolver = ProxyResolver(settingsRepo, QuickJsEngine(), logger = logger)
return GoogleTimeSyncViewModel(
repository = GoogleTimeSyncRepository(proxyResolver),
historyStore = GoogleTimeSyncHistoryStore(appContext),
settingsRepository = settingsRepo,
) as T
}
}
}This is the only factory that constructs a ProxyResolver and ProxyPacLogger β dependencies that are passed through to GoogleTimeSyncRepository. The proxy resolver is optional (ProxyResolver?), so other features that don't need network proxy support don't have to deal with it.
GoogleTimeSyncHistoryStore β Persistence
File: app/src/main/java/.../GoogleTimeSyncHistoryStore.kt
DataStore setup
private val Context.googleTimeSyncHistoryDataStore: DataStore<Preferences>
by preferencesDataStore(name = "google_time_sync_history")Uses "google_time_sync_history" as the DataStore name.
Serialization format
A single String preference key ("google_time_sync_history") contains all entries, one per line, fields separated by |:
2026/05/10 14:30:00|http://clients2.google.com/time/1/current|45|120|true
2026/05/09 09:15:22|http://clients2.google.com/time/1/current|-30|95|true| Field | Example | Notes |
|---|---|---|
timestamp | 2026/05/10 14:30:00 | yyyy/MM/dd HH:mm:ss |
url | http://clients2.google.com/time/1/current | Full URL (never contains ` |
offsetMs | 45 | Long, may be negative |
rttMs | 120 | Long, always positive |
success | true | Boolean |
Deserialization with backward compatibility
private fun deserialise(raw: String): List<GoogleTimeSyncHistoryEntry> =
raw.split(ENTRY_SEP)
.filter { it.isNotBlank() }
.mapNotNull { line ->
val parts = line.split(FIELD_SEP)
if (parts.size >= 4) {
val offsetMs = parts.getOrNull(2)?.toLongOrNull() ?: return@mapNotNull null
val rttMs = parts.getOrNull(3)?.toLongOrNull() ?: return@mapNotNull null
// parts[4] may be absent in entries saved before the success field was added
val success = parts.getOrNull(4)?.toBooleanStrictOrNull() ?: false
GoogleTimeSyncHistoryEntry(
timestamp = parts[0],
url = parts[1],
offsetMs = offsetMs,
rttMs = rttMs,
success = success,
)
} else null
}
.take(MAX_ENTRIES)- Requires at least 4 fields (timestamp, url, offsetMs, rttMs); 5th field (
success) defaults tofalseif missing β backward compatibility for entries saved before thesuccessfield was added toLongOrNull()andtoBooleanStrictOrNull()returnnull/falsefor malformed values, andmapNotNullfilters them out- URLs never contain
|, so the pipe separator is unambiguous
Reactive history flow
val historyFlow: Flow<List<GoogleTimeSyncHistoryEntry>> =
context.googleTimeSyncHistoryDataStore.data.map { prefs ->
prefs[KEY]?.let { raw -> deserialise(raw) } ?: emptyList()
}The ViewModel's init block reads the first emission:
init {
viewModelScope.launch {
val saved = historyStore.historyFlow.first()
_uiState.value = _uiState.value.copy(history = saved)
}
}UI β GoogleTimeSyncScreen
File: app/src/main/java/.../GoogleTimeSyncScreen.kt
The Google Time Sync screen has a single scrollable column layout with five sections:
βββββββββββββββββββββββββββββββββββββββββββ
β Query clients2.google.com for UTC timeβ¦ β β description
βββββββββββββββββββββββββββββββββββββββββββ€
β [π URL [Γ]] β
β http://clients2.google.com/time/1/currentβ
βββββββββββββββββββββββββββββββββββββββββββ€
β [β Sync Now ] β β disables during loading
βββββββββββββββββββββββββββββββββββββββββββ€
β ββ Sync Successful βββββββββββββββββββ β
β β β
RTT 120 ms Β· offset +45 ms β β
β β β β
β β π Server Time 2024-01-15β¦ β β
β β π Round-Trip Time 120 ms β β
β β β± Clock Offset +45 ms β β
β β Local clock is behind server β β
β β π Corrected Time 2024-01-15β¦ β β
β β β β
β β [π Copy Offset (+45 ms)] β β
β ββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββ€
β ββ Recent Syncs ββββββββββββββββββ β
β β 2026/05/10 14:30:00 β β
β β http://clients2.googleβ¦ β
β β
β β RTT 120 ms Β· offset +45 ms β β
β ββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββ€
β Offset = corrected server time β your β β footer
β device time β
βββββββββββββββββββββββββββββββββββββββββββScreen structure
@Composable
fun GoogleTimeSyncScreen() {
val context = LocalContext.current
val vm: GoogleTimeSyncViewModel = viewModel(
factory = GoogleTimeSyncViewModel.factory(context)
)
val screenState by vm.uiState.collectAsState()
val syncState = screenState.syncState
val focusManager = LocalFocusManager.current
val clipboardManager = LocalClipboardManager.current
var url by remember { mutableStateOf(GoogleTimeSyncRepository.DEFAULT_URL) }
var copied by remember { mutableStateOf(false) }
// Auto-reset the "Copied!" label after 2 s.
LaunchedEffect(copied) {
if (copied) {
delay(2_000)
copied = false
}
}Key UI details
-
Local URL state β
urlis amutableStateOflocal to the Composable, not part of the ViewModel's UI state. This is the only screen with a local mutable state variable (besides thecopiedflag for the copy button feedback). -
copiedauto-reset βLaunchedEffect(copied)resets the "Copied!" label after 2 seconds usingdelay(2_000). This provides brief visual feedback without requiring user interaction. -
URL field with clear button β trailing
IconButtonwithIcons.Filled.Clearthat clears the URL and resets the sync state. -
URL field auto-resets on edit β if the user types in the URL field while a sync is in progress (
syncState !is Idle), the ViewModel is reset toIdlefirst. This prevents stale loading states. -
Blank URL auto-fills β both the "Sync Now" button and the "Go" IME action check
if (url.isBlank()) url = DEFAULT_URLbefore callingvm.syncTime(url). -
Result/error animated visibility β
fadeIn(tween(300)) + slideInVertically(tween(300)) { it / 3 }β slides in from below with a 1/3 offset, creating a smooth entrance.
TimeSyncResultCard β color-coded offset display
@Composable
private fun TimeSyncResultCard(
result: TimeSyncResult,
copied: Boolean,
onCopy: () -> Unit,
) {
val offsetAbs = abs(result.offsetMillis)
val offsetColor = when {
offsetAbs < 100L -> MaterialTheme.colorScheme.secondary
offsetAbs < 500L -> Color(0xFFE65100) // deep-orange
else -> MaterialTheme.colorScheme.error
}
val offsetSign = if (result.offsetMillis >= 0) "+" else ""
val offsetLabel = when {
result.offsetMillis > 0L -> "Local clock is behind server"
result.offsetMillis < 0L -> "Local clock is ahead of server"
else -> "Clocks are in sync"
}Offset color coding
| Offset magnitude | Color | Meaning |
|---|---|---|
< 100 ms | secondary (blue/teal) | Clocks are well synchronized |
100β499 ms | 0xFFE65100 (deep orange) | Noticeable drift |
β₯ 500 ms | error (red) | Significant clock skew |
Metric rows
SyncMetricRow(icon = Icons.Filled.Schedule, label = "Server Time", value = serverTimeStr)
SyncMetricRow(icon = Icons.Filled.SwapHoriz, label = "Round-Trip Time", value = "${result.rttMillis} ms")
SyncMetricRow(icon = Icons.Filled.Adjust, label = "Corrected Time", value = correctedTimeStr)Each metric row uses the reusable SyncMetricRow composable: icon + label on the left (weight 1), monospace value on the right (weight 1.5).
Clock offset row β highlighted
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.Timer, tint = onSurfaceVariant)
Spacer(Modifier.width(8.dp))
Text("Clock Offset", style = bodyMedium, color = onSurfaceVariant)
}
Column(horizontalAlignment = Alignment.End, modifier = Modifier.weight(1.5f)) {
Text(
text = "$offsetSign${result.offsetMillis} ms",
style = bodyMedium.copy(fontFamily = Monospace),
fontWeight = Bold,
color = offsetColor, // β color-coded!
)
Text(
text = offsetLabel,
style = bodySmall,
color = offsetColor.copy(alpha = 0.8f),
)
}
}The offset is the most important value in the result β it's displayed in bold monospace with the color-coded magnitude, and the semantic label ("Local clock is behind server") underneath.
Copy to clipboard
OutlinedButton(
onClick = onCopy,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.CheckCircle else Icons.Filled.ContentCopy,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(6.dp))
Text(if (copied) "Copied!" else "Copy Offset ($offsetSign${result.offsetMillis} ms)")
}Copies only the offset value (with sign) to the clipboard β e.g., +45 ms or -30 ms. The button text changes to "Copied!" with a checkmark icon for 2 seconds.
ErrorCard β with retry
@Composable
private fun ErrorCard(message: String, onRetry: () -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = errorContainer),
) {
Column {
Row {
Icon(Icons.Filled.Error, tint = error)
Text("Sync Failed")
Text(message)
}
OutlinedButton(onClick = onRetry) {
Icon(Icons.Filled.Refresh)
Text("Retry")
}
}
}
}errorContainerbackground,errortint for the icon- Shows the error message from the ViewModel
- "Retry" button re-runs the sync with the current URL
History row β success/failure indicators
@Composable
private fun HistoryRow(
entry: GoogleTimeSyncHistoryEntry,
onClick: () -> Unit,
) {
val offsetSign = if (entry.offsetMs >= 0) "+" else ""
Row(
modifier = Modifier.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 10.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(entry.timestamp, fontFamily = Monospace)
Text("\t${entry.url}", fontFamily = Monospace, color = primary)
if (entry.success) {
Text(
text = "\tRTT ${entry.rttMs} ms Β· offset $offsetSign${entry.offsetMs} ms",
style = bodySmall,
fontFamily = Monospace,
color = onSurfaceVariant,
)
}
}
Spacer(Modifier.width(8.dp))
Icon(
imageVector = if (entry.success) Icons.Filled.CheckCircle else Icons.Filled.Error,
contentDescription = if (entry.success) "Success" else "Failed",
tint = if (entry.success) secondary else error,
modifier = Modifier.size(20.dp),
)
}
}- Success entries show the RTT and offset on a third line
- Failed entries show only the URL (no RTT/offset, since they're 0)
- Right-aligned checkmark (green) or error (red) icon
Footer helper text
Text(
text = "Offset = corrected server time β your device time",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)A subtle explanation of what "offset" means, displayed at 60% opacity at the bottom of the screen.
Test Coverage
The Google Time Sync feature has dedicated unit tests in GoogleTimeSyncViewModelTest (~14 tests) covering:
| Test | What it verifies |
|---|---|
initial state is Idle with empty history | Default state |
syncTime with blank URL uses default URL | Blank URL β DEFAULT_URL |
syncTime trims whitespace from URL | Whitespace trimming |
syncTime sets Loading state then Success on completion | State transitions (Idle β Loading β Success) |
syncTime handles NoNetwork error | NoNetwork β Error("No network connection") |
syncTime handles Timeout error | Timeout β Error("Request timed out") |
syncTime handles HttpError | HttpError(500) β Error("HTTP error 500") |
syncTime handles ParseError | ParseError β Error("Parse error: β¦") |
syncTime handles generic Error | Error β Error("Connection refused") |
syncTime saves history on completion | History save verification |
reset cancels job and sets Idle state | Reset to Idle |
selectHistoryEntry calls callback and syncs | History re-query via callback |
history is deduplicated by URL | Same URL β 1 history entry |
history is capped at 5 entries | 6 different URLs β 5 entries |
Notable test patterns:
- Uses
@Before/@AfterforDispatchers.setMain(testDispatcher)/Dispatchers.resetMain()(JUnit lifecycle instead of per-test setup) - Mocks
repository.fetchGoogleTime(any())to return specificGoogleTimeSyncResultvariants coVerify { repository.fetchGoogleTime(GoogleTimeSyncRepository.DEFAULT_URL) }to confirm the correct URL was usedcoVerify { historyStore.save(any()) }to confirm history persistence
Data Flow Summary
User taps "Sync Now" (or presses "Go")
β
ββ if (url.isBlank()) url = DEFAULT_URL
ββ vm.syncTime(url)
β β
β ββ effectiveUrl = url.trim().ifBlank { DEFAULT_URL }
β ββ syncJob?.cancel()
β ββ _uiState.value = Loading
β β
β ββ viewModelScope.launch {
β withTimeout(userSetting * 1000) {
β repository.fetchGoogleTime(effectiveUrl)
β β
β ββ T1 = System.currentTimeMillis()
β ββ proxyResolver?.resolveProxy(url) β proxy?
β ββ URL(url).openConnection(proxy) β HttpURLConnection
β ββ GET, Accept: application/json
β ββ responseCode == 200?
β β ββ No β HttpError(code, url)
β β ββ Yes β read body
β ββ rawBody.substring(4).trim() β strip XSSI
β ββ JSONObject(rawBody)
β ββ serverTime = json.getLong("current_time_millis")
β ββ T4 = System.currentTimeMillis()
β β
β ββ Compute:
β RTT = T4 β T1
β correctedServerTime = serverTime + RTT / 2
β offset = correctedServerTime β T4
β β Success(TimeSyncResult)
β β
β ββ Catch:
β SocketTimeoutException β Timeout(url)
β IOException (unreachable) β NoNetwork
β IOException (other) β Error(msg)
β Exception β Error(msg)
β
ββ Map result β syncState + historyEntry
ββ updatedHistory = [newEntry] + history.filter { it.url != url }.take(5)
ββ _uiState.value = copy(syncState, history)
ββ historyStore.save(updatedHistory)