Refactor timestamp generation to use helper function
Introduces `PendingStore.nowRfc3339()` to consistently generate RFC3339 timestamps with second granularity. This avoids potential issues with sub-second precision in filenames and aligns with common timestamp formatting practices. The helper function is used in `CaseDetailScreen` and `RecordingViewModel` to replace direct calls to `Instant.now().toString()`. A new unit test verifies the expected behavior of the helper function.
This commit is contained in:
@@ -19,6 +19,10 @@
|
|||||||
|
|
||||||
* CRITICAL: "cargo clippy" hat immer Recht!
|
* CRITICAL: "cargo clippy" hat immer Recht!
|
||||||
|
|
||||||
|
## Java
|
||||||
|
|
||||||
|
* Java Home ist in /opt/android-studio/jbr
|
||||||
|
|
||||||
## HTML
|
## HTML
|
||||||
|
|
||||||
* Innerhalb von HTML-Seiten sind JS-Einbettungen ausdrücklich erlaubt und gewünscht, wenn sie für den modernen Look-And-Feel der Webseite erforderlich sind.
|
* Innerhalb von HTML-Seiten sind JS-Einbettungen ausdrücklich erlaubt und gewünscht, wenn sie für den modernen Look-And-Feel der Webseite erforderlich sind.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import android.util.Log
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.StandardCopyOption
|
import java.nio.file.StandardCopyOption
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,5 +101,15 @@ class PendingStore(private val dir: File) {
|
|||||||
* `doctate-common::timestamp::recorded_at_to_filename_stem`. */
|
* `doctate-common::timestamp::recorded_at_to_filename_stem`. */
|
||||||
fun recordedAtToFilenameStem(recordedAt: String): String =
|
fun recordedAtToFilenameStem(recordedAt: String): String =
|
||||||
recordedAt.replace(':', '-')
|
recordedAt.replace(':', '-')
|
||||||
|
|
||||||
|
/** Current UTC time as RFC3339 with second granularity, e.g.
|
||||||
|
* `2026-04-15T10:32:00Z`. 1:1 to
|
||||||
|
* `doctate-common::timestamp::now_rfc3339`. The truncation is
|
||||||
|
* load-bearing: the server's filename parser expects exactly
|
||||||
|
* `YYYY-MM-DDTHH-MM-SSZ` after the `:` → `-` substitution, and
|
||||||
|
* `Instant.now().toString()` would otherwise leak microseconds
|
||||||
|
* from the platform clock. */
|
||||||
|
fun nowRfc3339(): String =
|
||||||
|
Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import androidx.wear.compose.material3.ScreenScaffold
|
|||||||
import androidx.wear.compose.material3.Text
|
import androidx.wear.compose.material3.Text
|
||||||
import androidx.wear.input.RemoteInputIntentHelper
|
import androidx.wear.input.RemoteInputIntentHelper
|
||||||
import com.doctate.watch.DoctateApp
|
import com.doctate.watch.DoctateApp
|
||||||
|
import com.doctate.watch.audio.PendingStore
|
||||||
import com.doctate.watch.domain.OnelinerState
|
import com.doctate.watch.domain.OnelinerState
|
||||||
import com.doctate.watch.domain.displayText
|
import com.doctate.watch.domain.displayText
|
||||||
import com.doctate.watch.domain.isManual
|
import com.doctate.watch.domain.isManual
|
||||||
@@ -79,7 +80,7 @@ fun CaseDetailScreen(
|
|||||||
// Optimistic local update — UI flips to Manual immediately.
|
// Optimistic local update — UI flips to Manual immediately.
|
||||||
app.caseStore.setOnelinerLocal(
|
app.caseStore.setOnelinerLocal(
|
||||||
caseId,
|
caseId,
|
||||||
OnelinerState.Manual(text, setAt = Instant.now().toString()),
|
OnelinerState.Manual(text, setAt = PendingStore.nowRfc3339()),
|
||||||
)
|
)
|
||||||
// Push the edit to the server. Fire-and-forget: the local
|
// Push the edit to the server. Fire-and-forget: the local
|
||||||
// state stays even if the network is down. Edits before
|
// state stays even if the network is down. Edits before
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import com.doctate.watch.audio.PendingUpload
|
|||||||
import com.doctate.watch.domain.CaseStore
|
import com.doctate.watch.domain.CaseStore
|
||||||
import com.doctate.watch.sync.SyncKick
|
import com.doctate.watch.sync.SyncKick
|
||||||
import com.doctate.watch.sync.SyncTrigger
|
import com.doctate.watch.sync.SyncTrigger
|
||||||
import java.time.Instant
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -98,7 +97,7 @@ class RecordingViewModel(
|
|||||||
private fun startRecordingFlow() {
|
private fun startRecordingFlow() {
|
||||||
finalizationStarted.set(false)
|
finalizationStarted.set(false)
|
||||||
recordingJob = viewModelScope.launch {
|
recordingJob = viewModelScope.launch {
|
||||||
val recordedAt = Instant.now().toString()
|
val recordedAt = PendingStore.nowRfc3339()
|
||||||
val target = pendingStore.audioPathFor(caseId, recordedAt)
|
val target = pendingStore.audioPathFor(caseId, recordedAt)
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) { recorder.start(target) }
|
withContext(Dispatchers.IO) { recorder.start(target) }
|
||||||
@@ -140,7 +139,7 @@ class RecordingViewModel(
|
|||||||
dispatch(RecordingEvent.UploadFailed("Stop failed: ${e.message}", transient = false))
|
dispatch(RecordingEvent.UploadFailed("Stop failed: ${e.message}", transient = false))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val recordedAt = pendingRecordedAt ?: Instant.now().toString()
|
val recordedAt = pendingRecordedAt ?: PendingStore.nowRfc3339()
|
||||||
pendingRecordedAt = null
|
pendingRecordedAt = null
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -100,4 +100,17 @@ class PendingStoreTest {
|
|||||||
assertThat(found.recordedAt).isEqualTo("2026-04-26T10:00:00Z")
|
assertThat(found.recordedAt).isEqualTo("2026-04-26T10:00:00Z")
|
||||||
assertThat(found.file.absolutePath).isEqualTo(audio.absolutePath)
|
assertThat(found.file.absolutePath).isEqualTo(audio.absolutePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Regression guard: subsecond precision must be stripped. A single
|
||||||
|
* microsecond leak would feed the server filename parser
|
||||||
|
* `YYYY-MM-DDTHH-MM-SS.NNNNNNZ.m4a`, which it treats as malformed —
|
||||||
|
* the resulting `<time datetime>` is empty and the day-grouping
|
||||||
|
* falls back to `today`, producing duplicate "Heute" headers.
|
||||||
|
* Mirrors `now_rfc3339_has_no_subsecond_component` in the Rust
|
||||||
|
* `doctate-common::timestamp` crate. */
|
||||||
|
@Test fun nowRfc3339_has_no_subsecond_component() {
|
||||||
|
val now = PendingStore.nowRfc3339()
|
||||||
|
assertThat(now).doesNotContain(".")
|
||||||
|
assertThat(now).endsWith("Z")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user