From b1c8e19fd8f6b17ab9866bc7f188968185564ebb Mon Sep 17 00:00:00 2001 From: tekstrand Date: Thu, 24 Sep 2026 18:52:49 -0500 Subject: [PATCH] Added a dialog for setting the time drift by hand. Monitor could sync the clock to the next decode or reset the offset, but there was no way to enter one. A device whose clock is more than a couple of seconds off had no way back: the decoder only searches about two and a half seconds either side of where it expects a frame, so nothing decodes, and sync-from-decode never gets a decode to work from. Monitor's overflow menu gains Adjust time drift, and tapping the drift reading on the status strip opens the same dialog. The reading gets its own view in the strip's second row so that only the time is a tap target, and the TX offset beside it stays free for a frequency dialog later. The separator between them carries real spaces rather than padding, so the gaps match the separator inside the power and offset text at any font scale. The dialog takes an offset in milliseconds, with one second and 100 ms nudge buttons, and its Reset clears the field without applying anything. It refuses a value outside plus or minus thirty seconds: the RX ring aligns to the UTC minute, so a larger offset wraps onto a smaller one and is never the value the user meant. Setting goes through the service's existing ACTION_SET_TIME_DRIFT, the same path as Reset time drift and the decode list's sync, so the engine, the saved preference and the heartbeat schedule all update as before, and a value set with the engine stopped is restored when it starts. The dialog prefills from that saved preference rather than from the strip, because the strip's copy only changes on a broadcast and reads 0 ms after a relaunch until the engine starts. Verified on the phone emulator: a value set with the engine stopped was restored on start, a value set while running applied at once, an out-of-range value was refused with the dialog left open, taps on the power, the TX offset and the separator open nothing while a tap on the drift opens the dialog, the tap still worked after a tab round trip, and the dialog works in landscape. --- .../com/js8call/example/ui/MonitorFragment.kt | 96 ++++++++++++++--- .../src/main/res/layout/dialog_time_drift.xml | 101 ++++++++++++++++++ .../main/res/layout/monitor_status_card.xml | 29 ++++- .../src/main/res/menu/monitor_overflow.xml | 4 + android/app/src/main/res/values/strings.xml | 16 ++- 5 files changed, 228 insertions(+), 18 deletions(-) create mode 100644 android/app/src/main/res/layout/dialog_time_drift.xml diff --git a/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt b/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt index ceadbfc0..4e114c24 100644 --- a/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt +++ b/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt @@ -11,13 +11,17 @@ import android.view.ViewGroup import android.widget.ImageView import android.widget.PopupMenu import android.widget.TextView +import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider +import androidx.preference.PreferenceManager import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.materialswitch.MaterialSwitch import com.google.android.material.snackbar.Snackbar +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout import com.js8call.example.R import com.js8call.example.model.EngineState import com.js8call.example.model.MonitorStatus @@ -40,6 +44,7 @@ class MonitorFragment : Fragment() { private lateinit var frequencyButton: MaterialButton private lateinit var powerSwitch: MaterialSwitch private lateinit var telemetryText: TextView + private lateinit var driftText: TextView // Frequency management private var frequencyEntries = listOf() @@ -78,6 +83,7 @@ class MonitorFragment : Fragment() { frequencyButton = view.findViewById(R.id.frequency_button) powerSwitch = view.findViewById(R.id.power_switch) telemetryText = view.findViewById(R.id.telemetry_text) + driftText = view.findViewById(R.id.drift_text) // Navigation keeps this fragment instance but recreates its views at // their layout defaults, so the repaint memo has to reset with them or @@ -113,6 +119,7 @@ class MonitorFragment : Fragment() { frequencyButton.setOnClickListener { showFrequencyDialog() } view.findViewById(R.id.monitor_overflow) .setOnClickListener { showOverflowMenu(it) } + driftText.setOnClickListener { showTimeDriftDialog() } } override fun onResume() { @@ -214,21 +221,17 @@ class MonitorFragment : Fragment() { } private fun renderTelemetry(status: MonitorStatus) { - val drift = if (status.timeDriftMs != 0L) { - String.format("%+d ms", status.timeDriftMs) - } else { - "0 ms" - } - val offsetAndDrift = getString( - R.string.monitor_telemetry, - status.txOffsetHz.toInt(), - drift - ) + val offset = getString(R.string.monitor_telemetry, status.txOffsetHz.toInt()) // A stopped engine reads no power, and a leading placeholder just adds noise telemetryText.text = if (status.powerDb != 0f) { - getString(R.string.monitor_telemetry_power, status.powerDb, offsetAndDrift) + getString(R.string.monitor_telemetry_power, status.powerDb, offset) } else { - offsetAndDrift + offset + } + driftText.text = if (status.timeDriftMs != 0L) { + String.format("%+d ms", status.timeDriftMs) + } else { + "0 ms" } } @@ -245,6 +248,10 @@ class MonitorFragment : Fragment() { armTimeSync() true } + R.id.action_time_drift_adjust -> { + showTimeDriftDialog() + true + } R.id.action_time_drift_reset -> { resetTimeDrift() true @@ -264,13 +271,73 @@ class MonitorFragment : Fragment() { } private fun resetTimeDrift() { + applyTimeDrift(0L) + } + + private fun applyTimeDrift(driftMs: Long) { val intent = Intent(requireContext(), JS8EngineService::class.java).apply { action = JS8EngineService.ACTION_SET_TIME_DRIFT - putExtra(JS8EngineService.EXTRA_TIME_DRIFT_MS, 0L) + putExtra(JS8EngineService.EXTRA_TIME_DRIFT_MS, driftMs) } requireContext().startService(intent) } + private fun showTimeDriftDialog() { + val view = layoutInflater.inflate(R.layout.dialog_time_drift, null) + val input = view.findViewById(R.id.drift_input) + val inputLayout = view.findViewById(R.id.drift_input_layout) + + // Not the strip's copy, which reads 0 after a relaunch + val currentMs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getLong(JS8EngineService.PREF_TIME_DRIFT_MS, 0L) + input.setText(currentMs.toString()) + input.setSelection(input.text?.length ?: 0) + + fun nudge(deltaMs: Long) { + val current = input.text?.toString()?.toLongOrNull() ?: 0L + input.setText((current + deltaMs).coerceIn(-DRIFT_LIMIT_MS, DRIFT_LIMIT_MS).toString()) + input.setSelection(input.text?.length ?: 0) + inputLayout.error = null + } + view.findViewById(R.id.nudge_minus_second).setOnClickListener { nudge(-1000L) } + view.findViewById(R.id.nudge_minus_fine).setOnClickListener { nudge(-100L) } + view.findViewById(R.id.nudge_plus_fine).setOnClickListener { nudge(100L) } + view.findViewById(R.id.nudge_plus_second).setOnClickListener { nudge(1000L) } + + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.monitor_time_drift_title) + .setView(view) + .setPositiveButton(R.string.monitor_time_drift_set, null) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.monitor_time_drift_reset_action, null) + .create() + + dialog.setOnShowListener { + // Set here, not in the builder, so a bad value keeps the dialog open + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val value = input.text?.toString()?.toLongOrNull() + if (value == null || value < -DRIFT_LIMIT_MS || value > DRIFT_LIMIT_MS) { + inputLayout.error = getString( + R.string.monitor_time_drift_range, -DRIFT_LIMIT_MS.toInt(), DRIFT_LIMIT_MS.toInt() + ) + return@setOnClickListener + } + applyTimeDrift(value) + Snackbar.make( + requireView(), + getString(R.string.monitor_time_drift_applied, value.toInt()), + Snackbar.LENGTH_SHORT + ).show() + dialog.dismiss() + } + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { + input.setText("0") + inputLayout.error = null + } + } + dialog.show() + } + private fun startMonitoring() { // Starting an engine that is already up tears down its audio capture val state = viewModel.status.value?.state @@ -498,5 +565,8 @@ class MonitorFragment : Fragment() { companion object { private const val REQUEST_AUDIO_PERMISSION = 1 + + // The RX ring aligns to the UTC minute, so a larger offset wraps + private const val DRIFT_LIMIT_MS = 30_000L } } diff --git a/android/app/src/main/res/layout/dialog_time_drift.xml b/android/app/src/main/res/layout/dialog_time_drift.xml new file mode 100644 index 00000000..ba03c7d1 --- /dev/null +++ b/android/app/src/main/res/layout/dialog_time_drift.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/monitor_status_card.xml b/android/app/src/main/res/layout/monitor_status_card.xml index 8536a0bd..ad8eeb06 100644 --- a/android/app/src/main/res/layout/monitor_status_card.xml +++ b/android/app/src/main/res/layout/monitor_status_card.xml @@ -88,14 +88,35 @@ + tools:text="-18.0 dB · TX 1500 Hz" /> + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 259044c5..be2119d9 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -21,11 +21,25 @@ Error Monitoring More options - TX %1$d Hz · %2$s + TX %1$d Hz + " · " %1$.1f dB · %2$s Audio device Sync clock to next decode + Adjust time drift… Reset time drift + Time drift + Offset + ms + −1s + −100 + +100 + +1s + Shifts decode windows and transmit timing without changing the device clock. Negative when the clock runs fast. + Set + Reset + Time drift %+d ms + Enter %1$+d to %2$+d ms Switching to %s… Audio device: %s %s was removed. Monitoring stopped.