diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/PassClock.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/PassClock.kt
new file mode 100644
index 00000000..f0b76545
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/PassClock.kt
@@ -0,0 +1,122 @@
+/*
+ * Look4Sat. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2019-2026 Arty Bishop and contributors.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.rtbishop.look4sat.core.domain.wavelog
+
+/**
+ * Works out what time a contact should carry.
+ *
+ * The logging screen used to stamp System.currentTimeMillis() and offer no way to change it. That
+ * assumes contacts are typed as they happen, and serious satellite operators do not work that way:
+ * the documented practice is to record the pass and transcribe it afterwards, because during eight
+ * minutes of a linear transponder there is no spare attention for a keyboard. A fixed clock makes
+ * every transcribed contact wrong by however long the transcription took.
+ *
+ * Two ways to say when: an absolute UTC time of day, or an offset from now. Both are typed into the
+ * same field, because a separate widget for each is more to reach for than an operator wants while
+ * holding an antenna.
+ */
+object PassClock {
+
+ /** What a typed token meant. */
+ sealed interface Command {
+
+ /** Log at this UTC time of day. [minuteOfDay] is minutes since 00:00 UTC. */
+ data class At(val minuteOfDay: Int) : Command
+
+ /** Log this many minutes from now. Negative counts backwards. */
+ data class Shift(val minutes: Int) : Command
+
+ /** Back to the current time. */
+ data object Live : Command
+
+ /** Not a time instruction. The caller should leave the clock alone. */
+ data object Unrecognised : Command
+ }
+
+ /**
+ * Interpret a typed token.
+ *
+ * Deliberately narrow. Anything that is not clearly a time is [Command.Unrecognised] rather than
+ * a guess, because a mis-parsed time silently backdates a contact and nothing downstream would
+ * catch it.
+ *
+ * Accepted: `14:55` or `1455` for a UTC time of day; `+3` or `-2` for a shift in minutes, with
+ * an optional `m`; empty or `now` to return to live time.
+ */
+ fun parse(entry: String): Command {
+ val text = entry.trim().lowercase()
+ if (text.isEmpty() || text == "now") return Command.Live
+ if (text.startsWith("+") || text.startsWith("-")) return parseShift(text)
+ return parseTimeOfDay(text)
+ }
+
+ /** `+3`, `-2m`, `+15`. */
+ private fun parseShift(text: String): Command {
+ val negative = text.startsWith("-")
+ val digits = text.drop(1).removeSuffix("m")
+ val minutes = digits.toIntOrNull() ?: return Command.Unrecognised
+ if (minutes > MAX_SHIFT_MINUTES) return Command.Unrecognised
+ return Command.Shift(if (negative) -minutes else minutes)
+ }
+
+ /** `14:55` or `1455`. */
+ private fun parseTimeOfDay(text: String): Command {
+ val digits = text.replace(":", "")
+ if (digits.length != TIME_DIGITS || digits.any { !it.isDigit() }) return Command.Unrecognised
+ val hours = digits.take(2).toInt()
+ val minutes = digits.drop(2).toInt()
+ if (hours > MAX_HOUR || minutes > MAX_MINUTE) return Command.Unrecognised
+ return Command.At(hours * MINUTES_PER_HOUR + minutes)
+ }
+
+ /**
+ * Apply a command, returning the timestamp a contact should carry.
+ *
+ * [now] is the current UTC time in milliseconds and [dayStart] is midnight UTC of the day [now]
+ * falls in - passed in rather than computed, because core:domain holds no calendar and the
+ * caller already knows which day it is working with.
+ *
+ * An absolute time later than [now] is read as belonging to the previous day: transcription
+ * happens after the pass, so a pass that ran across midnight UTC is the common case, not an
+ * error. Without this a contact logged at 23:58 while transcribing at 00:05 would land a full
+ * day in the future.
+ */
+ fun resolve(command: Command, now: Long, dayStart: Long): Long = when (command) {
+ is Command.At -> {
+ val candidate = dayStart + command.minuteOfDay * MILLIS_PER_MINUTE
+ if (candidate > now) candidate - MILLIS_PER_DAY else candidate
+ }
+ is Command.Shift -> now + command.minutes * MILLIS_PER_MINUTE
+ Command.Live, Command.Unrecognised -> now
+ }
+
+ /** Whether a command moves the clock off live time, so the UI can show that it has. */
+ fun isHolding(command: Command): Boolean =
+ command is Command.At || (command is Command.Shift && command.minutes != 0)
+
+ private const val TIME_DIGITS = 4
+ private const val MAX_HOUR = 23
+ private const val MAX_MINUTE = 59
+ private const val MINUTES_PER_HOUR = 60
+
+ /** A pass lasts minutes. Anything larger is a typo, not an intention. */
+ private const val MAX_SHIFT_MINUTES = 720
+
+ private const val MILLIS_PER_MINUTE = 60_000L
+ private const val MILLIS_PER_DAY = 86_400_000L
+}
diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/PassClockTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/PassClockTest.kt
new file mode 100644
index 00000000..49d57e72
--- /dev/null
+++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/PassClockTest.kt
@@ -0,0 +1,130 @@
+package com.rtbishop.look4sat.core.domain.wavelog
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The screen used to stamp the current time with no way to change it, which is wrong for the way
+ * satellite operators actually work: record the pass, transcribe it afterwards. Every transcribed
+ * contact was then off by however long the transcription took.
+ *
+ * The parse is deliberately narrow. A mis-parsed time silently backdates a contact and nothing
+ * downstream would notice, so anything unclear has to be rejected rather than guessed.
+ */
+class PassClockTest {
+
+ private val dayStart = 1_800_000_000_000L // some midnight UTC
+ private val minute = 60_000L
+ private val hour = 60 * minute
+
+ @Test
+ fun `a colon separated time is understood`() {
+ assertEquals(PassClock.Command.At(14 * 60 + 55), PassClock.parse("14:55"))
+ }
+
+ @Test
+ fun `a bare four digit time is understood`() {
+ assertEquals(PassClock.Command.At(14 * 60 + 55), PassClock.parse("1455"))
+ }
+
+ @Test
+ fun `midnight and the last minute of the day both parse`() {
+ assertEquals(PassClock.Command.At(0), PassClock.parse("00:00"))
+ assertEquals(PassClock.Command.At(23 * 60 + 59), PassClock.parse("23:59"))
+ }
+
+ @Test
+ fun `an impossible time is not guessed at`() {
+ assertEquals(PassClock.Command.Unrecognised, PassClock.parse("24:00"))
+ assertEquals(PassClock.Command.Unrecognised, PassClock.parse("12:60"))
+ assertEquals(PassClock.Command.Unrecognised, PassClock.parse("99:99"))
+ }
+
+ @Test
+ fun `a callsign shaped entry is not a time`() {
+ assertEquals(PassClock.Command.Unrecognised, PassClock.parse("BG7NTA"))
+ assertEquals(PassClock.Command.Unrecognised, PassClock.parse("59"))
+ assertEquals(PassClock.Command.Unrecognised, PassClock.parse("OL72AP"))
+ }
+
+ @Test
+ fun `a shift in minutes is understood in both directions`() {
+ assertEquals(PassClock.Command.Shift(3), PassClock.parse("+3"))
+ assertEquals(PassClock.Command.Shift(-2), PassClock.parse("-2"))
+ assertEquals(PassClock.Command.Shift(15), PassClock.parse("+15m"))
+ }
+
+ /** A pass lasts minutes, so a huge shift is a typo rather than an intention. */
+ @Test
+ fun `an absurd shift is rejected`() {
+ assertEquals(PassClock.Command.Unrecognised, PassClock.parse("+99999"))
+ assertEquals(PassClock.Command.Unrecognised, PassClock.parse("+x"))
+ }
+
+ @Test
+ fun `empty or now returns to live time`() {
+ assertEquals(PassClock.Command.Live, PassClock.parse(""))
+ assertEquals(PassClock.Command.Live, PassClock.parse(" "))
+ assertEquals(PassClock.Command.Live, PassClock.parse("now"))
+ assertEquals(PassClock.Command.Live, PassClock.parse("NOW"))
+ }
+
+ @Test
+ fun `live time resolves to now`() {
+ val now = dayStart + 10 * hour
+ assertEquals(now, PassClock.resolve(PassClock.Command.Live, now, dayStart))
+ }
+
+ /** An unrecognised entry must not move the clock. */
+ @Test
+ fun `an unrecognised command leaves the time alone`() {
+ val now = dayStart + 10 * hour
+ assertEquals(now, PassClock.resolve(PassClock.Command.Unrecognised, now, dayStart))
+ }
+
+ @Test
+ fun `an absolute time earlier today resolves to today`() {
+ val now = dayStart + 14 * hour
+ val resolved = PassClock.resolve(PassClock.Command.At(10 * 60 + 30), now, dayStart)
+ assertEquals(dayStart + 10 * hour + 30 * minute, resolved)
+ }
+
+ /**
+ * The case that would otherwise put a contact a day in the future. Transcribing at 00:05 UTC a
+ * pass that ran at 23:58 is normal, not an error - passes cross midnight UTC routinely.
+ */
+ @Test
+ fun `an absolute time later than now belongs to the previous day`() {
+ val now = dayStart + 5 * minute // 00:05 UTC
+ val resolved = PassClock.resolve(PassClock.Command.At(23 * 60 + 58), now, dayStart)
+ assertEquals(dayStart - 86_400_000L + 23 * hour + 58 * minute, resolved)
+ assertTrue("must be in the past", resolved < now)
+ }
+
+ @Test
+ fun `a shift moves the time by whole minutes`() {
+ val now = dayStart + 10 * hour
+ assertEquals(now + 3 * minute, PassClock.resolve(PassClock.Command.Shift(3), now, dayStart))
+ assertEquals(now - 2 * minute, PassClock.resolve(PassClock.Command.Shift(-2), now, dayStart))
+ }
+
+ /** The UI needs to show that the clock is no longer following real time. */
+ @Test
+ fun `holding is reported only when the clock has moved`() {
+ assertTrue(PassClock.isHolding(PassClock.Command.At(600)))
+ assertTrue(PassClock.isHolding(PassClock.Command.Shift(-5)))
+ assertFalse(PassClock.isHolding(PassClock.Command.Live))
+ assertFalse(PassClock.isHolding(PassClock.Command.Unrecognised))
+ assertFalse("a zero shift is still live", PassClock.isHolding(PassClock.Command.Shift(0)))
+ }
+
+ /** Whitespace and case must not change the verdict - this is typed one-handed outdoors. */
+ @Test
+ fun `surrounding whitespace does not matter`() {
+ assertEquals(PassClock.Command.At(14 * 60 + 55), PassClock.parse(" 14:55 "))
+ assertEquals(PassClock.Command.Shift(3), PassClock.parse(" +3 "))
+ assertEquals(PassClock.Command.Shift(15), PassClock.parse("+15M"))
+ }
+}
diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml
index 00923f3c..ea7c5c3c 100644
--- a/core/presentation/src/main/res/values-zh/strings.xml
+++ b/core/presentation/src/main/res/values-zh/strings.xml
@@ -278,6 +278,11 @@
对方呼号
模式
修改模式
+ 时间
+ 当前
+ 设置记录时间
+ UTC 时间如 14:55, 或 +3 / -2 分钟。留空则用当前时间。
+ 不是时间 - 将使用当前时间
已存入本地日志
已上传 %1$d 条, %2$d 条仍在队列
已上传 %1$d 条
diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml
index 53b9fb93..2ecf03e6 100644
--- a/core/presentation/src/main/res/values/strings.xml
+++ b/core/presentation/src/main/res/values/strings.xml
@@ -309,6 +309,11 @@
Callsign
Mode
Edit the mode
+ Time
+ Now
+ Set the logging time
+ UTC time such as 14:55, or +3 / -2 minutes. Empty logs at the current time.
+ Not a time - the current time will be used
Saved to local log
Uploaded %1$d, %2$d still queued
Uploaded %1$d QSO
diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt
index 2d2026ef..0702ae24 100644
--- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt
+++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt
@@ -75,6 +75,7 @@ import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
+import com.rtbishop.look4sat.core.domain.wavelog.PassClock
import com.rtbishop.look4sat.core.domain.wavelog.CallsignEntry
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
@@ -281,6 +282,11 @@ private fun ExpandedLogInput(
var mode by remember(radio.uuid) { mutableStateOf(radio.uplinkMode ?: "FM") }
var modeEditable by remember(radio.uuid) { mutableStateOf(false) }
val editModeLabel = stringResource(id = R.string.wavelog_mode_edit)
+ val editTimeLabel = stringResource(id = R.string.wavelog_time_edit)
+ // Survives rotation like workedThisSession: losing a held clock mid-transcription would put
+ // every remaining contact at the wrong time without saying so.
+ var timeEntry by rememberSaveable { mutableStateOf("") }
+ var timeEditable by rememberSaveable { mutableStateOf(false) }
// Calls logged during this pass, so a repeat can be mentioned without being blocked: the same
// station on a later pass is a legitimate new contact. This replaces a 300ms window that
// swallowed what it guessed were accidental double submissions - a guess that could discard
@@ -322,7 +328,11 @@ private fun ExpandedLogInput(
queue.add(
WavelogQso(
id = qsoId,
- timeUtcMs = System.currentTimeMillis(),
+ timeUtcMs = PassClock.resolve(
+ PassClock.parse(timeEntry),
+ System.currentTimeMillis(),
+ utcDayStart(System.currentTimeMillis())
+ ),
call = call,
mode = mode.trim().ifBlank { "FM" }.uppercase(),
freqTxHz = tx,
@@ -457,6 +467,63 @@ private fun ExpandedLogInput(
Text(text = mode, style = MaterialTheme.typography.bodyMedium)
}
}
+ // The clock. Serious operators record a pass and transcribe it afterwards, so a fixed
+ // timestamp made every transcribed contact wrong by however long the transcription took -
+ // and a pass crossing midnight UTC landed a whole day out.
+ val command = PassClock.parse(timeEntry)
+ val holding = PassClock.isHolding(command)
+ if (timeEditable) {
+ OutlinedTextField(
+ value = timeEntry,
+ onValueChange = { timeEntry = it.take(6) },
+ label = { Text(stringResource(id = R.string.wavelog_time_hint)) },
+ supportingText = {
+ Text(
+ text = stringResource(
+ if (command is PassClock.Command.Unrecognised) {
+ R.string.wavelog_time_unrecognised
+ } else {
+ R.string.wavelog_time_help
+ }
+ ),
+ style = MaterialTheme.typography.bodySmall
+ )
+ },
+ isError = command is PassClock.Command.Unrecognised,
+ singleLine = true,
+ textStyle = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.fillMaxWidth()
+ )
+ } else {
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 48.dp)
+ .clickable(role = Role.Button, onClickLabel = editTimeLabel) {
+ timeEditable = true
+ }
+ .padding(horizontal = LocalSpacing.current.extraExtraSmall)
+ ) {
+ Text(
+ text = stringResource(id = R.string.wavelog_time_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = if (holding) timeEntry.trim() else stringResource(id = R.string.wavelog_time_live),
+ style = MaterialTheme.typography.bodyMedium,
+ // A held clock is coloured, because logging at the wrong time silently is the
+ // failure this whole change exists to prevent.
+ color = if (holding) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ }
+ )
+ }
+ }
}
}
@@ -598,3 +665,19 @@ private fun buildSessionId(satName: String, aosTimeMs: Long): String {
)
return "$satName-$stamp"
}
+
+/**
+ * Midnight UTC of the day a timestamp falls in.
+ *
+ * PassClock needs this passed in: core:domain holds no calendar, so the day boundary is computed
+ * here where java.util is available.
+ */
+private fun utcDayStart(nowMs: Long): Long {
+ val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
+ cal.timeInMillis = nowMs
+ cal.set(java.util.Calendar.HOUR_OF_DAY, 0)
+ cal.set(java.util.Calendar.MINUTE, 0)
+ cal.set(java.util.Calendar.SECOND, 0)
+ cal.set(java.util.Calendar.MILLISECOND, 0)
+ return cal.timeInMillis
+}