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 +}