feat(watch): persistent upload queue with sidecar pairs
Recording flow no longer uploads inline. AudioRecorder writes the m4a
straight into filesDir/recordings/unsynced/{caseId}_{utc}.m4a; the VM
follows up with an atomic sidecar (.meta.json) holding case_id + recorded_at.
Sync trigger is a SyncTrigger seam (no-op for now, fleshed out in Phase 4)
so the screen pops back to the case detail immediately after the queue
write — uploads happen out-of-band.
PendingStore mirrors doctate-client-core::upload (sidecar shape, atomic
tmp+rename, scan FIFO sorted by recorded_at, deletePair idempotent).
Audio file naming uses recorded_at_to_filename_stem semantics — colons
swapped for dashes — so paths round-trip with the desktop client and
filesystems that disallow colons.
8 new PendingStoreTest cases (atomic write, FIFO order, orphan skip,
unreadable sidecar resilience, idempotent delete). 43 tests green total.
This commit is contained in:
@@ -3,12 +3,15 @@ package com.doctate.watch
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.wear.tiles.TileService
|
||||
import com.doctate.watch.audio.PendingStore
|
||||
import com.doctate.watch.domain.CaseStore
|
||||
import com.doctate.watch.domain.DiskCaseStore
|
||||
import com.doctate.watch.net.HttpClientProvider
|
||||
import com.doctate.watch.net.UploadClient
|
||||
import com.doctate.watch.settings.BuildConfigSettings
|
||||
import com.doctate.watch.settings.Settings
|
||||
import com.doctate.watch.sync.NoopSyncTrigger
|
||||
import com.doctate.watch.sync.SyncTrigger
|
||||
import com.doctate.watch.tile.DoctateTileService
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -35,6 +38,14 @@ class DoctateApp : Application() {
|
||||
DiskCaseStore(File(filesDir, "recordings/cases"))
|
||||
}
|
||||
|
||||
/** Persistent FIFO queue of recordings waiting to be uploaded. */
|
||||
val pendingStore: PendingStore by lazy {
|
||||
PendingStore(File(filesDir, "recordings/unsynced"))
|
||||
}
|
||||
|
||||
/** Replaced by [com.doctate.watch.sync.SyncServiceLauncher] in Phase 4. */
|
||||
var syncTrigger: SyncTrigger = NoopSyncTrigger
|
||||
|
||||
/** Process-scoped coroutine scope for work that must outlive any single ViewModel
|
||||
* (e.g. the post-stop OneLiner-burst fires after the user swipes back to the list). */
|
||||
val applicationScope: CoroutineScope by lazy {
|
||||
|
||||
@@ -11,14 +11,20 @@ import java.io.File
|
||||
*
|
||||
* Format: 16 kHz mono AAC-LC at 64 kbps — matches Whisper's native sample rate
|
||||
* so the server-side remux can skip a resample stage.
|
||||
*
|
||||
* Writes directly into the caller-supplied target file so the pending
|
||||
* upload queue can sit on the audio without an intermediate copy. The
|
||||
* caller is responsible for choosing a path that survives crashes
|
||||
* (typically [PendingStore.audioPathFor]).
|
||||
*/
|
||||
class AudioRecorder(private val context: Context) {
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var currentFile: File? = null
|
||||
|
||||
fun start(): File {
|
||||
/** Start recording into [outFile]. The parent directory is created if missing. */
|
||||
fun start(outFile: File): File {
|
||||
require(recorder == null) { "recorder already active" }
|
||||
val outFile = File(context.cacheDir, "rec-${System.currentTimeMillis()}.m4a")
|
||||
outFile.parentFile?.takeIf { !it.exists() }?.mkdirs()
|
||||
val r = newRecorderInstance().apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.doctate.watch.audio
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Persistent FIFO queue of recordings waiting to be uploaded. One pair per
|
||||
* recording: `{stem}.m4a` + `{stem}.meta.json`. The audio is written by
|
||||
* [AudioRecorder] directly into [dir]; the sidecar is written here, atomic
|
||||
* tmp+rename so a crash mid-write cannot leave a partial file.
|
||||
*
|
||||
* Mirrors `doctate-client-core::upload` exactly (sidecar shape, scan rules,
|
||||
* orphan handling). Wire-compatible with the desktop client.
|
||||
*/
|
||||
class PendingStore(private val dir: File) {
|
||||
|
||||
/** Compute the target audio path for a new recording. Caller passes it to [AudioRecorder.start]. */
|
||||
fun audioPathFor(caseId: String, recordedAtUtc: String): File {
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val stem = "${caseId}_${recordedAtToFilenameStem(recordedAtUtc)}"
|
||||
return File(dir, "$stem.m4a")
|
||||
}
|
||||
|
||||
/** Sidecar path next to a `.m4a` — same dir, same stem, `.meta.json`. */
|
||||
fun sidecarPathFor(audio: File): File =
|
||||
File(audio.parentFile ?: dir, "${audio.nameWithoutExtension}.meta.json")
|
||||
|
||||
/**
|
||||
* Persist a sidecar atomically. Caller must have already written the
|
||||
* `.m4a` audio to [PendingUpload.file]; the sidecar makes the pair
|
||||
* complete and visible to [scan].
|
||||
*/
|
||||
fun writeSidecar(upload: PendingUpload) {
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val sidecar = sidecarPathFor(upload.file)
|
||||
val tmp = File(sidecar.parentFile ?: dir, "${sidecar.name}.tmp")
|
||||
val json = JSONObject().apply {
|
||||
put("case_id", upload.caseId)
|
||||
put("recorded_at", upload.recordedAt)
|
||||
}.toString(2)
|
||||
tmp.writeText(json, Charsets.UTF_8)
|
||||
Files.move(
|
||||
tmp.toPath(),
|
||||
sidecar.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan [dir] for `.m4a` files with a matching sidecar. Returns the
|
||||
* complete pairs sorted by `recordedAt` ascending — the upload worker
|
||||
* processes them FIFO so the oldest dictation wins. Orphans (m4a
|
||||
* without sidecar, unreadable sidecar) are logged and skipped — one
|
||||
* broken pair must not stop recovery of the rest.
|
||||
*/
|
||||
fun scan(): List<PendingUpload> {
|
||||
if (!dir.exists()) return emptyList()
|
||||
val files = dir.listFiles { f -> f.isFile && f.name.endsWith(".m4a") } ?: return emptyList()
|
||||
val out = mutableListOf<PendingUpload>()
|
||||
for (audio in files) {
|
||||
val sidecar = sidecarPathFor(audio)
|
||||
if (!sidecar.exists()) continue
|
||||
try {
|
||||
val obj = JSONObject(sidecar.readText(Charsets.UTF_8))
|
||||
out += PendingUpload(
|
||||
caseId = obj.getString("case_id"),
|
||||
recordedAt = obj.getString("recorded_at"),
|
||||
file = audio,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "skipping unreadable sidecar ${sidecar.name}: ${e.message}")
|
||||
}
|
||||
}
|
||||
return out.sortedBy { it.recordedAt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop both files of a pair. Idempotent — missing files are not an
|
||||
* error. Called by the sync worker after a Success or Terminal ACK.
|
||||
*/
|
||||
fun deletePair(audio: File) {
|
||||
val sidecar = sidecarPathFor(audio)
|
||||
if (audio.exists() && !audio.delete()) {
|
||||
Log.w(TAG, "failed to delete audio ${audio.name}")
|
||||
}
|
||||
if (sidecar.exists() && !sidecar.delete()) {
|
||||
Log.w(TAG, "failed to delete sidecar ${sidecar.name}")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PendingStore"
|
||||
|
||||
/** RFC3339 → filesystem-safe stem (replace `:` with `-`). 1:1 to
|
||||
* `doctate-common::timestamp::recorded_at_to_filename_stem`. */
|
||||
fun recordedAtToFilenameStem(recordedAt: String): String =
|
||||
recordedAt.replace(':', '-')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.doctate.watch.audio
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* One recording waiting to be uploaded. Mirrors
|
||||
* `doctate-client-core::PendingUpload` (Rust).
|
||||
*
|
||||
* The [file] path is not persisted in the sidecar JSON — it's recovered
|
||||
* from the sidecar's on-disk location, so the pair stays valid if the
|
||||
* pending directory is moved.
|
||||
*/
|
||||
data class PendingUpload(
|
||||
val caseId: String,
|
||||
val recordedAt: String,
|
||||
val file: File,
|
||||
)
|
||||
+39
-46
@@ -1,5 +1,6 @@
|
||||
package com.doctate.watch.presentation
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
@@ -8,10 +9,13 @@ import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.doctate.watch.DoctateApp
|
||||
import com.doctate.watch.audio.AudioRecorder
|
||||
import com.doctate.watch.audio.PendingStore
|
||||
import com.doctate.watch.audio.PendingUpload
|
||||
import com.doctate.watch.domain.CaseStore
|
||||
import com.doctate.watch.domain.OnelinerState
|
||||
import com.doctate.watch.net.UploadClient
|
||||
import com.doctate.watch.net.UploadResult
|
||||
import com.doctate.watch.sync.SyncKick
|
||||
import com.doctate.watch.sync.SyncTrigger
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -22,17 +26,14 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.Instant
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class RecordingViewModel(
|
||||
private val caseId: String,
|
||||
private val recorder: AudioRecorder,
|
||||
private val uploadClient: UploadClient,
|
||||
private val pendingStore: PendingStore,
|
||||
private val caseStore: CaseStore,
|
||||
private val syncTrigger: SyncTrigger,
|
||||
private val appContext: Context,
|
||||
private val applicationScope: CoroutineScope,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -45,6 +46,10 @@ class RecordingViewModel(
|
||||
// Guards against double-finalization when stop, discard and auto-stop race.
|
||||
private val finalizationStarted = AtomicBoolean(false)
|
||||
|
||||
// Captured once recording starts; reused by finalizeAndUpload so audio file name
|
||||
// and sidecar metadata agree on the same `recorded_at` timestamp.
|
||||
private var pendingRecordedAt: String? = null
|
||||
|
||||
fun onRecordTap() = dispatch(RecordingEvent.OnRecordTap)
|
||||
|
||||
fun onPermissionResult(granted: Boolean) {
|
||||
@@ -93,8 +98,11 @@ class RecordingViewModel(
|
||||
private fun startRecordingFlow() {
|
||||
finalizationStarted.set(false)
|
||||
recordingJob = viewModelScope.launch {
|
||||
val recordedAt = Instant.now().toString()
|
||||
val target = pendingStore.audioPathFor(caseId, recordedAt)
|
||||
try {
|
||||
withContext(Dispatchers.IO) { recorder.start() }
|
||||
withContext(Dispatchers.IO) { recorder.start(target) }
|
||||
pendingRecordedAt = recordedAt
|
||||
} catch (e: Exception) {
|
||||
dispatch(RecordingEvent.UploadFailed("Recorder failed: ${e.message}", transient = false))
|
||||
return@launch
|
||||
@@ -117,6 +125,13 @@ class RecordingViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the freshly-finished recording over to the sync queue and kick
|
||||
* the worker. The actual `POST /api/upload` happens in the foreground
|
||||
* service (Phase 4); from the UI's point of view "queued" looks like
|
||||
* "uploaded" — we surface the success state immediately and pop back
|
||||
* to the case detail.
|
||||
*/
|
||||
private suspend fun finalizeAndUpload() {
|
||||
val finalFile = try {
|
||||
withContext(Dispatchers.IO) { recorder.stop() }
|
||||
@@ -125,42 +140,19 @@ class RecordingViewModel(
|
||||
dispatch(RecordingEvent.UploadFailed("Stop failed: ${e.message}", transient = false))
|
||||
return
|
||||
}
|
||||
val recordedAt = Instant.now().toString()
|
||||
val recordedAt = pendingRecordedAt ?: Instant.now().toString()
|
||||
pendingRecordedAt = null
|
||||
try {
|
||||
when (val result = uploadClient.upload(caseId, recordedAt, finalFile)) {
|
||||
is UploadResult.Success -> {
|
||||
caseStore.markActivity(caseId)
|
||||
caseStore.markSynced(caseId)
|
||||
triggerFakeOnelinerBurst(caseId)
|
||||
dispatch(RecordingEvent.UploadSucceeded(caseId))
|
||||
}
|
||||
is UploadResult.Transient -> {
|
||||
Log.w(TAG, "upload transient fail: ${result.reason}")
|
||||
dispatch(RecordingEvent.UploadFailed(result.reason, transient = true))
|
||||
}
|
||||
is UploadResult.Terminal -> {
|
||||
Log.w(TAG, "upload terminal fail: ${result.reason}")
|
||||
dispatch(RecordingEvent.UploadFailed(result.reason, transient = false))
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
pendingStore.writeSidecar(PendingUpload(caseId, recordedAt, finalFile))
|
||||
}
|
||||
} finally {
|
||||
finalFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PoC stand-in for post-stop-burst polling. Real flow would poll
|
||||
* `/api/oneliners` with a 2s/60s budget; here we just set a placeholder
|
||||
* so the UI changes visibly once the "oneliner arrives".
|
||||
*/
|
||||
private fun triggerFakeOnelinerBurst(caseId: String) {
|
||||
applicationScope.launch {
|
||||
delay(2_000)
|
||||
val label = fakeLabelFormat.format(Date())
|
||||
caseStore.setOnelinerLocal(
|
||||
caseId,
|
||||
OnelinerState.Ready("[PoC] OneLiner $label", Instant.now().toString()),
|
||||
)
|
||||
caseStore.markActivity(caseId)
|
||||
syncTrigger.kick(appContext, SyncKick.NewPending)
|
||||
dispatch(RecordingEvent.UploadSucceeded(caseId))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "writeSidecar failed: ${e.message}")
|
||||
// Audio is on disk but no sidecar: pending_cleanup will reap the orphan.
|
||||
dispatch(RecordingEvent.UploadFailed("Queue write failed: ${e.message}", transient = false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +163,6 @@ class RecordingViewModel(
|
||||
companion object {
|
||||
private const val TAG = "RecordingVM"
|
||||
private const val MAX_SECONDS = 300
|
||||
private val fakeLabelFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
|
||||
fun factoryFor(caseId: String): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
@@ -179,8 +170,10 @@ class RecordingViewModel(
|
||||
RecordingViewModel(
|
||||
caseId = caseId,
|
||||
recorder = AudioRecorder(app),
|
||||
uploadClient = app.uploadClient,
|
||||
pendingStore = app.pendingStore,
|
||||
caseStore = app.caseStore,
|
||||
syncTrigger = app.syncTrigger,
|
||||
appContext = app,
|
||||
applicationScope = app.applicationScope,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.doctate.watch.sync
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Kick the sync worker. Implementation lives in Phase 4 (`SyncServiceLauncher`);
|
||||
* Phase 2 only needs the seam so the recording flow can call into it.
|
||||
*
|
||||
* Different kicks carry different intent — `NewPending` says the queue
|
||||
* just grew, `BurstPoll` (Phase 7) says "poll aggressively for this case
|
||||
* for the next 60 seconds".
|
||||
*/
|
||||
sealed interface SyncKick {
|
||||
data object NewPending : SyncKick
|
||||
data class BurstPoll(val caseId: String, val deadlineMs: Long) : SyncKick
|
||||
}
|
||||
|
||||
/** Indirection so the ViewModel can call into the sync service without owning a reference to it. */
|
||||
fun interface SyncTrigger {
|
||||
fun kick(context: Context, kick: SyncKick)
|
||||
}
|
||||
|
||||
/** No-op. Replaced by [com.doctate.watch.sync.SyncServiceLauncher] in Phase 4. */
|
||||
object NoopSyncTrigger : SyncTrigger {
|
||||
override fun kick(context: Context, kick: SyncKick) = Unit
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.doctate.watch.audio
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import java.io.File
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class PendingStoreTest {
|
||||
@get:Rule val tempDir = TemporaryFolder()
|
||||
|
||||
private fun newStore(): PendingStore = PendingStore(File(tempDir.root, "unsynced"))
|
||||
|
||||
private fun writeAudio(store: PendingStore, caseId: String, recordedAt: String): File {
|
||||
val audio = store.audioPathFor(caseId, recordedAt)
|
||||
audio.writeBytes(byteArrayOf(0x66, 0x74, 0x79, 0x70)) // dummy 'ftyp' bytes
|
||||
return audio
|
||||
}
|
||||
|
||||
@Test fun audioPathFor_replaces_colons_in_recorded_at() {
|
||||
val store = newStore()
|
||||
val path = store.audioPathFor("case-aaa", "2026-04-26T10:30:45Z")
|
||||
assertThat(path.name).isEqualTo("case-aaa_2026-04-26T10-30-45Z.m4a")
|
||||
}
|
||||
|
||||
@Test fun writeSidecar_atomic_no_tmp_left() {
|
||||
val store = newStore()
|
||||
val audio = writeAudio(store, "case-a", "2026-04-26T10:30:45Z")
|
||||
store.writeSidecar(PendingUpload("case-a", "2026-04-26T10:30:45Z", audio))
|
||||
|
||||
val sidecar = store.sidecarPathFor(audio)
|
||||
assertThat(sidecar.exists()).isTrue()
|
||||
val tmpArtefacts = audio.parentFile?.listFiles { f -> f.name.endsWith(".tmp") } ?: emptyArray()
|
||||
assertThat(tmpArtefacts).isEmpty()
|
||||
}
|
||||
|
||||
@Test fun scan_returns_pairs_only_when_sidecar_present() {
|
||||
val store = newStore()
|
||||
// pair 1: complete
|
||||
val a = writeAudio(store, "case-a", "2026-04-26T10:00:00Z")
|
||||
store.writeSidecar(PendingUpload("case-a", "2026-04-26T10:00:00Z", a))
|
||||
// pair 2: orphan audio (no sidecar) — must be skipped, not crash the scan
|
||||
writeAudio(store, "case-b", "2026-04-26T11:00:00Z")
|
||||
|
||||
val found = store.scan()
|
||||
assertThat(found.map { it.caseId }).containsExactly("case-a")
|
||||
}
|
||||
|
||||
@Test fun scan_skips_unreadable_sidecar_without_killing_recovery() {
|
||||
val store = newStore()
|
||||
// pair 1: complete
|
||||
val a = writeAudio(store, "case-a", "2026-04-26T10:00:00Z")
|
||||
store.writeSidecar(PendingUpload("case-a", "2026-04-26T10:00:00Z", a))
|
||||
// pair 2: corrupted sidecar
|
||||
val b = writeAudio(store, "case-b", "2026-04-26T11:00:00Z")
|
||||
store.sidecarPathFor(b).writeText("{ this is not valid json", Charsets.UTF_8)
|
||||
|
||||
val found = store.scan()
|
||||
assertThat(found.map { it.caseId }).containsExactly("case-a")
|
||||
}
|
||||
|
||||
@Test fun scan_returns_pairs_in_recordedAt_ascending_order_for_FIFO_upload() {
|
||||
val store = newStore()
|
||||
val ts3 = "2026-04-26T12:00:00Z"
|
||||
val ts1 = "2026-04-26T10:00:00Z"
|
||||
val ts2 = "2026-04-26T11:00:00Z"
|
||||
// Insert out of chronological order — scan must still sort.
|
||||
for ((id, ts) in listOf("c-c" to ts3, "c-a" to ts1, "c-b" to ts2)) {
|
||||
val audio = writeAudio(store, id, ts)
|
||||
store.writeSidecar(PendingUpload(id, ts, audio))
|
||||
}
|
||||
val recorded = store.scan().map { it.recordedAt }
|
||||
assertThat(recorded).isInOrder() // ascending
|
||||
}
|
||||
|
||||
@Test fun scan_returns_empty_for_missing_dir() {
|
||||
val missing = PendingStore(File(tempDir.root, "does-not-exist"))
|
||||
assertThat(missing.scan()).isEmpty()
|
||||
}
|
||||
|
||||
@Test fun deletePair_idempotent_even_when_files_missing() {
|
||||
val store = newStore()
|
||||
val audio = writeAudio(store, "case-x", "2026-04-26T10:00:00Z")
|
||||
store.writeSidecar(PendingUpload("case-x", "2026-04-26T10:00:00Z", audio))
|
||||
// First delete: both files go.
|
||||
store.deletePair(audio)
|
||||
assertThat(audio.exists()).isFalse()
|
||||
assertThat(store.sidecarPathFor(audio).exists()).isFalse()
|
||||
// Second delete: no exception.
|
||||
store.deletePair(audio)
|
||||
}
|
||||
|
||||
@Test fun roundtrip_via_scan_recovers_caseId_and_recordedAt() {
|
||||
val store = newStore()
|
||||
val audio = writeAudio(store, "case-a", "2026-04-26T10:00:00Z")
|
||||
store.writeSidecar(PendingUpload("case-a", "2026-04-26T10:00:00Z", audio))
|
||||
|
||||
val found = store.scan().single()
|
||||
assertThat(found.caseId).isEqualTo("case-a")
|
||||
assertThat(found.recordedAt).isEqualTo("2026-04-26T10:00:00Z")
|
||||
assertThat(found.file.absolutePath).isEqualTo(audio.absolutePath)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user